import PropTypes from 'prop-types'; import XDate from 'xdate'; import React, {Component} from 'react'; import {FlatList, View, ViewStyle, LayoutChangeEvent, FlatListProps} from 'react-native'; import {extractComponentProps} from '../componentUpdater'; import {xdateToData, parseDate} from '../interface'; import {page, sameDate, sameMonth} from '../dateutils'; // @ts-expect-error import {STATIC_HEADER} from '../testIDs'; import styleConstructor from './style'; import Calendar, {CalendarProps} from '../calendar'; import CalendarListItem from './item'; import CalendarHeader from '../calendar/header/index'; import constants from '../commons/constants'; 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'> { /** Max amount of months allowed to scroll to the past. Default = 50 */ pastScrollRange?: number; /** Max amount of months allowed to scroll to the future. Default = 50 */ futureScrollRange?: number; /** Used when calendar scroll is horizontal, default is device width, pagination should be disabled */ calendarWidth?: number; /** Dynamic calendar height */ calendarHeight?: number; /** Style for the List item (the calendar) */ calendarStyle?: ViewStyle; /** Whether to use static header that will not scroll with the list (horizontal only) */ staticHeader?: boolean; /** Enable or disable vertical / horizontal scroll indicator. Default = false */ showScrollIndicator?: boolean; /** Whether to animate the auto month scroll */ animateScroll?: boolean; /** Enable or disable scrolling of calendar list */ scrollEnabled?: boolean; /** When true, the calendar list scrolls to top when the status bar is tapped. Default = true */ scrollsToTop?: boolean; /** Enable or disable paging on scroll */ pagingEnabled?: boolean; /** Whether the scroll is horizontal */ horizontal?: boolean; /** Should Keyboard persist taps */ keyboardShouldPersistTaps?: 'never' | 'always' | 'handled'; /** A custom key extractor for the generated calendar months */ keyExtractor?: (item: any, index: number) => string; /** How far from the end to trigger the onEndReached callback */ onEndReachedThreshold?: number; /** Called once when the scroll position gets within onEndReachedThreshold */ onEndReached?: () => void; /** onLayout event */ onLayout?: (event: LayoutChangeEvent) => void; removeClippedSubviews?: boolean; testID?: string; } type XDateAndBump = XDate & {propBump?: number}; type State = { rows: Array; texts: Array; openDate: XDate; currentMonth: XDate; }; /** * @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 */ class CalendarList extends Component { static displayName = 'CalendarList'; static 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 }; static defaultProps = { calendarWidth: CALENDAR_WIDTH, calendarHeight: CALENDAR_HEIGHT, pastScrollRange: PAST_SCROLL_RANGE, futureScrollRange: FUTURE_SCROLL_RANGE, showScrollIndicator: false, horizontal: false, scrollsToTop: false, scrollEnabled: true, removeClippedSubviews: constants.isAndroid, keyExtractor: (_: any, index: number) => String(index) }; style: any; list: React.RefObject = React.createRef(); viewabilityConfig = { itemVisiblePercentThreshold: 20 }; constructor(props: CalendarListProps) { super(props); this.style = styleConstructor(props.theme); const rows = []; const texts = []; const date = parseDate(props.current) || new XDate(); const {pastScrollRange = PAST_SCROLL_RANGE, futureScrollRange = FUTURE_SCROLL_RANGE} = props; for (let i = 0; i <= pastScrollRange + futureScrollRange; i++) { const rangeDate = date.clone().addMonths(i - pastScrollRange, true); const rangeDateStr = rangeDate.toString('MMM yyyy'); texts.push(rangeDateStr); /* * This selects range around current shown month [-0, +2] or [-1, +1] month for detail calendar rendering. * If `this.pastScrollRange` is `undefined` it's equal to `false` or 0 in next condition. */ if ( (pastScrollRange - 1 <= i && i <= pastScrollRange + 1) || (!pastScrollRange && i <= pastScrollRange + 2) ) { rows.push(rangeDate); } else { rows.push(rangeDateStr); } } this.state = { rows, texts, openDate: date, currentMonth: parseDate(props.current) }; } componentDidUpdate(prevProps: CalendarListProps) { const prevCurrent = parseDate(prevProps.current); const current = parseDate(this.props.current); if (current && prevCurrent && current.getTime() !== prevCurrent.getTime()) { this.scrollToMonth(current); } } static getDerivedStateFromProps(_: CalendarListProps, prevState: State) { const rowClone = prevState.rows; const newRows = []; for (let i = 0; i < rowClone.length; i++) { let val: XDate | string = prevState.texts[i]; // @ts-expect-error if (rowClone[i].getTime) { val = rowClone[i].clone(); // @ts-expect-error val.propBump = rowClone[i].propBump ? rowClone[i].propBump + 1 : 1; } newRows.push(val); } return {rows: newRows}; } scrollToDay(d: XDate, offset: number, animated: boolean) { const {horizontal, calendarHeight = CALENDAR_HEIGHT, calendarWidth = CALENDAR_WIDTH, pastScrollRange = PAST_SCROLL_RANGE, firstDay} = this.props; const day = parseDate(d); const diffMonths = Math.round(this.state.openDate.clone().setDate(1).diffMonths(day.clone().setDate(1))); const size = horizontal ? calendarWidth : calendarHeight; let scrollAmount = size * pastScrollRange + diffMonths * size + (offset || 0); if (!horizontal) { let week = 0; const days = page(day, firstDay); for (let i = 0; i < days.length; i++) { week = Math.floor(i / 7); if (sameDate(days[i], day)) { scrollAmount += 46 * week; break; } } } this.list?.current?.scrollToOffset({offset: scrollAmount, animated}); } scrollToMonth = (m: XDate) => { const {horizontal, calendarHeight = CALENDAR_HEIGHT, calendarWidth = CALENDAR_WIDTH, pastScrollRange = PAST_SCROLL_RANGE, animateScroll = false} = this.props; const month = parseDate(m); const scrollTo = month || this.state.openDate; const diffMonths = Math.round(this.state.openDate.clone().setDate(1).diffMonths(scrollTo.clone().setDate(1))); const size = horizontal ? calendarWidth : calendarHeight; const scrollAmount = size * pastScrollRange + diffMonths * size; this.list?.current?.scrollToOffset({offset: scrollAmount, animated: animateScroll}); }; getItemLayout = (_: Array | undefined | null, index: number) => { const {horizontal, calendarHeight = CALENDAR_HEIGHT, calendarWidth = CALENDAR_WIDTH} = this.props; const size = horizontal ? calendarWidth : calendarHeight; return { length: size, offset: size * index, index }; }; getMonthIndex(month: XDate) { const {pastScrollRange = PAST_SCROLL_RANGE} = this.props; const diffMonths = this.state.openDate.diffMonths(month) + pastScrollRange; return diffMonths; } addMonth = (count: number) => { this.updateMonth(this.state.currentMonth.clone().addMonths(count, true)); }; updateMonth(day: XDate) { if (sameMonth(day, this.state.currentMonth)) { return; } this.setState({currentMonth: day.clone()}, () => { this.scrollToMonth(this.state.currentMonth); const currMont = this.state.currentMonth.clone(); this.props.onMonthChange?.(xdateToData(currMont)); this.props.onVisibleMonthsChange?.([xdateToData(currMont)]); }); } onViewableItemsChanged = ({viewableItems}: any) => { function rowIsCloseToViewable(index: number, distance: number) { for (let i = 0; i < viewableItems.length; i++) { if (Math.abs(index - parseInt(viewableItems[i].index)) <= distance) { return true; } } return false; } const rowclone = this.state.rows; const newrows = []; const visibleMonths = []; for (let i = 0; i < rowclone.length; i++) { let val: XDate | string = rowclone[i]; const rowShouldBeRendered = rowIsCloseToViewable(i, 1); const {pastScrollRange = PAST_SCROLL_RANGE} = this.props; if (rowShouldBeRendered && !rowclone[i].getTime) { val = this.state.openDate.clone().addMonths(i - pastScrollRange, true); } else if (!rowShouldBeRendered) { val = this.state.texts[i]; } newrows.push(val); if (rowIsCloseToViewable(i, 0)) { const v = (val instanceof XDate) ? val : new XDate(val); visibleMonths.push(xdateToData(v)); } } this.props.onVisibleMonthsChange?.(visibleMonths); this.setState({ // @ts-expect-error rows: newrows, currentMonth: parseDate(visibleMonths[0]) }); }; renderItem = ({item}: any) => { const {calendarStyle, horizontal, calendarWidth, testID, ...others} = this.props; return ( ); }; renderStaticHeader() { const {staticHeader, horizontal, headerStyle} = this.props; const useStaticHeader = staticHeader && horizontal; const headerProps = extractComponentProps(CalendarHeader, this.props); if (useStaticHeader) { return ( ); } } render() { const {style, pastScrollRange, futureScrollRange, horizontal, showScrollIndicator} = this.props; return ( {this.renderStaticHeader()} ); } } export default CalendarList;