Skip to content

Commit 102e48b

Browse files
authored
Merge pull request #1756 from wix/feat/TimelineScrollToNow
Support initial scroll to current time
2 parents 17cb7aa + b847032 commit 102e48b

File tree

6 files changed

+135
-56
lines changed

6 files changed

+135
-56
lines changed

example/src/screens/timelineCalendar.tsx

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,94 +10,105 @@ import {
1010
} from 'react-native-calendars';
1111
import _ from 'lodash';
1212

13+
const INITIAL_TIME = {hour: 9, minutes: 0};
14+
const today = new Date();
15+
const getDate = (offset = 0) => CalendarUtils.getCalendarDateString(new Date().setDate(today.getDate() + offset));
16+
1317
const EVENTS: TimelineEventProps[] = [
1418
{
15-
start: '2017-09-06 01:30:00',
16-
end: '2017-09-06 02:30:00',
19+
start: `${getDate(-1)} 09:20:00`,
20+
end: `${getDate(-1)} 12:00:00`,
21+
title: 'Merge Request to React Native Calendars',
22+
summary: 'Merge Timeline Calendar to React Native Calendars'
23+
},
24+
{
25+
start: `${getDate()} 01:30:00`,
26+
end: `${getDate()} 02:30:00`,
1727
title: 'Dr. Mariana Joseph',
1828
summary: '3412 Piedmont Rd NE, GA 3032',
1929
color: '#e6add8'
2030
},
2131
{
22-
start: '2017-09-07 00:30:00',
23-
end: '2017-09-07 01:30:00',
32+
start: `${getDate(1)} 00:30:00`,
33+
end: `${getDate(1)} 01:30:00`,
2434
title: 'Visit Grand Mother',
2535
summary: 'Visit Grand Mother and bring some fruits.',
2636
color: '#ade6d8'
2737
},
2838
{
29-
start: '2017-09-07 02:30:00',
30-
end: '2017-09-07 03:20:00',
39+
start: `${getDate(1)} 02:30:00`,
40+
end: `${getDate(1)} 03:20:00`,
3141
title: 'Meeting with Prof. Behjet Zuhaira',
3242
summary: 'Meeting with Prof. Behjet at 130 in her office.',
3343
color: '#e6add8'
3444
},
3545
{
36-
start: '2017-09-07 04:10:00',
37-
end: '2017-09-07 04:40:00',
46+
start: `${getDate(1)} 04:10:00`,
47+
end: `${getDate(1)} 04:40:00`,
3848
title: 'Tea Time with Dr. Hasan',
3949
summary: 'Tea Time with Dr. Hasan, Talk about Project'
4050
},
4151
{
42-
start: '2017-09-07 01:05:00',
43-
end: '2017-09-07 01:35:00',
52+
start: `${getDate(1)} 01:05:00`,
53+
end: `${getDate(1)} 01:35:00`,
4454
title: 'Dr. Mariana Joseph',
4555
summary: '3412 Piedmont Rd NE, GA 3032'
4656
},
4757
{
48-
start: '2017-09-07 14:30:00',
49-
end: '2017-09-07 16:30:00',
58+
start: `${getDate(1)} 14:30:00`,
59+
end: `${getDate(1)} 16:30:00`,
5060
title: 'Meeting Some Friends in ARMED',
5161
summary: 'Arsalan, Hasnaat, Talha, Waleed, Bilal',
5262
color: '#d8ade6'
5363
},
5464
{
55-
start: '2017-09-08 01:40:00',
56-
end: '2017-09-08 02:25:00',
65+
start: `${getDate(2)} 01:40:00`,
66+
end: `${getDate(2)} 02:25:00`,
5767
title: 'Meet Sir Khurram Iqbal',
5868
summary: 'Computer Science Dept. Comsats Islamabad',
5969
color: '#e6bcad'
6070
},
6171
{
62-
start: '2017-09-08 04:10:00',
63-
end: '2017-09-08 04:40:00',
72+
start: `${getDate(2)} 04:10:00`,
73+
end: `${getDate(2)} 04:40:00`,
6474
title: 'Tea Time with Colleagues',
6575
summary: 'WeRplay'
6676
},
6777
{
68-
start: '2017-09-08 00:45:00',
69-
end: '2017-09-08 01:45:00',
78+
start: `${getDate(2)} 00:45:00`,
79+
end: `${getDate(2)} 01:45:00`,
7080
title: 'Lets Play Apex Legends',
7181
summary: 'with Boys at Work'
7282
},
7383
{
74-
start: '2017-09-08 11:30:00',
75-
end: '2017-09-08 12:30:00',
84+
start: `${getDate(2)} 11:30:00`,
85+
end: `${getDate(2)} 12:30:00`,
7686
title: 'Dr. Mariana Joseph',
7787
summary: '3412 Piedmont Rd NE, GA 3032'
7888
},
7989
{
80-
start: '2017-09-10 12:10:00',
81-
end: '2017-09-10 13:45:00',
90+
start: `${getDate(4)} 12:10:00`,
91+
end: `${getDate(4)} 13:45:00`,
8292
title: 'Merge Request to React Native Calendars',
8393
summary: 'Merge Timeline Calendar to React Native Calendars'
8494
}
8595
];
8696

