import first from 'lodash/first'; import isFunction from 'lodash/isFunction'; import isNumber from 'lodash/isNumber'; import XDate from 'xdate'; import React, { useContext, useRef, useState, useEffect, useCallback, useMemo, } from 'react'; import { AccessibilityInfo, PanResponder, Animated, View, Text, ImageSourcePropType, GestureResponderEvent, PanResponderGestureState, TouchableOpacity, StyleSheet, } from 'react-native'; import { page } from '../dateutils'; import { parseDate, toMarkingFormat } from '../interface'; import { DateData, Direction } from '../types'; import styleConstructor, { HEADER_HEIGHT, KNOB_CONTAINER_HEIGHT, } from './style'; import WeekDaysNames from '../commons/WeekDaysNames'; // import Calendar from '../calendar'; import CalendarList, { CalendarListProps } from '../calendar-list'; import Week from './week'; import WeekCalendar from './WeekCalendar'; import Context from './Context'; import constants from '../commons/constants'; import { UpdateSources } from './commons'; import Image from '../../../Image'; export enum Positions { CLOSED = 'closed', OPEN = 'open', } const SPEED = 20; const BOUNCINESS = 6; const CLOSED_HEIGHT = 120; // header + 1 week const WEEK_HEIGHT = 46; const DAY_NAMES_PADDING = 24; const PAN_GESTURE_THRESHOLD = 30; const LEFT_ARROW = require('../calendar/img/previous.png'); const RIGHT_ARROW = require('../calendar/img/next.png'); const knobHitSlop = { left: 10, right: 10, top: 10, bottom: 10 }; export interface ExpandableCalendarProps extends CalendarListProps { /** * 日历的初始位置(“打开”或“关闭”) */ initialPosition?: Positions; /** * 打开或关闭日历时触发的回调 */ onCalendarToggled?: (isOpen: boolean) => void; /** * 禁用平移手势并禁用日历打开和关闭的选项(initialPosition 将保留) */ disablePan?: boolean; /** * 是否隐藏下方旋钮 */ hideKnob?: boolean; /** * 左箭头图像的来源 */ leftArrowImageSource?: ImageSourcePropType; /** * 右箭头图像的来源 */ rightArrowImageSource?: ImageSourcePropType; /** * 日历是否有阴影/高程 */ allowShadow?: boolean; /** * 是否在关闭位置禁用周滚动 */ disableWeekScroll?: boolean; /** * 用平移手势打开日历的阈值 */ openThreshold?: number; /** * 使用平移手势关闭日历的阈值 */ closeThreshold?: number; /** * 是否在按下当天关闭日历。默认值 = true */ closeOnDayPress?: boolean; /** * 一周的高度 * @default 46 */ constantWeekHeight?: number; /** * 周试图时的高度 * @default 120 */ constantClosedHeight?: number; /** * 禁用滚动更改日期 * @default true */ disableScrollChangeDate?: boolean; /** * 滚动到对应的月份 */ visibleDate?: string; /** * 是否隐藏头部 */ hideHeader?: boolean; } const headerStyleOverride = { stylesheet: { calendar: { header: { week: { marginTop: 7, marginBottom: -4, // reduce space between dayNames and first line of dates flexDirection: 'row', justifyContent: 'space-around', }, }, }, }, }; /** * @description: Expandable calendar component * @note: Should be wrapped with 'CalendarProvider' * @extends: CalendarList * @extendslink: docs/CalendarList * @example: https://github.com/wix/react-native-calendars/blob/master/example/src/screens/expandableCalendar.js */ const ExpandableCalendar = (props: ExpandableCalendarProps) => { const context = useContext(Context); const { date, setDate, numberOfDays, timelineLeftInset } = context; const { /** ExpandableCalendar props */ initialPosition = Positions.CLOSED, onCalendarToggled, disablePan, hideKnob = numberOfDays && numberOfDays > 1, leftArrowImageSource = LEFT_ARROW, rightArrowImageSource = RIGHT_ARROW, allowShadow = true, disableWeekScroll, openThreshold = PAN_GESTURE_THRESHOLD, closeThreshold = PAN_GESTURE_THRESHOLD, closeOnDayPress = true, constantWeekHeight = WEEK_HEIGHT, constantClosedHeight = CLOSED_HEIGHT, disableScrollChangeDate = true, visibleDate: outVisibleDate, /** CalendarList props */ horizontal = true, calendarStyle, theme, style: propsStyle, firstDay = 0, onDayPress, hideArrows, onPressArrowLeft, onPressArrowRight, renderArrow, testID, ...others } = props; // const [screenReaderEnabled, setScreenReaderEnabled] = useState(false); /** Date */ const getYear = (date: string) => { const d = new XDate(date); return d.getFullYear(); }; const getMonth = (date: string) => { const d = new XDate(date); return d.getMonth() + 1; // getMonth() returns month's index' (0-11) }; const visibleMonth = useRef(getMonth(date)); const visibleYear = useRef(getYear(date)); const isLaterDate = (date1?: DateData, date2?: string) => { if (date1 && date2) { if (date1.year > getYear(date2)) { return true; } if (date1.year === getYear(date2)) { if (date1.month > getMonth(date2)) { return true; } } } return false; }; useEffect(() => { // date was changed from AgendaList, arrows or scroll scrollToDate(date); }, [date]); useEffect(() => { if (outVisibleDate) { scrollToDate(outVisibleDate); // 根据时间计算出日历的高度 const _numberOfWeeks = getNumberOfWeeksInMonth(outVisibleDate); if (_numberOfWeeks !== numberOfWeeks.current) { numberOfWeeks.current = _numberOfWeeks; openHeight.current = getOpenHeight(); if (isOpen) { bounceToPosition(openHeight.current); } } } }, [outVisibleDate]); /** Number of weeks */ const getNumberOfWeeksInMonth = (month: string) => { const days = page(new XDate(month), firstDay); return days.length / 7; }; const numberOfWeeks = useRef(getNumberOfWeeksInMonth(date)); /** Position */ const [position, setPosition] = useState( numberOfDays ? Positions.CLOSED : initialPosition ); const isOpen = position === Positions.OPEN; const getOpenHeight = () => { if (!horizontal) { return Math.max(constants.screenHeight, constants.screenWidth); } return ( constantClosedHeight + constantWeekHeight * (numberOfWeeks.current - 1) + (hideKnob ? 12 : KNOB_CONTAINER_HEIGHT) + (constants.isAndroid ? 8 : 4) ); }; const openHeight = useRef(getOpenHeight()); const closedHeight = useMemo( () => constantClosedHeight + (hideKnob || Number(numberOfDays) > 1 ? 0 : KNOB_CONTAINER_HEIGHT), [numberOfDays, hideKnob] ); const startHeight = useMemo( () => (isOpen ? openHeight.current : closedHeight), [closedHeight, isOpen] ); const _height = useRef(startHeight); const deltaY = useMemo(() => new Animated.Value(startHeight), [startHeight]); const headerDeltaY = useRef(new Animated.Value(isOpen ? -HEADER_HEIGHT : 0)); useEffect(() => { _height.current = startHeight; deltaY.setValue(startHeight); }, [startHeight]); useEffect(() => { if (numberOfDays) { setPosition(Positions.CLOSED); } }, [numberOfDays]); /** Components' refs */ const wrapper = useRef(); const calendarList = useRef(); const header = useRef(); const weekCalendarWrapper = useRef(); /** Styles */ const style = useRef(styleConstructor(theme)); const themeObject = Object.assign(headerStyleOverride, theme); const _wrapperStyles = useRef({ style: { height: startHeight } }); const _headerStyles = { style: { top: isOpen ? -HEADER_HEIGHT : 0 } }; const _weekCalendarStyles = { style: { opacity: isOpen ? 0 : 1 } }; const shouldHideArrows = !horizontal ? true : hideArrows || false; const updateNativeStyles = () => { wrapper?.current?.setNativeProps(_wrapperStyles.current); if (!horizontal) { header?.current?.setNativeProps(_headerStyles); } else { weekCalendarWrapper?.current?.setNativeProps(_weekCalendarStyles); } }; const weekDaysStyle = useMemo(() => { const leftPaddings = calendarStyle?.paddingLeft; const rightPaddings = calendarStyle?.paddingRight; return [ style.current.weekDayNames, { paddingLeft: isNumber(leftPaddings) ? leftPaddings + 6 : DAY_NAMES_PADDING, paddingRight: isNumber(rightPaddings) ? rightPaddings + 6 : DAY_NAMES_PADDING, }, ]; }, [calendarStyle]); const animatedHeaderStyle = useMemo(() => { return [ style.current.header, { height: HEADER_HEIGHT + 10, top: headerDeltaY.current }, ]; }, [headerDeltaY.current]); const weekCalendarStyle = useMemo(() => { return [ style.current.weekContainer, isOpen ? style.current.hidden : style.current.visible, ]; }, [isOpen]); const containerStyle = useMemo(() => { return [allowShadow && style.current.containerShadow, propsStyle]; }, [allowShadow, propsStyle]); const wrapperStyle = useMemo(() => { return { height: deltaY }; }, [deltaY]); const numberOfDaysHeaderStyle = useMemo(() => { if (numberOfDays && numberOfDays > 1) { return { paddingHorizontal: 0 }; } }, [numberOfDays]); const _headerStyle = useMemo(() => { return [numberOfDaysHeaderStyle, props.headerStyle]; }, [props.headerStyle, numberOfDaysHeaderStyle]); /** AccessibilityInfo */ useEffect(() => { if (AccessibilityInfo) { if (AccessibilityInfo.isScreenReaderEnabled) { AccessibilityInfo.isScreenReaderEnabled().then( handleScreenReaderStatus ); //@ts-expect-error } else if (AccessibilityInfo.fetch) { // Support for older RN versions //@ts-expect-error AccessibilityInfo.fetch().then(handleScreenReaderStatus); } } }, []); const handleScreenReaderStatus = (_: boolean) => { // setScreenReaderEnabled(_); }; /** Scroll */ const scrollToDate = (date: string) => { if (!horizontal) { calendarList?.current?.scrollToDay(date, 0, true); } else if ( getYear(date) !== visibleYear.current || getMonth(date) !== visibleMonth.current ) { // don't scroll if the month is already visible calendarList?.current?.scrollToMonth(date); visibleYear.current = getYear(date); visibleMonth.current = getMonth(date); } }; const scrollPage = useCallback( (next: boolean) => { if (horizontal) { const d = parseDate(date)!; if (isOpen) { d.setDate(1); d.addMonths(next ? 1 : -1); } else { let dayOfTheWeek = d.getDay(); if (dayOfTheWeek < firstDay && firstDay > 0) { dayOfTheWeek = 7 + dayOfTheWeek; } if (numberOfDays) { const daysToAdd = numberOfDays <= 1 ? 7 : numberOfDays; d.addDays(next ? daysToAdd : -daysToAdd); } else { const firstDayOfWeek = (next ? 7 : -7) - dayOfTheWeek + firstDay; d.addDays(firstDayOfWeek); } } if (!disableScrollChangeDate) { setDate?.(toMarkingFormat(d), UpdateSources.PAGE_SCROLL); } } }, [horizontal, isOpen, firstDay, numberOfDays, setDate, date] ); /** Pan Gesture */ const handleMoveShouldSetPanResponder = ( _: GestureResponderEvent, gestureState: PanResponderGestureState ) => { if (disablePan) { return false; } if (!horizontal && isOpen) { // disable pan detection when vertical calendar is open to allow calendar scroll return false; } if (!isOpen && gestureState.dy < 0) { // disable pan detection to limit to closed height return false; } return gestureState.dy > 5 || gestureState.dy < -5; }; const handlePanResponderMove = ( _: GestureResponderEvent, gestureState: PanResponderGestureState ) => { // limit min height to closed height and max to open height _wrapperStyles.current.style.height = Math.min( Math.max(closedHeight, _height.current + gestureState.dy), openHeight.current ); if (!horizontal) { // vertical CalenderList header _headerStyles.style.top = Math.min( Math.max(-gestureState.dy, -HEADER_HEIGHT), 0 ); } else { // horizontal Week view if (!isOpen) { _weekCalendarStyles.style.opacity = Math.min( 1, Math.max(1 - gestureState.dy / 100, 0) ); } else if (gestureState.dy < 0) { _weekCalendarStyles.style.opacity = Math.max( 0, Math.min(Math.abs(gestureState.dy / 200), 1) ); } } updateNativeStyles(); }; const handlePanResponderEnd = () => { _height.current = Number(_wrapperStyles.current.style.height); bounceToPosition(); }; const numberOfDaysCondition = useMemo(() => { return !numberOfDays || (numberOfDays && numberOfDays <= 1); }, [numberOfDays]); const panResponder = useMemo( () => numberOfDaysCondition ? PanResponder.create({ onMoveShouldSetPanResponder: handleMoveShouldSetPanResponder, onPanResponderMove: handlePanResponderMove, onPanResponderRelease: handlePanResponderEnd, onPanResponderTerminate: () => { // 修复上下滑动时响应者切换后,isOpen 状态不对 _wrapperStyles.current.style.height = _height.current; handlePanResponderEnd(); }, }) : PanResponder.create({}), [numberOfDays, position] ); /** Animated */ const bounceToPosition = (toValue = 0) => { if (!disablePan) { const threshold = isOpen ? openHeight.current - closeThreshold : closedHeight + openThreshold; let _isOpen = _height.current >= threshold; const newValue = _isOpen ? openHeight.current : closedHeight; deltaY.setValue(_height.current); // set the start position for the animated value _height.current = toValue || newValue; _isOpen = _height.current >= threshold; // re-check after _height.current was set Animated.spring(deltaY, { toValue: _height.current, speed: SPEED, bounciness: BOUNCINESS, useNativeDriver: false, }).start(); onCalendarToggled?.(_isOpen); setPosition(() => _height.current === closedHeight ? Positions.CLOSED : Positions.OPEN ); closeHeader(_isOpen); resetWeekCalendarOpacity(_isOpen); } }; const resetWeekCalendarOpacity = (isOpen: boolean) => { _weekCalendarStyles.style.opacity = isOpen ? 0 : 1; updateNativeStyles(); }; const closeHeader = (isOpen: boolean) => { headerDeltaY.current.setValue(Number(_headerStyles.style.top)); // set the start position for the animated value if (!horizontal && !isOpen) { Animated.spring(headerDeltaY.current, { toValue: 0, speed: SPEED / 10, bounciness: 1, useNativeDriver: false, }).start(); } }; const closeCalendar = useCallback(() => { setTimeout(() => { // to allows setDate to be completed if (isOpen) { bounceToPosition(closedHeight); } }, 0); }, [isOpen, closedHeight]); const toggleCalendarPosition = useCallback(() => { bounceToPosition(isOpen ? closedHeight : openHeight.current); }, [isOpen, bounceToPosition, closedHeight]); /** Events */ const _onPressArrowLeft = useCallback( (method: () => void, month?: XDate) => { onPressArrowLeft?.(method, month); scrollPage(false); }, [onPressArrowLeft, scrollPage] ); const _onPressArrowRight = useCallback( (method: () => void, month?: XDate) => { onPressArrowRight?.(method, month); scrollPage(true); }, [onPressArrowRight, scrollPage] ); const _onDayPress = useCallback( (value: DateData) => { if (numberOfDaysCondition) { setDate?.(value.dateString, UpdateSources.DAY_PRESS); } if (closeOnDayPress) { closeCalendar(); } onDayPress?.(value); }, [onDayPress, closeOnDayPress, closeCalendar, numberOfDaysCondition] ); const onVisibleMonthsChange = useCallback( (value: DateData[]) => { const newDate = first(value); if (newDate) { const month = newDate.month; if (month && visibleMonth.current !== month) { visibleMonth.current = month; const year = newDate.year; if (year) { visibleYear.current = year; } // for horizontal scroll if (visibleMonth.current !== getMonth(date)) { const next = isLaterDate(newDate, date); scrollPage(next); } // updating openHeight setTimeout(() => { // to wait for setDate() call in horizontal scroll (scrollPage()) const _numberOfWeeks = getNumberOfWeeksInMonth(newDate.dateString); if (_numberOfWeeks !== numberOfWeeks.current) { if (isOpen) { // 修复 knob 位置不正确 _wrapperStyles.current.style.height = openHeight.current; updateNativeStyles(); } numberOfWeeks.current = _numberOfWeeks; openHeight.current = getOpenHeight(); if (isOpen) { bounceToPosition(openHeight.current); } } }, 0); } } }, [date, scrollPage] ); /** Renders */ const _renderArrow = useCallback( (direction: Direction) => { if (isFunction(renderArrow)) { return renderArrow(direction); } return ( ); }, [renderArrow, rightArrowImageSource, leftArrowImageSource, testID] ); const renderWeekDaysNames = () => { return ( ); }; const renderAnimatedHeader = () => { const monthYear = new XDate(date)?.toString('MMMM yyyy'); return ( {monthYear} {renderWeekDaysNames()} ); }; const renderKnob = () => { return ( ); }; const renderWeekCalendar = () => { const WeekComponent = disableWeekScroll ? Week : WeekCalendar; return ( ); }; const renderCalendarList = () => { return ( ); }; return ( {/* {screenReaderEnabled ? ( ) : ( )} */} {renderCalendarList()} {renderWeekCalendar()} {!hideKnob && renderKnob()} {!horizontal && renderAnimatedHeader()} ); }; export default ExpandableCalendar; ExpandableCalendar.displayName = 'ExpandableCalendar'; ExpandableCalendar.defaultProps = { horizontal: true, initialPosition: Positions.CLOSED, firstDay: 0, leftArrowImageSource: LEFT_ARROW, rightArrowImageSource: RIGHT_ARROW, allowShadow: true, openThreshold: PAN_GESTURE_THRESHOLD, closeThreshold: PAN_GESTURE_THRESHOLD, closeOnDayPress: true, }; ExpandableCalendar.positions = Positions;