import first from 'lodash/first'; import values from 'lodash/values'; import isFunction from 'lodash/isFunction'; import throttle from 'lodash/throttle'; import PropTypes from 'prop-types'; import memoize from 'memoize-one'; import XDate from 'xdate'; import React, {Component} from 'react'; import { AccessibilityInfo, PanResponder, Animated, View, ViewStyle, Text, Image, ImageSourcePropType, PanResponderInstance, GestureResponderEvent, PanResponderGestureState } from 'react-native'; // @ts-expect-error import {CALENDAR_KNOB} from '../testIDs'; import {page, weekDayNames} from '../dateutils'; import {parseDate, toMarkingFormat} from '../interface'; import {Theme, DateData, Direction} from '../types'; import styleConstructor, {HEADER_HEIGHT, KNOB_CONTAINER_HEIGHT} from './style'; import CalendarList, {CalendarListProps} from '../calendar-list'; import Calendar from '../calendar'; import asCalendarConsumer from './asCalendarConsumer'; import WeekCalendar from './WeekCalendar'; import Week from './week'; import constants from '../commons/constants'; const commons = require('./commons'); const updateSources = commons.UpdateSources; 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'); export interface ExpandableCalendarProps extends CalendarListProps { /** the initial position of the calendar ('open' or 'closed') */ initialPosition?: Positions; /** callback that fires when the calendar is opened or closed */ onCalendarToggled?: (isOpen: boolean) => void; /** an option to disable the pan gesture and disable the opening and closing of the calendar (initialPosition will persist)*/ disablePan?: boolean; /** whether to hide the knob */ hideKnob?: boolean; /** source for the left arrow image */ leftArrowImageSource?: ImageSourcePropType; /** source for the right arrow image */ rightArrowImageSource?: ImageSourcePropType; /** whether to have shadow/elevation for the calendar */ allowShadow?: boolean; /** whether to disable the week scroll in closed position */ disableWeekScroll?: boolean; /** a threshold for opening the calendar with the pan gesture */ openThreshold?: number; /** a threshold for closing the calendar with the pan gesture */ closeThreshold?: number; /** Whether to close the calendar on day press. Default = true */ closeOnDayPress?: boolean; context?: any; } interface State { deltaY: Animated.Value; headerDeltaY: Animated.Value; position: Positions; screenReaderEnabled: boolean; } /** * @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 */ class ExpandableCalendar extends Component { static displayName = 'ExpandableCalendar'; static propTypes = { ...CalendarList.propTypes, initialPosition: PropTypes.oneOf(values(Positions)), onCalendarToggled: PropTypes.func, disablePan: PropTypes.bool, hideKnob: PropTypes.bool, leftArrowImageSource: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.func]), rightArrowImageSource: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.func]), allowShadow: PropTypes.bool, disableWeekScroll: PropTypes.bool, openThreshold: PropTypes.number, closeThreshold: PropTypes.number, closeOnDayPress: PropTypes.bool }; static 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 }; static positions = Positions; style = styleConstructor(this.props.theme); panResponder: PanResponderInstance; closedHeight: number; numberOfWeeks: number; openHeight: number; _height: number; _wrapperStyles: { style: ViewStyle; }; _headerStyles: { style: ViewStyle; }; _weekCalendarStyles: { style: ViewStyle; }; visibleMonth: number; visibleYear: number | undefined; initialDate: string; headerStyleOverride: Theme; header: React.RefObject = React.createRef(); wrapper: React.RefObject = React.createRef(); calendar: React.RefObject = React.createRef(); weekCalendar: React.RefObject = React.createRef(); constructor(props: ExpandableCalendarProps) { super(props); this.closedHeight = CLOSED_HEIGHT + (props.hideKnob ? 0 : KNOB_CONTAINER_HEIGHT); this.numberOfWeeks = this.getNumberOfWeeksInMonth(this.props.context.date); this.openHeight = this.getOpenHeight(); const startHeight = props.initialPosition === Positions.CLOSED ? this.closedHeight : this.openHeight; this._height = startHeight; this._wrapperStyles = {style: {height: startHeight}}; this._headerStyles = {style: {top: props.initialPosition === Positions.CLOSED ? 0 : -HEADER_HEIGHT}}; this._weekCalendarStyles = {style: {}}; this.visibleMonth = this.getMonth(this.props.context.date); this.visibleYear = this.getYear(this.props.context.date); this.initialDate = props.context.date; this.headerStyleOverride = { stylesheet: { calendar: { header: { week: { marginTop: 7, marginBottom: -4, // reduce space between dayNames and first line of dates flexDirection: 'row', justifyContent: 'space-around' } } } } }; this.state = { deltaY: new Animated.Value(startHeight), headerDeltaY: new Animated.Value(props.initialPosition === Positions.CLOSED ? 0 : -HEADER_HEIGHT), position: props.initialPosition || Positions.CLOSED, screenReaderEnabled: false }; this.panResponder = PanResponder.create({ onMoveShouldSetPanResponder: this.handleMoveShouldSetPanResponder, onPanResponderMove: this.handlePanResponderMove, onPanResponderRelease: this.handlePanResponderEnd, onPanResponderTerminate: this.handlePanResponderEnd }); } componentDidMount() { if (AccessibilityInfo) { if (AccessibilityInfo.isScreenReaderEnabled) { AccessibilityInfo.isScreenReaderEnabled().then(this.handleScreenReaderStatus); } else if (AccessibilityInfo.fetch) { // Support for older RN versions AccessibilityInfo.fetch().then(this.handleScreenReaderStatus); } } } componentDidUpdate(prevProps: ExpandableCalendarProps) { const {date} = this.props.context; if (date !== prevProps.context.date) { // date was changed from AgendaList, arrows or scroll this.scrollToDate(date); } } handleScreenReaderStatus = (screenReaderEnabled: any) => { this.setState({screenReaderEnabled}); }; updateNativeStyles() { this.wrapper?.current?.setNativeProps(this._wrapperStyles); if (!this.props.horizontal) { this.header?.current?.setNativeProps(this._headerStyles); } else { this.weekCalendar?.current?.setNativeProps(this._weekCalendarStyles); } } /** Scroll */ scrollToDate(date: XDate) { if (!this.props.horizontal) { this.calendar?.current?.scrollToDay(date, 0, true); } else if (this.getYear(date) !== this.visibleYear || this.getMonth(date) !== this.visibleMonth) { // don't scroll if the month is already visible this.calendar?.current?.scrollToMonth(date); } } scrollPage(next: boolean) { if (this.props.horizontal) { const d = parseDate(this.props.context.date); if (this.state.position === Positions.OPEN) { d.setDate(1); d.addMonths(next ? 1 : -1); } else { const {firstDay = 0} = this.props; let dayOfTheWeek = d.getDay(); if (dayOfTheWeek < firstDay && firstDay > 0) { dayOfTheWeek = 7 + dayOfTheWeek; } const firstDayOfWeek = (next ? 7 : -7) - dayOfTheWeek + firstDay; d.addDays(firstDayOfWeek); } this.props.context.setDate?.(toMarkingFormat(d), updateSources.PAGE_SCROLL); } } /** Utils */ getOpenHeight() { if (!this.props.horizontal) { return Math.max(constants.screenHeight, constants.screenWidth); } return CLOSED_HEIGHT + (WEEK_HEIGHT * (this.numberOfWeeks - 1)) + (this.props.hideKnob ? 12 : KNOB_CONTAINER_HEIGHT) + (constants.isAndroid ? 3 : 0); } getYear(date: XDate) { const d = new XDate(date); return d.getFullYear(); } getMonth(date: XDate) { const d = new XDate(date); // getMonth() returns the month of the year (0-11). Value is zero-index, meaning Jan=0, Feb=1, Mar=2, etc. return d.getMonth() + 1; } getNumberOfWeeksInMonth(month: string) { const days = page(parseDate(month), this.props.firstDay); return days.length / 7; } shouldHideArrows() { if (!this.props.horizontal) { return true; } return this.props.hideArrows || false; } isLaterDate(date1?: DateData, date2?: XDate) { if (date1 && date2) { if (date1.year > this.getYear(date2)) { return true; } if (date1.year === this.getYear(date2)) { if (date1.month > this.getMonth(date2)) { return true; } } } return false; } /** Pan Gesture */ handleMoveShouldSetPanResponder = (_: GestureResponderEvent, gestureState: PanResponderGestureState) => { if (this.props.disablePan) { return false; } if (!this.props.horizontal && this.state.position === Positions.OPEN) { // disable pan detection when vertical calendar is open to allow calendar scroll return false; } if (this.state.position === Positions.CLOSED && gestureState.dy < 0) { // disable pan detection to limit to closed height return false; } return gestureState.dy > 5 || gestureState.dy < -5; }; handlePanResponderMove = (_: GestureResponderEvent, gestureState: PanResponderGestureState) => { // limit min height to closed height this._wrapperStyles.style.height = Math.max(this.closedHeight, this._height + gestureState.dy); if (!this.props.horizontal) { // vertical CalenderList header this._headerStyles.style.top = Math.min(Math.max(-gestureState.dy, -HEADER_HEIGHT), 0); } else { // horizontal Week view if (this.state.position === Positions.CLOSED) { this._weekCalendarStyles.style.opacity = Math.min(1, Math.max(1 - gestureState.dy / 100, 0)); } } this.updateNativeStyles(); }; handlePanResponderEnd = () => { this._height = Number(this._wrapperStyles.style.height); this.bounceToPosition(); }; /** Animated */ bounceToPosition(toValue = 0) { if (!this.props.disablePan) { const {deltaY, position} = this.state; const {openThreshold = PAN_GESTURE_THRESHOLD, closeThreshold = PAN_GESTURE_THRESHOLD} = this.props; const threshold = position === Positions.OPEN ? this.openHeight - closeThreshold : this.closedHeight + openThreshold; let isOpen = this._height >= threshold; const newValue = isOpen ? this.openHeight : this.closedHeight; deltaY.setValue(this._height); // set the start position for the animated value this._height = toValue || newValue; isOpen = this._height >= threshold; // re-check after this._height was set Animated.spring(deltaY, { toValue: this._height, speed: SPEED, bounciness: BOUNCINESS, useNativeDriver: false }).start(this.onAnimatedFinished); this.props.onCalendarToggled?.(isOpen); this.setPosition(); this.closeHeader(isOpen); this.resetWeekCalendarOpacity(isOpen); } } onAnimatedFinished = (result: {finished: boolean}) => { if (result?.finished) { // this.setPosition(); } }; setPosition() { const isClosed = this._height === this.closedHeight; this.setState({position: isClosed ? Positions.CLOSED : Positions.OPEN}); } resetWeekCalendarOpacity(isOpen: boolean) { this._weekCalendarStyles.style.opacity = isOpen ? 0 : 1; this.updateNativeStyles(); } closeHeader(isOpen: boolean) { const {headerDeltaY} = this.state; headerDeltaY.setValue(Number(this._headerStyles.style.top)); // set the start position for the animated value if (!this.props.horizontal && !isOpen) { Animated.spring(headerDeltaY, { toValue: 0, speed: SPEED / 10, bounciness: 1, useNativeDriver: false }).start(); } } /** Events */ onPressArrowLeft = (method: () => void, month?: XDate) => { this.props.onPressArrowLeft?.(method, month); this.scrollPage(false); }; onPressArrowRight = (method: () => void, month?: XDate) => { this.props.onPressArrowRight?.(method, month); this.scrollPage(true); }; onDayPress = (value: DateData) => { // {year: 2019, month: 4, day: 22, timestamp: 1555977600000, dateString: "2019-04-23"} this.props.context.setDate?.(value.dateString, updateSources.DAY_PRESS); if (this.props.closeOnDayPress) { setTimeout(() => { // to allows setDate to be completed if (this.state.position === Positions.OPEN) { this.bounceToPosition(this.closedHeight); } }, 0); } if (this.props.onDayPress) { this.props.onDayPress(value); } }; onVisibleMonthsChange = throttle( (value: DateData[]) => { const month = first(value)?.month; // equivalent to this.getMonth(value[0].dateString) if (month && this.visibleMonth !== month) { this.visibleMonth = month; if (first(value)?.year) { this.visibleYear = first(value)?.year; } // for horizontal scroll const {date} = this.props.context; if (this.visibleMonth !== this.getMonth(date)) { const next = this.isLaterDate(first(value), date); this.scrollPage(next); } // updating openHeight setTimeout(() => { // to wait for setDate() call in horizontal scroll (this.scrollPage()) const numberOfWeeks = this.getNumberOfWeeksInMonth(this.props.context.date); if (numberOfWeeks !== this.numberOfWeeks) { this.numberOfWeeks = numberOfWeeks; this.openHeight = this.getOpenHeight(); if (this.state.position === Positions.OPEN) { this.bounceToPosition(this.openHeight); } } }, 0); } }, 100, {trailing: true, leading: false} ); /** Renders */ getWeekDaysStyle = memoize(calendarStyle => { return [ this.style.weekDayNames, { paddingLeft: calendarStyle?.paddingLeft + 6 || DAY_NAMES_PADDING, paddingRight: calendarStyle?.paddingRight + 6 || DAY_NAMES_PADDING } ]; }); renderWeekDaysNames = memoize((weekDaysNames, calendarStyle) => { return ( {weekDaysNames.map((day: string, index: number) => ( {day} ))} ); }); renderHeader() { const monthYear = new XDate(this.props.context.date).toString('MMMM yyyy'); const weekDaysNames = weekDayNames(this.props.firstDay); return ( {monthYear} {this.renderWeekDaysNames(weekDaysNames, this.props.calendarStyle)} ); } renderWeekCalendar() { const {position} = this.state; const {disableWeekScroll} = this.props; const WeekComponent = disableWeekScroll ? Week : WeekCalendar; const weekCalendarProps = disableWeekScroll ? undefined : {allowShadow: false}; return ( ); } renderKnob() { // TODO: turn to TouchableOpacity with onPress that closes it return ( ); } renderArrow = (direction: Direction) => { const {renderArrow, rightArrowImageSource = RIGHT_ARROW, leftArrowImageSource = LEFT_ARROW, testID} = this.props; if (isFunction(renderArrow)) { return renderArrow(direction); } return ( ); }; render() { const {style, hideKnob, horizontal, allowShadow, theme, ...others} = this.props; const {deltaY, position, screenReaderEnabled} = this.state; const isOpen = position === Positions.OPEN; const themeObject = Object.assign(this.headerStyleOverride, theme); return ( {screenReaderEnabled ? ( ) : ( {horizontal && this.renderWeekCalendar()} {!hideKnob && this.renderKnob()} {!horizontal && this.renderHeader()} )} ); } } export default asCalendarConsumer(ExpandableCalendar);