8797
export default class TimelineCalendarScreen extends Component {
8898
state = {
89-
currentDate: '2017-09-10',
99+
currentDate: getDate(),
90100
events: EVENTS,
91101
eventsByDate: _.groupBy(EVENTS, e => CalendarUtils.getCalendarDateString(e.start)) as {
92102
[key: string]: TimelineEventProps[];
93103
}
94104
};
95105

96106
marked = {
97-
'2017-09-06': {marked: true},
98-
'2017-09-07': {marked: true},
99-
'2017-09-08': {marked: true},
100-
'2017-09-10': {marked: true}
107+
[`${getDate(-1)}`]: {marked: true},
108+
[`${getDate()}`]: {marked: true},
109+
[`${getDate(1)}`]: {marked: true},
110+
[`${getDate(2)}`]: {marked: true},
111+
[`${getDate(4)}`]: {marked: true}
101112
};
102113

103114
onDateChanged = (date: string) => {
@@ -197,7 +208,14 @@ export default class TimelineCalendarScreen extends Component {
197208
rightArrowImageSource={require('../img/next.png')}
198209
markedDates={this.marked}
199210
/>
200-
<TimelineList events={eventsByDate} timelineProps={this.timelineProps} showNowIndicator />
211+
<TimelineList
212+
events={eventsByDate}
213+
timelineProps={this.timelineProps}
214+
showNowIndicator
215+
scrollToNow
216+
scrollToFirst
217+
initialTime={INITIAL_TIME}
218+
/>
201219
</CalendarProvider>
202220
);
203221
}

src/timeline-list/index.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,34 @@ import InfiniteList from '../infinite-list';
1111
import useTimelinePages, {INITIAL_PAGE, NEAR_EDGE_THRESHOLD} from './useTimelinePages';
1212

1313
export interface TimelineListProps {
14+
/**
15+
* Map of all timeline events ({[date]: events})
16+
*/
1417
events: {[date: string]: TimelineProps['events']};
15-
timelineProps?: Omit<TimelineProps, 'events' | 'showNowIndicator'>;
18+
/**
19+
* General timeline props to pass to each timeline item
20+
*/
21+
timelineProps?: Omit<TimelineProps, 'events' | 'scrollToFirst' | 'showNowIndicator' | 'scrollToNow' | 'initialTime'>;
22+
/**
23+
* Should scroll to first event of the day
24+
*/
25+
scrollToFirst?: boolean;
26+
/**
27+
* Should show now indicator (shown only on "today" timeline)
28+
*/
1629
showNowIndicator?: boolean;
30+
/**
31+
* Should initially scroll to current time (relevant only for "today" timeline)
32+
*/
33+
scrollToNow?: boolean;
34+
/**
35+
* Should initially scroll to a specific time (relevant only for NOT "today" timelines)
36+
*/
37+
initialTime?: TimelineProps['initialTime'];
1738
}
1839

1940
const TimelineList = (props: TimelineListProps) => {
20-
const {timelineProps, events, showNowIndicator} = props;
41+
const {timelineProps, events, showNowIndicator, scrollToFirst, scrollToNow, initialTime} = props;
2142
const {date, updateSource, setDate} = useContext(Context);
2243
const listRef = useRef<any>();
2344
const prevDate = useRef(date);
@@ -73,20 +94,25 @@ const TimelineList = (props: TimelineListProps) => {
7394
}, []);
7495

7596
const renderPage = useCallback(
76-
(_type, item) => {
97+
(_type, item, index) => {
7798
const timelineEvent = events[item];
7899
const isCurrent = prevDate.current === item;
100+
const isInitialPage = index === INITIAL_PAGE;
101+
const _isToday = isToday(new XDate(item));
102+
79103
return (
80104
<>
81105
<Timeline
82106
{...timelineProps}
83107
key={item}
84108
date={item}
85-
scrollToFirst={false}
86109
events={timelineEvent}
110+
scrollToNow={_isToday && isInitialPage && scrollToNow}
111+
initialTime={!_isToday && isInitialPage ? initialTime : undefined}
112+
scrollToFirst={!_isToday && isInitialPage && scrollToFirst}
87113
scrollOffset={isCurrent ? undefined : timelineOffset}
88114
onChangeOffset={onTimelineOffsetChange}
89-
showNowIndicator={showNowIndicator && isToday(new XDate(item))}
115+
showNowIndicator={_isToday && showNowIndicator}
90116
/>
91117
{/* NOTE: Keeping this for easy debugging */}
92118
{/* <Text style={{position: 'absolute'}}>{item}</Text> */}

src/timeline/NowIndicator.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
import React from 'react';
22
import {View, TextStyle, ViewStyle} from 'react-native';
33
import {HOUR_BLOCK_HEIGHT} from './Packer';
4-
5-
export {HOUR_BLOCK_HEIGHT} from './Packer';
4+
import {calcTimeOffset} from './helpers/presenter';
65

76
export interface NowIndicatorProps {
87
styles: {[key: string]: ViewStyle | TextStyle};
98
}
109

1110
const NowIndicator = (props: NowIndicatorProps) => {
1211
const {styles} = props;
13-
const now = new Date();
14-
const hour = now.getHours();
15-
const minutes = now.getMinutes();
1612

17-
const indicatorPosition = (hour + minutes / 60) * HOUR_BLOCK_HEIGHT;
13+
const indicatorPosition = calcTimeOffset(HOUR_BLOCK_HEIGHT);
1814

1915
return (
2016
<View style={[styles.nowIndicator, {top: indicatorPosition}]}>

src/timeline/Timeline.tsx

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
2-
import {View, Dimensions, ScrollView} from 'react-native';
2+
import {View, ScrollView} from 'react-native';
33
import min from 'lodash/min';
44
import map from 'lodash/map';
55

6+
import constants from '../commons/constants';
67
import {Theme} from '../types';
78
import styleConstructor, {HOURS_SIDEBAR_WIDTH} from './style';
89
import populateEvents, {HOUR_BLOCK_HEIGHT} from './Packer';
10+
import {calcTimeOffset} from './helpers/presenter';
911
import TimelineHours, {TimelineHoursProps} from './TimelineHours';
1012
import EventBlock, {Event, PackedEvent} from './EventBlock';
1113
import NowIndicator from './NowIndicator';
1214
import useTimelineOffset from './useTimelineOffset';
1315

14-
const {width: dimensionWidth} = Dimensions.get('window');
15-
1616
export interface TimelineProps {
1717
/**
1818
* The date of this timeline instance in ISO format (e.g. 2011-10-25)
@@ -51,7 +51,18 @@ export interface TimelineProps {
5151
onBackgroundLongPressOut?: TimelineHoursProps['onBackgroundLongPressOut'];
5252
styles?: Theme; //TODO: deprecate (prop renamed 'theme', as in the other components).
5353
theme?: Theme;
54+
/**
55+
* Should scroll to first event when loaded
56+
*/
5457
scrollToFirst?: boolean;
58+
/**
59+
* Should scroll to current time when loaded
60+
*/
61+
scrollToNow?: boolean;
62+
/**
63+
* Initial time to scroll to
64+
*/
65+
initialTime?: {hour: number; minutes: number};
5566
/**
5667
* Whether to use 24 hours format for the timeline hours
5768
*/
@@ -87,6 +98,8 @@ const Timeline = (props: TimelineProps) => {
8798
renderEvent,
8899
theme,
89100
scrollToFirst,
101+
scrollToNow,
102+
initialTime,
90103
showNowIndicator,
91104
scrollOffset,
92105
onChangeOffset,
@@ -100,24 +113,27 @@ const Timeline = (props: TimelineProps) => {
100113
const {scrollEvents} = useTimelineOffset({onChangeOffset, scrollOffset, scrollViewRef: scrollView});
101114

102115
const packedEvents = useMemo(() => {
103-
const width = dimensionWidth - HOURS_SIDEBAR_WIDTH;
116+
const width = constants.screenWidth - HOURS_SIDEBAR_WIDTH;
104117
return populateEvents(events, width, start);
105118
}, [events, start]);
106119

107120
useEffect(() => {
108-
if (scrollToFirst) {
109-
const firstTop = min(map(packedEvents, 'top')) ?? 0;
110-
const initPosition = firstTop - calendarHeight.current / (end - start);
111-
const verifiedInitPosition = initPosition < 0 ? 0 : initPosition;
121+
let initialPosition = 0;
122+
if (scrollToNow) {
123+
initialPosition = calcTimeOffset(HOUR_BLOCK_HEIGHT);
124+
} else if (scrollToFirst && packedEvents.length > 0) {
125+
initialPosition = min(map(packedEvents, 'top')) ?? 0;
126+
} else if (initialTime) {
127+
initialPosition = calcTimeOffset(HOUR_BLOCK_HEIGHT, initialTime.hour, initialTime.minutes);
128+
}
112129

113-
if (verifiedInitPosition) {
114-
setTimeout(() => {
115-
scrollView?.current?.scrollTo({
116-
y: verifiedInitPosition,
117-
animated: true
118-
});
119-
}, 0);
120-
}
130+
if (initialPosition) {
131+
setTimeout(() => {
132+
scrollView?.current?.scrollTo({
133+
y: Math.max(0, initialPosition - HOUR_BLOCK_HEIGHT),
134+
animated: true
135+
});
136+
}, 0);
121137
}
122138
}, []);
123139

@@ -160,7 +176,7 @@ const Timeline = (props: TimelineProps) => {
160176
<ScrollView
161177
// @ts-expect-error
162178
ref={scrollView}
163-
contentContainerStyle={[styles.current.contentStyle, {width: dimensionWidth}]}
179+
contentContainerStyle={[styles.current.contentStyle, {width: constants.screenWidth}]}
164180
{...scrollEvents}
165181
>
166182
<TimelineHours

src/timeline/__tests__/presenter.spec.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,20 @@ describe('timeline presenter', () => {
6868
expect(uut.buildTimeString(15, 0, '2017-03-05')).toBe('2017-03-05 15:00:00');
6969
});
7070
});
71+
72+
describe('calcTimeOffset', () => {
73+
// NOTE: useFakeTimers API works only in jest 27, unfortunately, other tests fail in jest 27
74+
it.skip('should give offset based on current time', () => {
75+
jest.useFakeTimers().setSystemTime(new Date('2020-01-01 15:30').getTime());
76+
expect(uut.calcTimeOffset(100)).toBe(1550);
77+
78+
jest.useFakeTimers().setSystemTime(new Date('2020-01-01 12:10').getTime());
79+
expect(uut.calcTimeOffset(100)).toBeCloseTo(1216.66, 1);
80+
});
81+
82+
it('should give offset based on given time', () => {
83+
expect(uut.calcTimeOffset(100, 15, 30)).toBe(1550);
84+
expect(uut.calcTimeOffset(100, 12, 10)).toBeCloseTo(1216.66, 1);
85+
});
86+
});
7187
});

src/timeline/helpers/presenter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@ export function calcTimeByPosition(yPosition: number, hourBlockHeight: number) {
1010
export function buildTimeString(hour = 0, minutes = 0, date = '') {
1111
return `${date} ${hour.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`.trimStart();
1212
}
13+
14+
export function calcTimeOffset(hourBlockHeight: number, hour?: number, minutes?: number) {
15+
const now = new Date();
16+
const h = hour ?? now.getHours();
17+
const m = minutes ?? now.getMinutes();
18+
return (h + m / 60) * hourBlockHeight;
19+
}

0 commit comments

Comments
 (0)