UNPKG

react-native-week-view

Version:
779 lines (709 loc) 24.1 kB
import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { View, InteractionManager, ActivityIndicator, Dimensions, } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import moment from 'moment'; import memoizeOne from 'memoize-one'; import Events from '../Events/Events'; import Header from '../Header/Header'; import Title from '../Title/Title'; import Times from '../Times/Times'; import VerticalAgenda from '../VerticalAgenda/VerticalAgenda'; import styles from './WeekView.styles'; import bucketEventsByDate from '../pipeline/box'; import { HorizontalSyncFlatList, HeaderRefContextProvider, } from '../utils/HorizontalScroll'; import { DATE_STR_FORMAT, availableNumberOfDays, setLocale, } from '../utils/dates'; import { mod } from '../utils/misc'; import { computeHorizontalDimensions } from '../utils/dimensions'; import { GridRowPropType, GridColumnPropType, EditEventConfigPropType, EventPropType, PageStartAtOptionsPropType, DragEventConfigPropType, } from '../utils/types'; import { PAGES_OFFSET, calculatePagesDates, getRawDayOffset, DEFAULT_WINDOW_SIZE, } from '../utils/pages'; import { RunGesturesOnJSContext } from '../utils/gestures'; import { VerticalDimensionsProvider } from '../utils/VerticalDimContext'; /** For some reason, this sign is necessary in all cases. */ const VIEW_OFFSET_SIGN = -1; const identity = (item) => item; const MINUTES_IN_DAY = 60 * 24; const calculateTimesArray = ( minutesStep, formatTimeLabel, beginAt = 0, endAt = MINUTES_IN_DAY, ) => { const times = []; const startOfDay = moment().startOf('day'); for ( let timer = beginAt >= 0 && beginAt < MINUTES_IN_DAY ? beginAt : 0; timer < endAt && timer < MINUTES_IN_DAY; timer += minutesStep ) { const time = startOfDay.clone().minutes(timer); times.push(time.format(formatTimeLabel)); } return times; }; export default class WeekView extends Component { constructor(props) { super(props); this.eventsGrid = React.createRef(); this.verticalAgenda = React.createRef(); this.currentPageIndex = PAGES_OFFSET; const initialDates = calculatePagesDates( props.selectedDate, props.numberOfDays, props.pageStartAt, props.prependMostRecent, ); const { width: windowWidth, height: windowHeight } = Dimensions.get( 'window', ); this.state = { // currentMoment should always be the first date of the current page currentMoment: moment(initialDates[this.currentPageIndex]).toDate(), initialDates, windowWidth, windowHeight, }; setLocale(props.locale); this.dimensions = {}; } componentDidMount() { requestAnimationFrame(() => { this.scrollToVerticalStart(); }); this.windowListener = Dimensions.addEventListener( 'change', ({ window }) => { const { width: windowWidth, height: windowHeight } = window; this.setState({ windowWidth, windowHeight }); }, ); } componentDidUpdate(prevProps, prevState) { if (this.props.locale !== prevProps.locale) { setLocale(this.props.locale); } if (this.props.numberOfDays !== prevProps.numberOfDays) { /** * HOTFIX: linter rules no-access-state-in-setstate and no-did-update-set-state * are disabled here for now. * TODO: apply a better solution for the `currentMoment` and `initialDates` logic, * without using componentDidUpdate() */ const initialDates = calculatePagesDates( // eslint-disable-next-line react/no-access-state-in-setstate this.state.currentMoment, this.props.numberOfDays, this.props.pageStartAt, this.props.prependMostRecent, ); this.currentPageIndex = PAGES_OFFSET; // eslint-disable-next-line react/no-did-update-set-state this.setState( { currentMoment: moment(initialDates[this.currentPageIndex]).toDate(), initialDates, }, () => { this.eventsGrid.current.scrollToIndex({ index: PAGES_OFFSET, animated: false, }); }, ); } if (this.state.windowWidth !== prevState.windowWidth) { // NOTE: after a width change, the position may be off by a few days this.eventsGrid.current.scrollToIndex({ index: this.currentPageIndex, animated: false, }); } } componentWillUnmount() { if (this.windowListener) { this.windowListener.remove(); } } calculateTimes = memoizeOne(calculateTimesArray); scrollToVerticalStart = () => { this.scrollToTime(this.props.startHour * 60, { animated: false }); }; scrollToTime = (minutes, options = {}) => { if (this.verticalAgenda.current) { this.verticalAgenda.current.scrollToTime(minutes, options); } }; handleTimeScrolled = (secondsInDay) => { const { onTimeScrolled } = this.props; if (!onTimeScrolled) { return; } const date = moment(this.state.currentMoment) .startOf('day') .seconds(secondsInDay) .toDate(); onTimeScrolled(date); }; isAppendingTheFuture = () => !this.props.prependMostRecent; getSignToTheFuture = () => (this.isAppendingTheFuture() ? 1 : -1); buildPages = (fromDate, nPages, appending) => { const timeSign = this.isAppendingTheFuture() === !!appending ? 1 : -1; const deltaDays = timeSign * this.props.numberOfDays; const newPages = Array.from({ length: nPages }, (_, index) => moment(fromDate) .add((index + 1) * deltaDays, 'days') .format(DATE_STR_FORMAT), ); return appending ? newPages : newPages.reverse(); }; goToDate = (targetDate, options) => { const targetDateMoment = moment(targetDate); if (!targetDateMoment || !targetDateMoment.isValid()) { return; } const { initialDates } = this.state; const { numberOfDays, allowScrollByDay } = this.props; // Compute target index const startOfPage = moment(initialDates[this.currentPageIndex]).startOf( 'day', ); const deltaDay = targetDateMoment.startOf('day').diff(startOfPage, 'day'); const deltaIndex = Math.floor(deltaDay / numberOfDays); const newDayOffset = mod(deltaDay, numberOfDays); const targetPageIndex = this.currentPageIndex + deltaIndex * this.getSignToTheFuture(); if (!allowScrollByDay) { this.goToPageIndex(targetPageIndex, null, options); return; } // Adjust offset const rawShiftOffset = getRawDayOffset(newDayOffset, options); const overflowPages = Math.floor(rawShiftOffset / numberOfDays); const targetDayOffset = mod(rawShiftOffset, numberOfDays); this.goToPageIndex( targetPageIndex + overflowPages, targetDayOffset, options, ); }; goToNextPage = (options) => this.goToPageIndex( this.currentPageIndex + this.getSignToTheFuture(), null, options, ); goToPrevPage = (options) => this.goToPageIndex( this.currentPageIndex - this.getSignToTheFuture(), null, options, ); goToNextDay = (options) => this.goToDate(moment(this.state.currentMoment).add(1, 'day'), options); goToPrevDay = (options) => this.goToDate(moment(this.state.currentMoment).add(-1, 'day'), options); /** * Computes the targetIndex and newState for a goToPage operation. * * Helper for goToPageIndex() method. * Notice a new targetIndex is returned, while the dayOffset is handled outside of this method. * * @param {Number} targetPageIndex index between (-infinity, infinity) indicating target page. * @param {Number} targetDayOffset day offset inside a page. * @returns [reindexedTargetIndex, newState] */ computeParamsToGoToIndex = (targetPageIndex, targetDayOffset) => { /** helper for readability */ const date2State = (dateStr) => moment(dateStr).add(targetDayOffset, 'day').toDate(); const { initialDates: oldPages } = this.state; const firstViewablePage = PAGES_OFFSET; const lastViewablePage = oldPages.length - PAGES_OFFSET; if (targetPageIndex < firstViewablePage) { const firstPageDate = oldPages[0]; const prependNeeded = firstViewablePage - targetPageIndex; const newPages = [ ...this.buildPages(firstPageDate, prependNeeded, false), ...oldPages, ]; const reIndexedTargetPage = PAGES_OFFSET; return [ reIndexedTargetPage, { initialDates: newPages, currentMoment: date2State(newPages[reIndexedTargetPage]), }, ]; } if (targetPageIndex > lastViewablePage) { const lastPageDate = oldPages[oldPages.length - 1]; const appendNeeded = targetPageIndex - lastViewablePage; const newPages = [ ...oldPages, ...this.buildPages(lastPageDate, appendNeeded, true), ]; const reIndexedTargetPage = newPages.length - PAGES_OFFSET; return [ reIndexedTargetPage, { initialDates: newPages, currentMoment: date2State(newPages[reIndexedTargetPage]), }, ]; } return [ targetPageIndex, { currentMoment: date2State(oldPages[targetPageIndex]), }, ]; }; /** * Computes the left-offset displayed in the current date. * * Helper method used in goToPageIndex() * */ getCurrentDayOffset = () => { const { initialDates, currentMoment } = this.state; return moment(currentMoment).diff( initialDates[this.currentPageIndex], 'day', ); }; /** * Navigates the view to a pageIndex and (optional) dayOffset. * * Adds more pages (if necessary), scrolls the List to the new index, * and updates this.currentPageIndex. * * @param {Number} targetPageIndex between (-infinity, infinity) indicating target page. * @param {Number} targetDayOffset day offset inside a page. * Only used if allowScrollByDay is true. */ goToPageIndex = (targetPageIndex, targetDayOffset, options = {}) => { const { allowScrollByDay } = this.props; if (targetPageIndex === this.currentPageIndex && !allowScrollByDay) { // If allowScrollByDay is false, cannot scroll through offsets return; } const dayOffset = !allowScrollByDay || targetDayOffset == null ? this.getCurrentDayOffset() : targetDayOffset; const viewOffset = targetDayOffset == null ? undefined : VIEW_OFFSET_SIGN * this.dimensions.dayWidth * dayOffset; const [moveToIndex, newState] = this.computeParamsToGoToIndex( targetPageIndex, targetDayOffset || 0, ); const { animated = true } = options || {}; this.setState(newState, () => // setTimeout is used to force calling scroll after UI is updated setTimeout(() => { this.eventsGrid.current.scrollToIndex({ index: moveToIndex, viewOffset, animated, }); this.currentPageIndex = moveToIndex; }, 0), ); }; horizontalScrollEnded = (newXPosition) => { const { pageWidth, dayWidth } = this.dimensions; const { initialDates, currentMoment: oldMoment } = this.state; const newPageIndex = Math.floor(newXPosition / pageWidth); const dayOffset = Math.round((newXPosition % pageWidth) / dayWidth); const movedPages = newPageIndex - this.currentPageIndex; this.currentPageIndex = newPageIndex; const newMoment = moment(initialDates[newPageIndex]) .add(dayOffset, 'd') .toDate(); const movedDays = moment(newMoment).diff(oldMoment, 'd'); if (movedDays === 0) { return; } InteractionManager.runAfterInteractions(() => { const newState = { currentMoment: newMoment, }; let newStateCallback = () => {}; const buffer = PAGES_OFFSET; const pagesToStartOfList = newPageIndex; const pagesToEndOfList = initialDates.length - newPageIndex - 1; if (movedPages < 0 && pagesToStartOfList < buffer) { const prependNeeded = buffer - pagesToStartOfList; newState.initialDates = [ ...this.buildPages(initialDates[0], prependNeeded, false), ...initialDates, ]; // After prepending, it needs to scroll to fix its position, // to mantain visible content position (mvcp) this.currentPageIndex += prependNeeded; const scrollToCurrentIndexAndOffset = () => this.eventsGrid.current.scrollToIndex({ index: this.currentPageIndex, viewOffset: VIEW_OFFSET_SIGN * dayOffset * dayWidth, animated: false, }); newStateCallback = () => setTimeout(scrollToCurrentIndexAndOffset, 0); } else if (movedPages > 0 && pagesToEndOfList < buffer) { const appendNeeded = buffer - pagesToEndOfList; newState.initialDates = [ ...initialDates, ...this.buildPages( initialDates[initialDates.length - 1], appendNeeded, true, ), ]; } this.setState(newState, newStateCallback); const { onSwipePrev: onSwipeToThePast, onSwipeNext: onSwipeToTheFuture, } = this.props; const callback = movedDays > 0 ? onSwipeToTheFuture : onSwipeToThePast; if (callback) { callback(newMoment); } }); }; bucketEventsByDate = memoizeOne(bucketEventsByDate); getListItemLayout = (item, index) => { const pageWidth = this.dimensions.pageWidth || 0; return { length: pageWidth, offset: pageWidth * index, index, }; }; render() { const { showTitle, numberOfDays, headerStyle, headerTextStyle, hourTextStyle, hourContainerStyle, gridRowStyle, gridColumnStyle, eventContainerStyle, eventTextStyle, allDayEventContainerStyle, AllDayEventComponent, DayHeaderComponent, TodayHeaderComponent, formatDateHeader, timesColumnWidth, onEventPress, onEventLongPress, events, hoursInDisplay, timeStep, beginAgendaAt, endAgendaAt, formatTimeLabel, allowScrollByDay, onGridClick, onGridLongPress, onEditEvent, editEventConfig, editingEvent, enableVerticalPinch, EventComponent, prependMostRecent, rightToLeft, fixedHorizontally, showNowLine, nowLineColor, dragEventConfig, onDragEvent, onMonthPress, onDayPress, isRefreshing, RefreshComponent, windowSize, initialNumToRender, maxToRenderPerBatch, updateCellsBatchingPeriod, removeClippedSubviews, disableVirtualization, runOnJS, onTimeScrolled, } = this.props; const { currentMoment, initialDates, windowWidth, windowHeight, } = this.state; const times = this.calculateTimes( timeStep, formatTimeLabel, beginAgendaAt, endAgendaAt, ); const { regularEvents: eventsByDate, allDayEvents, computeMaxVisibleLanesInHeader, } = this.bucketEventsByDate(events); const horizontalInverted = (prependMostRecent && !rightToLeft) || (!prependMostRecent && rightToLeft); const { pageWidth, dayWidth, timeLabelsWidth, } = computeHorizontalDimensions( windowWidth, numberOfDays, timesColumnWidth, ); this.dimensions = { dayWidth, pageWidth, }; const horizontalScrollProps = allowScrollByDay ? { decelerationRate: 'fast', snapToInterval: dayWidth, } : { pagingEnabled: true, }; return ( <GestureHandlerRootView style={styles.container}> <HeaderRefContextProvider> <View style={styles.headerAndTitleContainer}> <Title showTitle={showTitle} style={headerStyle} textStyle={headerTextStyle} currentDate={currentMoment} onMonthPress={onMonthPress} width={timeLabelsWidth} /> <Header numberOfDays={numberOfDays} currentDate={currentMoment} allDayEvents={allDayEvents} initialDates={initialDates} formatDate={formatDateHeader} style={headerStyle} textStyle={headerTextStyle} eventContainerStyle={allDayEventContainerStyle} EventComponent={AllDayEventComponent} TodayComponent={TodayHeaderComponent} DayComponent={DayHeaderComponent} rightToLeft={rightToLeft} computeMaxVisibleLanes={computeMaxVisibleLanesInHeader} onDayPress={onDayPress} onEventPress={onEventPress} onEventLongPress={onEventLongPress} dayWidth={dayWidth} horizontalInverted={horizontalInverted} getListItemLayout={this.getListItemLayout} windowSize={windowSize} initialNumToRender={initialNumToRender} maxToRenderPerBatch={maxToRenderPerBatch} updateCellsBatchingPeriod={updateCellsBatchingPeriod} /> </View> {isRefreshing && RefreshComponent && ( <RefreshComponent style={[ styles.loadingSpinner, { right: pageWidth / 2, top: windowHeight / 2 }, ]} /> )} <VerticalDimensionsProvider enableVerticalPinch={enableVerticalPinch} hoursInDisplay={hoursInDisplay} beginAgendaAt={beginAgendaAt} endAgendaAt={endAgendaAt} timeStep={timeStep} > <VerticalAgenda onTimeScrolled={onTimeScrolled && this.handleTimeScrolled} ref={this.verticalAgenda} > <View style={styles.scrollViewChild}> <Times times={times} containerStyle={hourContainerStyle} textStyle={hourTextStyle} width={timeLabelsWidth} /> <RunGesturesOnJSContext.Provider value={runOnJS}> <HorizontalSyncFlatList data={initialDates} getItemLayout={this.getListItemLayout} keyExtractor={identity} initialScrollIndex={PAGES_OFFSET} scrollEnabled={!fixedHorizontally} horizontal // eslint-disable-next-line react/jsx-props-no-spreading {...horizontalScrollProps} horizontalScrollEnded={this.horizontalScrollEnded} inverted={horizontalInverted} ref={this.eventsGrid} windowSize={windowSize} initialNumToRender={initialNumToRender} maxToRenderPerBatch={maxToRenderPerBatch} updateCellsBatchingPeriod={updateCellsBatchingPeriod} removeClippedSubviews={removeClippedSubviews} disableVirtualization={disableVirtualization} accessible accessibilityLabel="Grid with horizontal scroll" accessibilityHint="Grid with horizontal scroll" renderItem={({ item }) => { return ( <Events times={times} eventsByDate={eventsByDate} initialDate={item} numberOfDays={numberOfDays} onEventPress={onEventPress} onEventLongPress={onEventLongPress} onGridClick={onGridClick} onGridLongPress={onGridLongPress} beginAgendaAt={beginAgendaAt} EventComponent={EventComponent} eventContainerStyle={eventContainerStyle} eventTextStyle={eventTextStyle} gridRowStyle={gridRowStyle} gridColumnStyle={gridColumnStyle} rightToLeft={rightToLeft} showNowLine={showNowLine} nowLineColor={nowLineColor} onDragEvent={onDragEvent} pageWidth={pageWidth} dayWidth={dayWidth} onEditEvent={onEditEvent} editingEventId={editingEvent} editEventConfig={editEventConfig} dragEventConfig={dragEventConfig} /> ); }} /> </RunGesturesOnJSContext.Provider> </View> </VerticalAgenda> </VerticalDimensionsProvider> </HeaderRefContextProvider> </GestureHandlerRootView> ); } } WeekView.propTypes = { events: PropTypes.arrayOf(EventPropType), formatDateHeader: PropTypes.string, numberOfDays: PropTypes.oneOf(availableNumberOfDays).isRequired, timesColumnWidth: PropTypes.number, pageStartAt: PageStartAtOptionsPropType, onSwipeNext: PropTypes.func, onSwipePrev: PropTypes.func, onTimeScrolled: PropTypes.func, onEventPress: PropTypes.func, onEventLongPress: PropTypes.func, onGridClick: PropTypes.func, onGridLongPress: PropTypes.func, editingEvent: PropTypes.number, onEditEvent: PropTypes.func, editEventConfig: EditEventConfigPropType, dragEventConfig: DragEventConfigPropType, enableVerticalPinch: PropTypes.bool, headerStyle: PropTypes.object, headerTextStyle: PropTypes.object, hourTextStyle: PropTypes.object, hourContainerStyle: PropTypes.object, eventContainerStyle: PropTypes.object, eventTextStyle: PropTypes.object, allDayEventContainerStyle: PropTypes.object, gridRowStyle: GridRowPropType, gridColumnStyle: GridColumnPropType, selectedDate: PropTypes.instanceOf(Date).isRequired, locale: PropTypes.string, hoursInDisplay: PropTypes.number, allowScrollByDay: PropTypes.bool, timeStep: PropTypes.number, beginAgendaAt: PropTypes.number, endAgendaAt: PropTypes.number, formatTimeLabel: PropTypes.string, startHour: PropTypes.number, AllDayEventComponent: PropTypes.elementType, EventComponent: PropTypes.elementType, DayHeaderComponent: PropTypes.elementType, TodayHeaderComponent: PropTypes.elementType, showTitle: PropTypes.bool, rightToLeft: PropTypes.bool, fixedHorizontally: PropTypes.bool, prependMostRecent: PropTypes.bool, showNowLine: PropTypes.bool, nowLineColor: PropTypes.string, onDragEvent: PropTypes.func, onMonthPress: PropTypes.func, onDayPress: PropTypes.func, isRefreshing: PropTypes.bool, RefreshComponent: PropTypes.elementType, windowSize: PropTypes.number, initialNumToRender: PropTypes.number, maxToRenderPerBatch: PropTypes.number, updateCellsBatchingPeriod: PropTypes.number, removeClippedSubviews: PropTypes.bool, disableVirtualization: PropTypes.bool, runOnJS: PropTypes.bool, }; WeekView.defaultProps = { events: [], locale: 'en', hoursInDisplay: 6, timeStep: 60, beginAgendaAt: 0, endAgendaAt: MINUTES_IN_DAY, allowScrollByDay: false, formatTimeLabel: 'H:mm', startHour: 8, showTitle: true, rightToLeft: false, enableVerticalPinch: false, prependMostRecent: false, RefreshComponent: ActivityIndicator, windowSize: DEFAULT_WINDOW_SIZE, initialNumToRender: DEFAULT_WINDOW_SIZE, maxToRenderPerBatch: PAGES_OFFSET, updateCellsBatchingPeriod: 50, // RN default removeClippedSubviews: true, disableVirtualization: false, runOnJS: false, };