|
| 1 | +import React, {useContext, useCallback, useRef} from 'react'; |
| 2 | +import {runOnJS, useAnimatedReaction, useSharedValue} from 'react-native-reanimated'; |
| 3 | +import {FlashList, ViewToken} from '@shopify/flash-list'; |
| 4 | +import {BorderRadiuses} from 'style'; |
| 5 | +import View from '../../components/view'; |
| 6 | +import Text from '../../components/text'; |
| 7 | +import {isSameDay, isSameMonth} from './helpers/DateUtils'; |
| 8 | +import {InternalEvent, Event, DateSectionHeader, UpdateSource} from './types'; |
| 9 | +import CalendarContext from './CalendarContext'; |
| 10 | + |
| 11 | +// TODO: Fix initial scrolling |
| 12 | +function Agenda() { |
| 13 | + const {data, selectedDate, setDate, updateSource} = useContext(CalendarContext); |
| 14 | + const flashList = useRef<FlashList<InternalEvent>>(null); |
| 15 | + const closestSectionHeader = useSharedValue<DateSectionHeader | null>(null); |
| 16 | + const scrolledByUser = useSharedValue<boolean>(false); |
| 17 | + |
| 18 | + const keyExtractor = useCallback((item: InternalEvent) => { |
| 19 | + return item.type === 'Event' ? item.id : item.header; |
| 20 | + }, []); |
| 21 | + |
| 22 | + const renderEvent = useCallback((item: Event) => { |
| 23 | + return ( |
| 24 | + <View |
| 25 | + marginV-1 |
| 26 | + marginH-10 |
| 27 | + paddingH-10 |
| 28 | + height={50} |
| 29 | + style={{borderWidth: 1, borderRadius: BorderRadiuses.br20, justifyContent: 'center'}} |
| 30 | + > |
| 31 | + <Text style={{}}> |
| 32 | + Item for{' '} |
| 33 | + {new Date(item.start).toLocaleString('en-GB', { |
| 34 | + month: 'short', |
| 35 | + day: 'numeric', |
| 36 | + hour12: false, |
| 37 | + hour: '2-digit', |
| 38 | + minute: '2-digit' |
| 39 | + })} |
| 40 | + -{new Date(item.end).toLocaleString('en-GB', {hour12: false, hour: '2-digit', minute: '2-digit'})} |
| 41 | + </Text> |
| 42 | + </View> |
| 43 | + ); |
| 44 | + }, []); |
| 45 | + |
| 46 | + const renderHeader = useCallback((item: DateSectionHeader) => { |
| 47 | + return ( |
| 48 | + <View |
| 49 | + marginB-1 |
| 50 | + paddingB-4 |
| 51 | + marginH-10 |
| 52 | + paddingH-10 |
| 53 | + height={50} |
| 54 | + bottom |
| 55 | + > |
| 56 | + <Text>{item.header}</Text> |
| 57 | + </View> |
| 58 | + ); |
| 59 | + }, []); |
| 60 | + |
| 61 | + const renderItem = useCallback(({item}: {item: InternalEvent; index: number}) => { |
| 62 | + switch (item.type) { |
| 63 | + case 'Event': |
| 64 | + return renderEvent(item); |
| 65 | + case 'Header': |
| 66 | + return renderHeader(item); |
| 67 | + } |
| 68 | + }, |
| 69 | + [renderEvent, renderHeader]); |
| 70 | + |
| 71 | + const getItemType = useCallback(item => item.type, []); |
| 72 | + |
| 73 | + const findClosestDateAfter = useCallback((selected: number) => { |
| 74 | + 'worklet'; |
| 75 | + for (let index = 0; index < data.length; ++index) { |
| 76 | + const item = data[index]; |
| 77 | + if (item.type === 'Header') { |
| 78 | + if (item.date >= selected) { |
| 79 | + return {dateSectionHeader: item, index}; |
| 80 | + } |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + return null; |
| 85 | + }, |
| 86 | + [data]); |
| 87 | + |
| 88 | + const scrollToIndex = useCallback((index: number, animated: boolean) => { |
| 89 | + flashList.current?.scrollToIndex({index, animated}); |
| 90 | + }, []); |
| 91 | + |
| 92 | + useAnimatedReaction(() => { |
| 93 | + return selectedDate.value; |
| 94 | + }, |
| 95 | + (selected, previous) => { |
| 96 | + if (updateSource?.value !== UpdateSource.AGENDA_SCROLL) { |
| 97 | + if ( |
| 98 | + selected !== previous && |
| 99 | + (closestSectionHeader.value?.date === undefined || !isSameDay(selected, closestSectionHeader.value?.date)) |
| 100 | + ) { |
| 101 | + const result = findClosestDateAfter(selected); |
| 102 | + if (result !== null) { |
| 103 | + const {dateSectionHeader, index} = result; |
| 104 | + closestSectionHeader.value = dateSectionHeader; |
| 105 | + scrolledByUser.value = false; |
| 106 | + // TODO: Can the animation be improved (not in JS)? |
| 107 | + if (previous) { |
| 108 | + const _isSameMonth = isSameMonth(selected, previous); |
| 109 | + runOnJS(scrollToIndex)(index, _isSameMonth); |
| 110 | + } |
| 111 | + } |
| 112 | + } |
| 113 | + } |
| 114 | + }, |
| 115 | + [findClosestDateAfter]); |
| 116 | + |
| 117 | + // TODO: look at https://docs.swmansion.com/react-native-reanimated/docs/api/hooks/useAnimatedScrollHandler |
| 118 | + const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]}) => { |
| 119 | + if (scrolledByUser.value) { |
| 120 | + const result = viewableItems.find(item => item.item.type === 'Header'); |
| 121 | + if (result) { |
| 122 | + const {item}: {item: DateSectionHeader} = result; |
| 123 | + if (closestSectionHeader.value?.date !== item.date) { |
| 124 | + closestSectionHeader.value = item; |
| 125 | + setDate(item.date, UpdateSource.AGENDA_SCROLL); |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 130 | + }, []); |
| 131 | + |
| 132 | + const onMomentumScrollBegin = useCallback(() => { |
| 133 | + scrolledByUser.value = true; |
| 134 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 135 | + }, []); |
| 136 | + |
| 137 | + const onScrollBeginDrag = useCallback(() => { |
| 138 | + scrolledByUser.value = true; |
| 139 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 140 | + }, []); |
| 141 | + |
| 142 | + return ( |
| 143 | + <FlashList |
| 144 | + ref={flashList} |
| 145 | + estimatedItemSize={52} |
| 146 | + data={data} |
| 147 | + keyExtractor={keyExtractor} |
| 148 | + renderItem={renderItem} |
| 149 | + getItemType={getItemType} |
| 150 | + onViewableItemsChanged={onViewableItemsChanged} |
| 151 | + onMomentumScrollBegin={onMomentumScrollBegin} |
| 152 | + onScrollBeginDrag={onScrollBeginDrag} |
| 153 | + initialScrollIndex={findClosestDateAfter(selectedDate.value)?.index ?? 0} |
| 154 | + /> |
| 155 | + ); |
| 156 | +} |
| 157 | + |
| 158 | +export default Agenda; |
0 commit comments