import findIndex from 'lodash/findIndex'; import PropTypes from 'prop-types'; import XDate from 'xdate'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { FlatListProps, View, ViewStyle, VirtualizedList } from 'react-native'; import { extractCalendarProps, extractHeaderProps } from '../componentUpdater'; import { parseDate, toMarkingFormat, xdateToData } from '../interface'; import { page, sameDate, sameMonth } from '../dateutils'; import constants from '../commons/constants'; import { useDidUpdate } from '../hooks'; import { ContextProp } from '../types'; import omit from 'lodash/omit'; import styleConstructor from './style'; import Calendar, { CalendarProps } from '../calendar'; import CalendarListItem from './item'; import CalendarHeader from '../calendar/header/index'; import isEqual from 'lodash/isEqual'; const CALENDAR_WIDTH = constants.screenWidth; const CALENDAR_HEIGHT = 360; const PAST_SCROLL_RANGE = 50; const FUTURE_SCROLL_RANGE = 50; export interface CalendarListProps extends CalendarProps, Omit, 'data' | 'renderItem'> { /** * 允许滚动到过去的最大月份数 * @default 50 */ pastScrollRange?: number; /** * 允许滚动到未来的最大月份数 * @default 50 */ futureScrollRange?: number; /** * 当日历滚动为水平时使用,默认为设备宽度,应禁用分页 */ calendarWidth?: number; /** * 动态日历高度 */ calendarHeight?: number; /** * 列表项的样式(日历) */ calendarStyle?: ViewStyle; /** * 是否使用不随列表滚动的静态标题(仅限水平) */ staticHeader?: boolean; /** * 启用或禁用垂直/水平滚动指示器 * @default false */ showScrollIndicator?: boolean; /** * 是否使自动月滚动动画化 */ animateScroll?: boolean; } export interface CalendarListImperativeMethods { scrollToDay: ( date: XDate | string, offset: number, animated: boolean ) => void; scrollToMonth: (date: XDate | string) => void; } class CalendarItem extends React.Component { shouldComponentUpdate(nextProps: { item: XDate; currentMonth: XDate; visible: boolean; }): boolean { const date = xdateToData(nextProps.currentMonth); const currentDate = xdateToData(this.props.item); const update = date.year === currentDate.year && date.month === currentDate.month; return update || nextProps.visible; } render() { // @ts-ignore return ; } } /** * @description: Calendar List component for both vertical and horizontal calendars * @extends: Calendar * @extendslink: docs/Calendar * @example: https://github.com/wix/react-native-calendars/blob/master/example/src/screens/calendarsList.js * @gif: https://github.com/wix/react-native-calendars/blob/master/demo/assets/calendar-list.gif */ const CalendarList = (props: CalendarListProps & ContextProp, ref: any) => { useImperativeHandle(ref, () => ({ scrollToDay: (date: XDate | string, offset: number, animated: boolean) => { scrollToDay(date, offset, animated); }, scrollToMonth: (date: XDate | string) => { scrollToMonth(date); }, })); const { /** Calendar props */ theme, current, firstDay, markedDates, headerStyle, onMonthChange, onVisibleMonthsChange, /** CalendarList props */ pastScrollRange = PAST_SCROLL_RANGE, futureScrollRange = FUTURE_SCROLL_RANGE, calendarHeight = CALENDAR_HEIGHT, calendarWidth = CALENDAR_WIDTH, calendarStyle, animateScroll = false, showScrollIndicator = false, staticHeader, /** View props */ testID, style: propsStyle, onLayout, removeClippedSubviews, /** ScrollView props */ horizontal = false, pagingEnabled, scrollEnabled = true, nestedScrollEnabled = true, scrollsToTop = false, keyExtractor = (_: any, index: number) => String(index), keyboardShouldPersistTaps, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollBegin, onMomentumScrollEnd, /** FlatList props */ onEndReachedThreshold, onEndReached, } = props; const calendarProps = extractCalendarProps(props); const headerProps = extractHeaderProps(props); const calendarSize = horizontal ? calendarWidth : calendarHeight; const [currentMonth, setCurrentMonth] = useState(parseDate(current)); const shouldUseAndroidRTLFix = useMemo( () => constants.isAndroidRTL && horizontal, [horizontal] ); const style = useRef(styleConstructor(theme)); const list = useRef(); const range = useRef(horizontal ? 1 : 3); const initialDate = useRef(parseDate(current) || new XDate()); const visibleMonth = useRef(currentMonth); const items: XDate[] = useMemo(() => { const months: any[] = []; for (let i = 0; i <= pastScrollRange + futureScrollRange; i++) { const rangeDate = initialDate.current ?.clone() .addMonths(i - pastScrollRange, true); months.push(rangeDate); } return months; }, [pastScrollRange, futureScrollRange]); const staticHeaderStyle = useMemo(() => { return [style.current.staticHeader, headerStyle]; }, [headerStyle]); const listStyle = useMemo(() => { return [style.current.container, propsStyle]; }, [propsStyle]); const initialDateIndex = useMemo(() => { return findIndex(items, function (item) { return item.toString() === initialDate.current?.toString(); }); }, [items]); const getDateIndex = useCallback( (date: string) => { return findIndex(items, function (item) { return item.toString() === date.toString(); }); }, [items] ); useEffect(() => { if (current) { scrollToMonth(new XDate(current)); } }, [current]); useDidUpdate(() => { const currMont = currentMonth?.clone(); if (currMont) { const data = xdateToData(currMont); onMonthChange?.(data); onVisibleMonthsChange?.([data]); } }, [currentMonth]); const scrollToDay = ( date: XDate | string, offset: number, animated: boolean ) => { const scrollTo = parseDate(date); const diffMonths = Math.round( initialDate?.current ?.clone() .setDate(1) // @ts-ignore .diffMonths(scrollTo?.clone().setDate(1)) ); let scrollAmount = calendarSize * pastScrollRange + diffMonths * calendarSize + (offset || 0); if (!horizontal) { let week = 0; // @ts-ignore const days = page(scrollTo, firstDay); for (let i = 0; i < days.length; i++) { week = Math.floor(i / 7); if (sameDate(days[i], scrollTo)) { scrollAmount += 46 * week; break; } } } if (scrollAmount !== 0) { // @ts-expect-error list?.current?.scrollToOffset({ offset: scrollAmount, animated }); } }; const scrollToMonth = useCallback( (date: XDate | string) => { const scrollTo = parseDate(date); const diffMonths = Math.round( initialDate?.current ?.clone() .setDate(1) // @ts-ignore .diffMonths(scrollTo?.clone().setDate(1)) ); const scrollAmount = calendarSize * (shouldUseAndroidRTLFix ? pastScrollRange - diffMonths : pastScrollRange + diffMonths); if (scrollAmount !== 0) { // @ts-expect-error list?.current?.scrollToOffset({ offset: scrollAmount, animated: animateScroll, }); } }, [calendarSize, shouldUseAndroidRTLFix, pastScrollRange, animateScroll] ); const addMonth = useCallback( (count: number) => { const day = currentMonth?.clone().addMonths(count, true); // @ts-ignore if (sameMonth(day, currentMonth) || getDateIndex(day) === -1) { return; } // @ts-ignore scrollToMonth(day); setCurrentMonth(day); }, [currentMonth, scrollToMonth] ); const getMarkedDatesForItem = useCallback( (item?: XDate) => { if (markedDates && item) { for (const [key, _] of Object.entries(markedDates)) { if (sameMonth(new XDate(key), new XDate(item))) { return markedDates; } } } }, [markedDates] ); const getItemLayout = useCallback( (_: Array | undefined | null, index: number) => { return { length: calendarSize, offset: calendarSize * index, index, }; }, [] ); const isDateInRange = useCallback( (date: any) => { for (let i = -range.current; i <= range.current; i++) { const newMonth = currentMonth?.clone().addMonths(i, true); if (sameMonth(date, newMonth)) { return true; } } return false; }, [currentMonth] ); const renderItem = useCallback( ({ item }: { item: XDate }) => { const dateString = toMarkingFormat(item); const [year, month] = dateString.split('-'); const testId = `${testID}.item_${year}-${month}`; return ( ); }, [ currentMonth, horizontal, calendarStyle, calendarWidth, testID, getMarkedDatesForItem, isDateInRange, calendarProps, ] ); const renderStaticHeader = () => { if (staticHeader && horizontal) { return ( ); } }; /** Viewable month */ const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 20, }); const onViewableItemsChanged = useCallback( ({ viewableItems }: any) => { const newVisibleMonth = parseDate(viewableItems[0]?.item); if (!newVisibleMonth) return; if (shouldUseAndroidRTLFix) { const centerIndex = items.findIndex((item) => isEqual(parseDate(current), item) ); const adjustedOffset = centerIndex - items.findIndex((item) => isEqual(newVisibleMonth, item)); visibleMonth.current = items[centerIndex + adjustedOffset]; setCurrentMonth(visibleMonth.current); } else { if (!sameDate(visibleMonth?.current, newVisibleMonth)) { visibleMonth.current = newVisibleMonth; setCurrentMonth(visibleMonth.current); } } }, [items, shouldUseAndroidRTLFix, current] ); const viewabilityConfigCallbackPairs = useRef([ { viewabilityConfig: viewabilityConfig.current, onViewableItemsChanged, }, ]); return ( items.length} getItem={(data, index) => data[index]} initialNumToRender={range.current} initialScrollIndex={initialDateIndex} viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current} testID={`${testID}.list`} onLayout={onLayout} removeClippedSubviews={removeClippedSubviews} pagingEnabled={pagingEnabled} scrollEnabled={scrollEnabled} scrollsToTop={scrollsToTop} horizontal={horizontal} keyboardShouldPersistTaps={keyboardShouldPersistTaps} keyExtractor={keyExtractor} onEndReachedThreshold={onEndReachedThreshold} onEndReached={onEndReached} nestedScrollEnabled={nestedScrollEnabled} onMomentumScrollBegin={onMomentumScrollBegin} onMomentumScrollEnd={onMomentumScrollEnd} onScrollBeginDrag={onScrollBeginDrag} onScrollEndDrag={onScrollEndDrag} /> {renderStaticHeader()} ); }; export default forwardRef(CalendarList); CalendarList.displayName = 'CalendarList'; CalendarList.propTypes = { ...Calendar.propTypes, pastScrollRange: PropTypes.number, futureScrollRange: PropTypes.number, calendarWidth: PropTypes.number, calendarHeight: PropTypes.number, calendarStyle: PropTypes.oneOfType([ PropTypes.object, PropTypes.number, PropTypes.array, ]), staticHeader: PropTypes.bool, showScrollIndicator: PropTypes.bool, animateScroll: PropTypes.bool, scrollEnabled: PropTypes.bool, scrollsToTop: PropTypes.bool, pagingEnabled: PropTypes.bool, horizontal: PropTypes.bool, keyboardShouldPersistTaps: PropTypes.oneOf(['never', 'always', 'handled']), keyExtractor: PropTypes.func, onEndReachedThreshold: PropTypes.number, onEndReached: PropTypes.func, nestedScrollEnabled: PropTypes.bool, };