|
| 1 | +import PropTypes from 'prop-types'; |
| 2 | + |
| 3 | +import isUndefined from 'lodash/isUndefined'; |
| 4 | +import debounce from 'lodash/debounce'; |
| 5 | +import InfiniteList from '../infinite-list'; |
| 6 | + |
| 7 | +import XDate from 'xdate'; |
| 8 | + |
| 9 | +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; |
| 10 | +import { |
| 11 | + DefaultSectionT, |
| 12 | + SectionListData, |
| 13 | +} from 'react-native'; |
| 14 | + |
| 15 | +import {useDidUpdate} from '../hooks'; |
| 16 | +import {getMoment} from '../momentResolver'; |
| 17 | +import {isGTE, isToday} from '../dateutils'; |
| 18 | +import {getDefaultLocale} from '../services'; |
| 19 | +import {UpdateSources, todayString} from './commons'; |
| 20 | +import styleConstructor from './style'; |
| 21 | +import Context from './Context'; |
| 22 | +import constants from "../commons/constants"; |
| 23 | +import {parseDate} from "../interface"; |
| 24 | +import {LayoutProvider} from "recyclerlistview/dist/reactnative/core/dependencies/LayoutProvider"; |
| 25 | +import {AgendaListProps, AgendaSectionHeader} from "./agendaList"; |
| 26 | + |
| 27 | +/** |
| 28 | + * @description: AgendaList component that use InfiniteList to improve performance |
| 29 | + * @note: Should be wrapped with 'CalendarProvider' |
| 30 | + * @extends: InfiniteList |
| 31 | + * @example: https://github.com/wix/react-native-calendars/blob/master/example/src/screens/expandableCalendar.js |
| 32 | + */ |
| 33 | +const InfiniteAgendaList = (props: AgendaListProps) => { |
| 34 | + const { |
| 35 | + theme, |
| 36 | + sections, |
| 37 | + scrollToNextEvent, |
| 38 | + avoidDateUpdates, |
| 39 | + onScroll, |
| 40 | + renderSectionHeader, |
| 41 | + sectionStyle, |
| 42 | + dayFormatter, |
| 43 | + dayFormat = 'dddd, MMM d', |
| 44 | + useMoment, |
| 45 | + markToday = true, |
| 46 | + infiniteListProps, |
| 47 | + renderItem, |
| 48 | + onEndReached, |
| 49 | + onEndReachedThreshold |
| 50 | + } = props; |
| 51 | + |
| 52 | + const {date, updateSource, setDate} = useContext(Context); |
| 53 | + |
| 54 | + const style = useRef(styleConstructor(theme)); |
| 55 | + const list = useRef<any>(); |
| 56 | + const _topSection = useRef(sections[0]?.title); |
| 57 | + const didScroll = useRef(false); |
| 58 | + const sectionScroll = useRef(false); |
| 59 | + const [data, setData] = useState([] as any[]); |
| 60 | + |
| 61 | + useEffect(() => { |
| 62 | + const items = sections.reduce((acc: any, cur: any) => { |
| 63 | + return [...acc, {title: cur.title, isTitle: true}, ...cur.data]; |
| 64 | + }, []); |
| 65 | + setData(items); |
| 66 | + |
| 67 | + if (date !== _topSection.current) { |
| 68 | + setTimeout(() => { |
| 69 | + scrollToSection(date); |
| 70 | + }, 500); |
| 71 | + } |
| 72 | + }, [sections]); |
| 73 | + |
| 74 | + useDidUpdate(() => { |
| 75 | + // NOTE: on first init data should set first section to the current date!!! |
| 76 | + if (updateSource !== UpdateSources.LIST_DRAG && updateSource !== UpdateSources.CALENDAR_INIT) { |
| 77 | + scrollToSection(date); |
| 78 | + } |
| 79 | + }, [date]); |
| 80 | + |
| 81 | + const getSectionIndex = (date: string) => { |
| 82 | + let dataIndex = 0; |
| 83 | + |
| 84 | + for (let i = 0; i < sections.length; i++) { |
| 85 | + if (sections[i].title === date) { |
| 86 | + return dataIndex; |
| 87 | + } |
| 88 | + dataIndex += sections[i].data.length + 1; |
| 89 | + } |
| 90 | + }; |
| 91 | + |
| 92 | + const getNextSectionIndex = (date: string) => { |
| 93 | + const cur = new XDate(date); |
| 94 | + let dataIndex = 0; |
| 95 | + |
| 96 | + for (let i = 0; i < sections.length; i++) { |
| 97 | + const titleDate = parseDate(sections[i].title); |
| 98 | + if (isGTE(titleDate,cur)) { |
| 99 | + return dataIndex; |
| 100 | + } |
| 101 | + dataIndex += sections[i].data.length + 1; |
| 102 | + } |
| 103 | + }; |
| 104 | + |
| 105 | + const getSectionTitle = useCallback((title: string) => { |
| 106 | + if (!title) return; |
| 107 | + |
| 108 | + let sectionTitle = title; |
| 109 | + |
| 110 | + if (dayFormatter) { |
| 111 | + sectionTitle = dayFormatter(title); |
| 112 | + } else if (dayFormat) { |
| 113 | + if (useMoment) { |
| 114 | + const moment = getMoment(); |
| 115 | + sectionTitle = moment(title).format(dayFormat); |
| 116 | + } else { |
| 117 | + sectionTitle = new XDate(title).toString(dayFormat); |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + if (markToday) { |
| 122 | + const string = getDefaultLocale().today || todayString; |
| 123 | + const today = isToday(title); |
| 124 | + sectionTitle = today ? `${string}, ${sectionTitle}` : sectionTitle; |
| 125 | + } |
| 126 | + |
| 127 | + return sectionTitle; |
| 128 | + }, []); |
| 129 | + |
| 130 | + const scrollToSection = useCallback(debounce((d) => { |
| 131 | + const sectionIndex = scrollToNextEvent ? getNextSectionIndex(d) : getSectionIndex(d); |
| 132 | + if (isUndefined(sectionIndex)) { |
| 133 | + return; |
| 134 | + } |
| 135 | + |
| 136 | + if (list?.current && sectionIndex !== undefined) { |
| 137 | + sectionScroll.current = true; // to avoid setDate() in _onVisibleIndicesChanged |
| 138 | + _topSection.current = sections[findItemTitleIndex(sectionIndex)]?.title; |
| 139 | + |
| 140 | + list.current?.scrollToIndex(sectionIndex, true); |
| 141 | + } |
| 142 | + }, 1000, {leading: false, trailing: true}), [ sections]); |
| 143 | + |
| 144 | + const layoutProvider = useMemo( |
| 145 | + () => new LayoutProvider( |
| 146 | + (index) => data[index]?.isTitle ? 'title': 'page', |
| 147 | + (type, dim) => { |
| 148 | + dim.width = constants.screenWidth; |
| 149 | + dim.height = type === 'title' ? infiniteListProps?.titleHeight ?? 60 : infiniteListProps?.itemHeight ?? 80; |
| 150 | + } |
| 151 | + ), |
| 152 | + [data] |
| 153 | + ); |
| 154 | + |
| 155 | + const _onScroll = useCallback((rawEvent: any) => { |
| 156 | + if (!didScroll.current) { |
| 157 | + didScroll.current = true; |
| 158 | + scrollToSection.cancel(); |
| 159 | + } |
| 160 | + |
| 161 | + // Convert to a format similar to NativeSyntheticEvent<NativeScrollEvent> |
| 162 | + const event = { |
| 163 | + nativeEvent: { |
| 164 | + contentOffset: rawEvent.nativeEvent.contentOffset, |
| 165 | + layoutMeasurement: rawEvent.nativeEvent.layoutMeasurement, |
| 166 | + contentSize: rawEvent.nativeEvent.contentSize, |
| 167 | + }, |
| 168 | + }; |
| 169 | + onScroll?.(event as any); |
| 170 | + }, [onScroll]); |
| 171 | + |
| 172 | + const _onVisibleIndicesChanged = useCallback(debounce((all: number[]) => { |
| 173 | + if (all && all.length && !sectionScroll.current) { |
| 174 | + const topItemIndex = all[0]; |
| 175 | + const topSection = data[findItemTitleIndex(topItemIndex)]; |
| 176 | + if (topSection && topSection !== _topSection.current) { |
| 177 | + _topSection.current = topSection.title; |
| 178 | + if (didScroll.current && !avoidDateUpdates) { |
| 179 | + // to avoid setDate() on first load (while setting the initial context.date value) |
| 180 | + setDate?.(topSection.title, UpdateSources.LIST_DRAG); |
| 181 | + } |
| 182 | + } |
| 183 | + } |
| 184 | + }, 1000, {leading: false, trailing: true},), [avoidDateUpdates, setDate, data]); |
| 185 | + |
| 186 | + const findItemTitleIndex = useCallback((itemIndex: number) => { |
| 187 | + let titleIndex = itemIndex; |
| 188 | + while (titleIndex > 0 && !data[titleIndex]?.isTitle) { |
| 189 | + titleIndex--; |
| 190 | + } |
| 191 | + |
| 192 | + return titleIndex; |
| 193 | + }, [data]); |
| 194 | + |
| 195 | + const _onMomentumScrollEnd = useCallback(() => { |
| 196 | + sectionScroll.current = false; |
| 197 | + }, []); |
| 198 | + |
| 199 | + const headerTextStyle = useMemo(() => [style.current.sectionText, sectionStyle], [sectionStyle]); |
| 200 | + |
| 201 | + const _renderSectionHeader = useCallback((info: {section: SectionListData<any, DefaultSectionT>}) => { |
| 202 | + const title = info?.section?.title; |
| 203 | + |
| 204 | + if (renderSectionHeader) { |
| 205 | + return renderSectionHeader(title); |
| 206 | + } |
| 207 | + |
| 208 | + const headerTitle = getSectionTitle(title); |
| 209 | + return <AgendaSectionHeader title={headerTitle} style={headerTextStyle}/>; |
| 210 | + }, [headerTextStyle]); |
| 211 | + |
| 212 | + const _renderItem = useCallback((_type: any, item: any) => { |
| 213 | + if (item?.isTitle) { |
| 214 | + return _renderSectionHeader({section: item}); |
| 215 | + } |
| 216 | + |
| 217 | + if (renderItem) { |
| 218 | + return renderItem({item} as any); |
| 219 | + } |
| 220 | + |
| 221 | + return <></>; |
| 222 | + }, [renderItem]); |
| 223 | + |
| 224 | + const _onEndReached = useCallback(() => { |
| 225 | + if (onEndReached) { |
| 226 | + onEndReached({distanceFromEnd: 0}); // The RecyclerListView doesn't provide the distanceFromEnd, so we just pass 0 |
| 227 | + } |
| 228 | + }, [onEndReached]); |
| 229 | + |
| 230 | + return ( |
| 231 | + <InfiniteList |
| 232 | + ref={list} |
| 233 | + renderItem={_renderItem} |
| 234 | + data={data} |
| 235 | + style={style.current.container} |
| 236 | + layoutProvider={layoutProvider} |
| 237 | + onScroll={_onScroll} |
| 238 | + onVisibleIndicesChanged={_onVisibleIndicesChanged} |
| 239 | + scrollViewProps={{onMomentumScrollEnd: _onMomentumScrollEnd}} |
| 240 | + onEndReached={_onEndReached} |
| 241 | + onEndReachedThreshold={onEndReachedThreshold as number | undefined} |
| 242 | + disableScrollOnDataChange |
| 243 | + /> |
| 244 | + ); |
| 245 | +}; |
| 246 | + |
| 247 | + |
| 248 | +export default InfiniteAgendaList; |
| 249 | + |
| 250 | +InfiniteAgendaList.displayName = 'InfiniteAgendaList'; |
| 251 | +InfiniteAgendaList.propTypes = { |
| 252 | + dayFormat: PropTypes.string, |
| 253 | + dayFormatter: PropTypes.func, |
| 254 | + useMoment: PropTypes.bool, |
| 255 | + markToday: PropTypes.bool, |
| 256 | + sectionStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]), |
| 257 | + avoidDateUpdates: PropTypes.bool |
| 258 | +}; |
0 commit comments