diff --git a/example/App.js b/example/App.js index af509030..5c8e11b7 100644 --- a/example/App.js +++ b/example/App.js @@ -224,6 +224,7 @@ const App = ({}) => { editEventConfig={EDIT_EVENT_CONFIG} dragEventConfig={DRAG_EVENT_CONFIG} runOnJS={false} + enableVerticalPinch /> diff --git a/src/Event/Event.js b/src/Event/Event.js index 7f7dc4a4..1f7185f1 100644 --- a/src/Event/Event.js +++ b/src/Event/Event.js @@ -4,9 +4,7 @@ import { View, Text } from 'react-native'; import { GestureDetector, Gesture } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, - useAnimatedReaction, useSharedValue, - withTiming, withSpring, runOnJS, useDerivedValue, @@ -18,26 +16,17 @@ import { DragEventConfigPropType, } from '../utils/types'; import { RunGesturesOnJSContext } from '../utils/gestures'; +import { + computeHeight, + computeWidth, + computeLeft, + computeTop, +} from '../pipeline/position'; +import { useVerticalDimensionContext } from '../utils/VerticalDimContext'; const DEFAULT_COLOR = 'red'; -const UPDATE_EVENT_ANIMATION_DURATION = 150; const SIDES = ['bottom', 'top', 'left', 'right']; -const useCurrentDimension = (dimension) => { - const currentDimension = useSharedValue(dimension); - useAnimatedReaction( - () => dimension, - (newValue) => { - if (currentDimension.value !== newValue) { - currentDimension.value = withTiming(newValue, { - duration: UPDATE_EVENT_ANIMATION_DURATION, - }); - } - }, - ); - return currentDimension; -}; - const Circle = ({ side }) => ( onPress && onPress(event); const onLongPressWrapper = () => onLongPress && onLongPress(event); - const onDragWrapper = (dx, dy) => { - if (!onDrag) return; - - const newX = left + dx; - const newY = top + dy; - onDrag(event, newX, newY, width); - }; - const onEditWrapper = (side, resizedAmount) => { - if (!onEdit) return; - - const params = {}; - switch (side) { - case 'top': - params.top = top + resizedAmount; - break; - case 'bottom': - params.bottom = top + height + resizedAmount; - break; - case 'left': - params.left = left + resizedAmount; - break; - case 'right': - params.right = left + width + resizedAmount; - break; - default: - } - onEdit(event, params); - }; + const onDragWrapper = (newX, newY, width) => + onDrag && onDrag(event, newX, newY, width); + const onEditWrapper = (side, newPosition) => + onEdit && onEdit(event, side, newPosition); const resizeByEdit = { bottom: useSharedValue(0), @@ -132,10 +100,25 @@ const Event = ({ }; const translatedByDrag = useSharedValue({ x: 0, y: 0 }); - const currentWidth = useCurrentDimension(width); - const currentLeft = useCurrentDimension(left); - const currentTop = useCurrentDimension(top); - const currentHeight = useCurrentDimension(height); + const currentWidth = useDerivedValue(() => + computeWidth( + boxStartTimestamp, + boxEndTimestamp, + nLanes, + stackPosition, + dayWidth, + ), + ); + const currentLeft = useDerivedValue( + () => computeLeft(lane, nLanes, stackPosition, dayWidth), + [boxStartTimestamp, lane, nLanes, stackPosition, dayWidth], + ); + const currentTop = useDerivedValue(() => + computeTop(boxStartTimestamp, verticalResolution, beginAgendaAt), + ); + const currentHeight = useDerivedValue(() => + computeHeight(boxStartTimestamp, boxEndTimestamp, verticalResolution), + ); const dragStatus = useSharedValue(DRAG_STATUS.STATIC); const isPressing = useSharedValue(false); @@ -196,11 +179,17 @@ const Event = ({ } const { translationX, translationY } = evt; + // NOTE: do not delete these auxiliar variables + // currentDimension.value might be updated asyncly in some cases + const newX = currentLeft.value + translationX; + const newY = currentTop.value + translationY; + const width = currentWidth.value; + currentTop.value += translationY; currentLeft.value += translationX; translatedByDrag.value = { x: 0, y: 0 }; - runOnJS(onDragWrapper)(translationX, translationY); + runOnJS(onDragWrapper)(newX, newY, width); }) .onFinalize(() => { dragStatus.value = DRAG_STATUS.STATIC; @@ -265,22 +254,22 @@ const Event = ({ const { translationX, translationY } = panEvt; switch (side) { case 'top': - if (translationY < height) { + if (translationY < currentHeight.value) { resizeByEdit.top.value = translationY; } break; case 'bottom': - if (translationY > -height) { + if (translationY > -currentHeight.value) { resizeByEdit.bottom.value = translationY; } break; case 'left': - if (translationX < width) { + if (translationX < currentWidth.value) { resizeByEdit.left.value = translationX; } break; case 'right': - if (translationX > -width) { + if (translationX > -currentWidth.value) { resizeByEdit.right.value = translationX; } break; @@ -294,26 +283,37 @@ const Event = ({ } const resizedAmount = resizeByEdit[side].value; resizeByEdit[side].value = 0; + let newPosition = 0; switch (side) { case 'top': + newPosition = currentTop.value + resizedAmount; + currentTop.value += resizedAmount; currentHeight.value -= resizedAmount; break; case 'bottom': + newPosition = + currentTop.value + currentHeight.value + resizedAmount; + currentHeight.value += resizedAmount; break; case 'left': + newPosition = currentLeft.value + resizedAmount; + currentLeft.value += resizedAmount; currentWidth.value -= resizedAmount; break; case 'right': + newPosition = + currentLeft.value + currentWidth.value + resizedAmount; + currentWidth.value += resizedAmount; break; default: } - runOnJS(onEditWrapper)(side, resizedAmount); + runOnJS(onEditWrapper)(side, newPosition); }); return ( @@ -334,10 +334,7 @@ const Event = ({ ]} > {EventComponent ? ( - + ) : ( {event.description} @@ -355,10 +352,12 @@ const Event = ({ Event.propTypes = { event: EventPropType.isRequired, - top: PropTypes.number.isRequired, - left: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - width: PropTypes.number.isRequired, + boxStartTimestamp: PropTypes.number.isRequired, + boxEndTimestamp: PropTypes.number.isRequired, + lane: PropTypes.number, + nLanes: PropTypes.number, + stackPosition: PropTypes.number, + dayWidth: PropTypes.number.isRequired, onPress: PropTypes.func, onLongPress: PropTypes.func, containerStyle: PropTypes.object, diff --git a/src/Events/Events.js b/src/Events/Events.js index 097ecdad..5784d339 100644 --- a/src/Events/Events.js +++ b/src/Events/Events.js @@ -18,15 +18,13 @@ import { import { calculateDaysArray, availableNumberOfDays } from '../utils/dates'; import { topToSecondsInDay as topToSecondsInDayFromUtils } from '../utils/dimensions'; import { ViewWithTouchable } from '../utils/gestures'; +import { + VerticalDimensionContext, + useVerticalDimensionContext, +} from '../utils/VerticalDimContext'; import styles from './Events.styles'; import resolveEventOverlaps from '../pipeline/overlap'; -import { - computeHeight, - computeWidth, - computeLeft, - computeTop, -} from '../pipeline/position'; const processEvents = ( eventsByDate, @@ -41,9 +39,10 @@ const processEvents = ( return dates.map((date) => resolveEventOverlaps(eventsByDate[date] || [])); }; -const Lines = ({ initialDate, times, timeLabelHeight, gridRowStyle }) => { +const Lines = ({ initialDate, times, gridRowStyle }) => { + const { timeLabelHeight } = useVerticalDimensionContext(); const heightStyle = useAnimatedStyle(() => ({ - height: withTiming(timeLabelHeight), + height: withTiming(timeLabelHeight.value), })); return times.map((time) => ( topToSecondsInDayFromUtils( yValue, - this.props.verticalResolution, + this.context.verticalResolution, this.props.beginAgendaAt, ); @@ -120,40 +119,44 @@ class Events extends PureComponent { onDragEvent(event, newStartDate, newEndDate); }; - handleEditEvent = (event, params) => { + handleEditEvent = (event, side, newPosition) => { const { onEditEvent } = this.props; if (!onEditEvent) { return; } - if (!params || Object.keys(params).length === 0) { - return; - } let newStartDate = moment(event.startDate); let newEndDate = moment(event.endDate); - if (params.left != null) { - const daysToLeft = this.xToDayIndex(params.left); - newStartDate = newStartDate.add(daysToLeft, 'days'); - } - if (params.right != null) { - const newRightIndex = this.xToDayIndex(params.right); - const prevRightIndex = moment(event.endDate).diff( - event.startDate, - 'days', - ); - const movedRight = newRightIndex - prevRightIndex; - newEndDate = newEndDate.add(movedRight, 'days'); - } - if (params.top != null) { - newStartDate = newStartDate - .startOf('day') - .seconds(this.topToSecondsInDay(params.top)); - } - if (params.bottom != null) { - newEndDate = newEndDate - .startOf('day') - .seconds(this.topToSecondsInDay(params.bottom)); + switch (side) { + case 'left': { + const daysToLeft = this.xToDayIndex(newPosition); + newStartDate = newStartDate.add(daysToLeft, 'days'); + break; + } + case 'right': { + const newRightIndex = this.xToDayIndex(newPosition); + const prevRightIndex = moment(event.endDate).diff( + event.startDate, + 'days', + ); + const movedRight = newRightIndex - prevRightIndex; + newEndDate = newEndDate.add(movedRight, 'days'); + break; + } + case 'top': { + newStartDate = newStartDate + .startOf('day') + .seconds(this.topToSecondsInDay(newPosition)); + break; + } + case 'bottom': { + newEndDate = newEndDate + .startOf('day') + .seconds(this.topToSecondsInDay(newPosition)); + break; + } + default: } onEditEvent(event, newStartDate.toDate(), newEndDate.toDate()); @@ -187,8 +190,6 @@ class Events extends PureComponent { onGridLongPress, dayWidth, pageWidth, - timeLabelHeight, - verticalResolution, onEditEvent, editingEventId, editEventConfig, @@ -206,7 +207,6 @@ class Events extends PureComponent { @@ -234,10 +233,12 @@ class Events extends PureComponent { { return minutesInDay(now); }; -const NowLine = ({ verticalResolution, beginAgendaAt, color, width }) => { +const NowLine = ({ beginAgendaAt, color, width }) => { + const { verticalResolution } = useVerticalDimensionContext(); const minutesNow = useMinutesNow(); const currentTop = useDerivedValue(() => @@ -63,7 +65,6 @@ const NowLine = ({ verticalResolution, beginAgendaAt, color, width }) => { NowLine.propTypes = { width: PropTypes.number.isRequired, - verticalResolution: PropTypes.number.isRequired, beginAgendaAt: PropTypes.number, color: PropTypes.string, }; diff --git a/src/Times/Times.js b/src/Times/Times.js index 49e6ada9..5eabe685 100644 --- a/src/Times/Times.js +++ b/src/Times/Times.js @@ -6,16 +6,12 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import styles from './Times.styles'; +import { useVerticalDimensionContext } from '../utils/VerticalDimContext'; -const Times = ({ - times, - containerStyle, - textStyle, - width, - timeLabelHeight, -}) => { +const Times = ({ times, containerStyle, textStyle, width }) => { + const { timeLabelHeight } = useVerticalDimensionContext(); const lineStyle = useAnimatedStyle(() => ({ - height: withTiming(timeLabelHeight), + height: withTiming(timeLabelHeight.value), })); return ( @@ -32,7 +28,6 @@ Times.propTypes = { times: PropTypes.arrayOf(PropTypes.string).isRequired, textStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), width: PropTypes.number.isRequired, - timeLabelHeight: PropTypes.number.isRequired, }; export default React.memo(Times); diff --git a/src/VerticalAgenda/VerticalAgenda.js b/src/VerticalAgenda/VerticalAgenda.js new file mode 100644 index 00000000..6712332a --- /dev/null +++ b/src/VerticalAgenda/VerticalAgenda.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + GestureDetector, + ScrollView as GHScrollView, +} from 'react-native-gesture-handler'; + +import styles from './VerticalAgenda.styles'; +import { minutesInDayToTop, topToSecondsInDay } from '../utils/dimensions'; +import { useVerticalDimensionContext } from '../utils/VerticalDimContext'; + +const VerticalAgenda = React.forwardRef( + ({ onTimeScrolled, children }, upperRef) => { + const agendaRef = React.useRef(null); + + const { + verticalPinchGesture, + verticalResolution, + beginAgendaAt, + } = useVerticalDimensionContext(); + + const pinchRef = React.useRef(); + + React.useImperativeHandle(upperRef, () => ({ + scrollToTime: (minutes, options = {}) => { + if (agendaRef.current) { + const { animated = false } = options || {}; + const top = minutesInDayToTop( + minutes, + verticalResolution, + beginAgendaAt, + ); + agendaRef.current.scrollTo({ + y: top, + x: 0, + animated, + }); + } + }, + })); + + const isScrollingVertical = React.useRef(false); + const verticalScrollBegun = () => { + isScrollingVertical.current = true; + }; + + const verticalScrollEnded = (scrollEvent) => { + if (!isScrollingVertical.current) { + // Ensure the callback is called only once + return; + } + isScrollingVertical.current = false; + + if (!onTimeScrolled) { + return; + } + + const { + nativeEvent: { contentOffset }, + } = scrollEvent; + const { y: yPosition } = contentOffset; + + const secondsInDay = topToSecondsInDay( + yPosition, + verticalResolution, + beginAgendaAt, + ); + + onTimeScrolled(secondsInDay); + }; + + return ( + + + {children} + + + ); + }, +); + +VerticalAgenda.propTypes = { + onTimeScrolled: PropTypes.func, + children: PropTypes.element, +}; + +export default VerticalAgenda; diff --git a/src/VerticalAgenda/VerticalAgenda.styles.js b/src/VerticalAgenda/VerticalAgenda.styles.js new file mode 100644 index 00000000..087c9aae --- /dev/null +++ b/src/VerticalAgenda/VerticalAgenda.styles.js @@ -0,0 +1,15 @@ +import { StyleSheet, Platform } from 'react-native'; + +const styles = StyleSheet.create({ + scrollViewContentContainer: + Platform.OS === 'web' + ? { + height: '100vh', + } + : { flex: 0 }, + scrollView: { + flex: 1, + }, +}); + +export default styles; diff --git a/src/WeekView/WeekView.js b/src/WeekView/WeekView.js index d5c3be02..a77c9a91 100644 --- a/src/WeekView/WeekView.js +++ b/src/WeekView/WeekView.js @@ -2,7 +2,6 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { View, - ScrollView, InteractionManager, ActivityIndicator, Dimensions, @@ -15,6 +14,7 @@ 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 { @@ -27,12 +27,7 @@ import { setLocale, } from '../utils/dates'; import { mod } from '../utils/misc'; -import { - minutesInDayToTop, - topToSecondsInDay, - computeVerticalDimensions, - computeHorizontalDimensions, -} from '../utils/dimensions'; +import { computeHorizontalDimensions } from '../utils/dimensions'; import { GridRowPropType, GridColumnPropType, @@ -48,6 +43,7 @@ import { 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; @@ -174,49 +170,16 @@ export default class WeekView extends Component { scrollToTime = (minutes, options = {}) => { if (this.verticalAgenda.current) { - const { animated = false } = options || {}; - const { beginAgendaAt } = this.props; - const top = minutesInDayToTop( - minutes, - this.dimensions.verticalResolution, - beginAgendaAt, - ); - this.verticalAgenda.current.scrollTo({ - y: top, - x: 0, - animated, - }); + this.verticalAgenda.current.scrollToTime(minutes, options); } }; - verticalScrollBegun = () => { - this.isScrollingVertical = true; - }; - - verticalScrollEnded = (scrollEvent) => { - if (!this.isScrollingVertical) { - // Ensure the callback is called only once, same as with horizontal case - return; - } - this.isScrollingVertical = false; - - const { onTimeScrolled, beginAgendaAt } = this.props; + handleTimeScrolled = (secondsInDay) => { + const { onTimeScrolled } = this.props; if (!onTimeScrolled) { return; } - - const { - nativeEvent: { contentOffset }, - } = scrollEvent; - const { y: yPosition } = contentOffset; - - const secondsInDay = topToSecondsInDay( - yPosition, - this.dimensions.verticalResolution, - beginAgendaAt, - ); - const date = moment(this.state.currentMoment) .startOf('day') .seconds(secondsInDay) @@ -536,6 +499,7 @@ export default class WeekView extends Component { onEditEvent, editEventConfig, editingEvent, + enableVerticalPinch, EventComponent, prependMostRecent, rightToLeft, @@ -555,6 +519,7 @@ export default class WeekView extends Component { removeClippedSubviews, disableVirtualization, runOnJS, + onTimeScrolled, } = this.props; const { currentMoment, @@ -587,15 +552,9 @@ export default class WeekView extends Component { timesColumnWidth, ); - const { - timeLabelHeight, - resolution: verticalResolution, - } = computeVerticalDimensions(windowHeight, hoursInDisplay, timeStep); - this.dimensions = { dayWidth, pageWidth, - verticalResolution, }; const horizontalScrollProps = allowScrollByDay @@ -653,79 +612,81 @@ export default class WeekView extends Component { ]} /> )} - - - - - { - return ( - - ); - }} + + + - - - + + { + return ( + + ); + }} + /> + + + + ); @@ -749,6 +710,7 @@ WeekView.propTypes = { onEditEvent: PropTypes.func, editEventConfig: EditEventConfigPropType, dragEventConfig: DragEventConfigPropType, + enableVerticalPinch: PropTypes.bool, headerStyle: PropTypes.object, headerTextStyle: PropTypes.object, hourTextStyle: PropTypes.object, @@ -803,6 +765,7 @@ WeekView.defaultProps = { startHour: 8, showTitle: true, rightToLeft: false, + enableVerticalPinch: false, prependMostRecent: false, RefreshComponent: ActivityIndicator, windowSize: DEFAULT_WINDOW_SIZE, diff --git a/src/WeekView/WeekView.styles.js b/src/WeekView/WeekView.styles.js index a5a2e7a6..413a7e62 100644 --- a/src/WeekView/WeekView.styles.js +++ b/src/WeekView/WeekView.styles.js @@ -1,18 +1,9 @@ -import { StyleSheet, Platform } from 'react-native'; +import { StyleSheet } from 'react-native'; const styles = StyleSheet.create({ container: { flex: 1, }, - scrollViewContentContainer: - Platform.OS === 'web' - ? { - height: '100vh', - } - : { flex: 0 }, - scrollView: { - flex: 1, - }, scrollViewChild: { flexDirection: 'row', }, diff --git a/src/__tests__/Event.test.js b/src/__tests__/Event.test.js index 94249b39..a11f36ac 100644 --- a/src/__tests__/Event.test.js +++ b/src/__tests__/Event.test.js @@ -6,15 +6,12 @@ import { } from 'react-native-gesture-handler/jest-utils'; import { render } from '@testing-library/react-native'; import Event from '../Event/Event'; +import { VerticalDimensionsProvider } from '../utils/VerticalDimContext'; describe('onDrag handler', () => { // Any values as constants: - const INITIAL_TOP = 10; - const INITIAL_LEFT = 0; const TRANSLATION_X = 7; const TRANSLATION_Y = 52; - const EVT_HEIGHT = 50; - const EVT_WIDTH = 40; const buildDragGesture = () => [ // {}, // implicit BEGIN state @@ -36,14 +33,21 @@ describe('onDrag handler', () => { const onDragMock = jest.fn(() => null); render( - , + + + , ); fireGestureHandler( getByGestureTestId(`dragGesture-${targetId}`), @@ -53,9 +57,10 @@ describe('onDrag handler', () => { expect(onDragMock).toHaveBeenCalledTimes(1); expect(onDragMock).toHaveBeenCalledWith( mockEvent, - INITIAL_LEFT + TRANSLATION_X, - INITIAL_TOP + TRANSLATION_Y, - EVT_WIDTH, + // NOTE: actual positions depends on dimensions, hoursInDisplay, etc + expect.toBeNumber(), + expect.toBeNumber(), + expect.toBeNumber(), ); }); }); diff --git a/src/__tests__/pipeline/overlap.test.js b/src/__tests__/pipeline/overlap.test.js index c48c50db..2bf6abe2 100644 --- a/src/__tests__/pipeline/overlap.test.js +++ b/src/__tests__/pipeline/overlap.test.js @@ -9,8 +9,8 @@ const makeEventWithMetaBuilder = () => { return { box: { background: false, - startDate, - endDate, + startTimestamp: startDate.getTime(), + endTimestamp: endDate.getTime(), }, ref: { id: nextId, diff --git a/src/pipeline/buckets.js b/src/pipeline/buckets.js index abb7d0cd..ac1718c7 100644 --- a/src/pipeline/buckets.js +++ b/src/pipeline/buckets.js @@ -34,7 +34,7 @@ const regularEventsWithMetaSorter = (evtA, evtB) => { if (backgroundDiff !== 0) { return backgroundDiff; } - return moment(evtA.box.startDate).diff(evtB.box.startDate, 'minutes'); + return evtA.box.startTimestamp - evtB.box.startTimestamp; }; export class RegularEventsInBuckets { @@ -57,8 +57,8 @@ export class RegularEventsInBuckets { this.buckets[dateStr].push({ ref: eventRef, box: { - startDate: new Date(boxStartDate.getTime()), - endDate: new Date(boxEndDate.getTime()), + startTimestamp: boxStartDate.getTime(), + endTimestamp: boxEndDate.getTime(), background: eventRef.eventKind === EVENT_KINDS.BLOCK || eventRef.resolveOverlap === OVERLAP_METHOD.IGNORE, diff --git a/src/pipeline/overlap.js b/src/pipeline/overlap.js index 81fad167..9561457e 100644 --- a/src/pipeline/overlap.js +++ b/src/pipeline/overlap.js @@ -1,21 +1,18 @@ /* eslint-disable max-classes-per-file */ -import moment from 'moment'; import { EVENT_KINDS, OVERLAP_METHOD } from '../utils/types'; -const ALLOW_OVERLAP_SECONDS = 2; +const ALLOW_OVERLAP_MILLISECONDS = 2000; -const areEventsOverlapped = (event1EndDate, event2StartDate) => { - if (!event1EndDate || !event2StartDate) return false; +const areEventsOverlapped = (event1EndTimestamp, event2StartTimestamp) => { + if (event1EndTimestamp == null || event2StartTimestamp == null) return false; - const endDate = moment(event1EndDate); - endDate.subtract(ALLOW_OVERLAP_SECONDS, 'seconds'); - return endDate.isSameOrAfter(event2StartDate); + return event1EndTimestamp - ALLOW_OVERLAP_MILLISECONDS > event2StartTimestamp; }; class Lane { constructor() { this.event2StackPosition = {}; - this.latestDate = null; + this.latestTimestamp = -1; this.resetStack(); } @@ -25,21 +22,26 @@ class Lane { this.stackKey = null; }; - addToStack = (event, eventIndex) => { - this.latestDate = - this.latestDate == null - ? moment(event.endDate) - : moment.max(moment(event.endDate), this.latestDate); - this.stackKey = event.stackKey || null; + addToStack = (eventWithMeta, eventIndex) => { + this.latestTimestamp = Math.max( + eventWithMeta.box.endTimestamp, + this.latestTimestamp, + ); + this.stackKey = eventWithMeta.ref.stackKey || null; this.event2StackPosition[eventIndex] = this.currentStackLength; this.currentStackLength += 1; }; - addEvent = (event, eventIndex) => { - if (!areEventsOverlapped(this.latestDate, event.startDate)) { + addEvent = (eventWithMeta, eventIndex) => { + if ( + !areEventsOverlapped( + this.latestTimestamp, + eventWithMeta.box.startTimestamp, + ) + ) { this.resetStack(); } - this.addToStack(event, eventIndex); + this.addToStack(eventWithMeta, eventIndex); }; } @@ -56,42 +58,43 @@ class OverlappedEventsHandler { this.ignoredEvents = {}; } - saveEventToLane = (event, eventIndex, laneIndex) => { - this.lanes[laneIndex].addEvent(event, eventIndex); + saveEventToLane = (eventWithMeta, eventIndex, laneIndex) => { + this.lanes[laneIndex].addEvent(eventWithMeta, eventIndex); this.event2LaneIndex[eventIndex] = laneIndex; }; - findFirstLaneNotOverlapping = (startDate) => + findFirstLaneNotOverlapping = (boxStartTimestamp) => this.lanes.findIndex( - (lane) => !areEventsOverlapped(lane.latestDate, startDate), + (lane) => !areEventsOverlapped(lane.latestTimestamp, boxStartTimestamp), ); - addToNextAvailableLane = (event, eventIndex) => { - let laneIndex = this.findFirstLaneNotOverlapping(event.startDate); + addToNextAvailableLane = (eventWithMeta, eventIndex) => { + let laneIndex = this.findFirstLaneNotOverlapping( + eventWithMeta.box.startTimestamp, + ); if (laneIndex === -1) { this.lanes.push(new Lane()); laneIndex = this.lanes.length - 1; } - this.saveEventToLane(event, eventIndex, laneIndex); + this.saveEventToLane(eventWithMeta, eventIndex, laneIndex); }; - findLaneWithOverlapAndKey = (startDate, targetKey = null) => - this.lanes.findIndex( + findLaneWithOverlapAndKey = (eventWithMeta) => { + const { ref: event, box } = eventWithMeta; + return this.lanes.findIndex( (lane) => - (targetKey == null || targetKey === lane.stackKey) && - areEventsOverlapped(lane.latestDate, startDate), + (event.targetKey == null || event.targetKey === lane.stackKey) && + areEventsOverlapped(lane.latestTimestamp, box.startTimestamp), ); + }; - addToNextMatchingStack = (event, eventIndex) => { - const laneIndex = this.findLaneWithOverlapAndKey( - event.startDate, - event.stackKey, - ); + addToNextMatchingStack = (eventWithMeta, eventIndex) => { + const laneIndex = this.findLaneWithOverlapAndKey(eventWithMeta); if (laneIndex !== -1) { - this.saveEventToLane(event, eventIndex, laneIndex); + this.saveEventToLane(eventWithMeta, eventIndex, laneIndex); } else { - this.addToNextAvailableLane(event, eventIndex); + this.addToNextAvailableLane(eventWithMeta, eventIndex); } }; @@ -99,20 +102,20 @@ class OverlappedEventsHandler { this.ignoredEvents[eventIndex] = true; }; - static buildFromOverlappedEvents = (events) => { + static buildFromOverlappedEvents = (eventsWithMeta) => { const layout = new OverlappedEventsHandler(); - (events || []).forEach(({ ref: event }, eventIndex) => { - switch (event.resolveOverlap) { + (eventsWithMeta || []).forEach((eventWithMeta, eventIndex) => { + switch (eventWithMeta.ref.resolveOverlap) { case OVERLAP_METHOD.STACK: - layout.addToNextMatchingStack(event, eventIndex); + layout.addToNextMatchingStack(eventWithMeta, eventIndex); break; case OVERLAP_METHOD.IGNORE: layout.addAsIgnored(eventIndex); break; case OVERLAP_METHOD.LANE: default: - layout.addToNextAvailableLane(event, eventIndex); + layout.addToNextAvailableLane(eventWithMeta, eventIndex); break; } }); @@ -164,7 +167,7 @@ const addOverlappedToArray = (baseArr, overlappedArr) => { const resolveEventOverlaps = (events) => { let overlappedSoFar = []; - let lastDate = null; + let latestTimestamp = -1; const resolvedEvents = events.reduce((accumulated, eventWithMeta) => { const { ref, box } = eventWithMeta; const shouldIgnoreOverlap = @@ -172,14 +175,16 @@ const resolveEventOverlaps = (events) => { ref.resolveOverlap === OVERLAP_METHOD.IGNORE; if (shouldIgnoreOverlap) { accumulated.push(eventWithMeta); - } else if (!lastDate || areEventsOverlapped(lastDate, box.startDate)) { + } else if ( + latestTimestamp === -1 || + areEventsOverlapped(latestTimestamp, box.startTimestamp) + ) { overlappedSoFar.push(eventWithMeta); - const endDate = moment(box.endDate); - lastDate = lastDate ? moment.max(endDate, lastDate) : endDate; + latestTimestamp = Math.max(box.endTimestamp, latestTimestamp); } else { addOverlappedToArray(accumulated, overlappedSoFar); overlappedSoFar = [eventWithMeta]; - lastDate = moment(box.endDate); + latestTimestamp = box.endTimestamp; } return accumulated; }, []); diff --git a/src/pipeline/position.js b/src/pipeline/position.js index 23227d8d..817390e2 100644 --- a/src/pipeline/position.js +++ b/src/pipeline/position.js @@ -1,52 +1,96 @@ -import { minutesInDay, daysInBetweenInclusive } from '../utils/dates'; +import { minutesInDay } from '../utils/dates'; import { minutesInDayToTop, minutesToHeight } from '../utils/dimensions'; const EVENT_HORIZONTAL_PADDING = 8 / 100; const STACK_OFFSET_FRACTION = 15 / 100; const MIN_ITEM_WIDTH = 4; // pixels -const computeWidthByLane = (overlap, dayWidth) => - dayWidth / (overlap.nLanes || 1); +const computeWidthByLane = (nLanes, dayWidth) => { + 'worklet'; -const computeHorizontalPadding = (overlap, dayWidth) => { - const widthByLane = computeWidthByLane(overlap, dayWidth); - const paddingByLane = EVENT_HORIZONTAL_PADDING / (overlap.nLanes || 1); + return dayWidth / (nLanes || 1); +}; + +const computeHorizontalPadding = (nLanes, dayWidth) => { + 'worklet'; + + const widthByLane = computeWidthByLane(nLanes, dayWidth); + const paddingByLane = EVENT_HORIZONTAL_PADDING / (nLanes || 1); return widthByLane * paddingByLane; }; -const computeStackOffset = (overlap, dayWidth) => { - const widthByLane = computeWidthByLane(overlap, dayWidth); - return widthByLane * STACK_OFFSET_FRACTION * (overlap.stackPosition || 0); +const computeStackOffset = (nLanes, stackPosition, dayWidth) => { + 'worklet'; + + const widthByLane = computeWidthByLane(nLanes, dayWidth); + return widthByLane * STACK_OFFSET_FRACTION * (stackPosition || 0); +}; + +const daysInBetweenInclusive = (startTimestamp, endTimestamp) => { + 'worklet'; + + const startWeekDay = new Date(startTimestamp).getDay(); + const endWeekDay = new Date(endTimestamp).getDay(); + + const positiveDayDiffExclusive = (endWeekDay + 7 - startWeekDay) % 7; + return positiveDayDiffExclusive + 1; }; -export const computeWidth = (box, overlap, dayWidth) => { - const nDays = daysInBetweenInclusive(box.startDate, box.endDate); +export const computeWidth = ( + boxStartTimestamp, + boxEndTimestamp, + nLanes, + stackPosition, + dayWidth, +) => { + 'worklet'; + + const nDays = daysInBetweenInclusive(boxStartTimestamp, boxEndTimestamp); const width = - computeWidthByLane(overlap, dayWidth) * nDays - - computeHorizontalPadding(overlap, dayWidth) - - computeStackOffset(overlap, dayWidth); + computeWidthByLane(nLanes, dayWidth) * nDays - + computeHorizontalPadding(nLanes, dayWidth) - + computeStackOffset(nLanes, stackPosition, dayWidth); return Math.max(width, MIN_ITEM_WIDTH); }; -const computeLaneOffset = (overlap, dayWidth) => - computeWidthByLane(overlap, dayWidth) * (overlap.lane || 0); +const computeLaneOffset = (lane, nLanes, dayWidth) => { + 'worklet'; + + return computeWidthByLane(nLanes, dayWidth) * (lane || 0); +}; + +export const computeLeft = (lane, nLanes, stackPosition, dayWidth) => { + 'worklet'; -export const computeLeft = (overlap, dayWidth) => { const left = - computeLaneOffset(overlap, dayWidth) + - computeStackOffset(overlap, dayWidth); + computeLaneOffset(lane, nLanes, dayWidth) + + computeStackOffset(nLanes, stackPosition, dayWidth); return Math.min(left, dayWidth); }; -export const computeHeight = (box, verticalResolution) => - minutesToHeight( - minutesInDay(box.endDate) - minutesInDay(box.startDate), +export const computeHeight = ( + boxStartTimestamp, + boxEndTimestamp, + verticalResolution, +) => { + 'worklet'; + + return minutesToHeight( + minutesInDay(boxEndTimestamp) - minutesInDay(boxStartTimestamp), verticalResolution, ); +}; -export const computeTop = (box, verticalResolution, beginAgendaAt) => - minutesInDayToTop( - minutesInDay(box.startDate), +export const computeTop = ( + boxStartTimestamp, + verticalResolution, + beginAgendaAt, +) => { + 'worklet'; + + return minutesInDayToTop( + minutesInDay(boxStartTimestamp), verticalResolution, beginAgendaAt, ); +}; diff --git a/src/utils/VerticalDimContext.js b/src/utils/VerticalDimContext.js new file mode 100644 index 00000000..3ffc2393 --- /dev/null +++ b/src/utils/VerticalDimContext.js @@ -0,0 +1,74 @@ +import React, { useContext } from 'react'; +import { useWindowDimensions } from 'react-native'; +import { useDerivedValue } from 'react-native-reanimated'; +import { Gesture } from 'react-native-gesture-handler'; + +export const VerticalDimensionContext = React.createContext(null); + +const useSetupDimensions = ({ + enableVerticalPinch, + hoursInDisplay, + beginAgendaAt, + endAgendaAt, + timeStep, +}) => { + const { height: windowHeight } = useWindowDimensions(); + + const currentVerticalScale = useDerivedValue(() => 1, [ + hoursInDisplay, + windowHeight, + ]); + const savedVerticalScale = useDerivedValue(() => 1, [ + hoursInDisplay, + windowHeight, + ]); + + const MIN_HOURS_IN_DISPLAY = 30 / 60; + const MAX_HOURS_IN_DISPLAY = endAgendaAt - beginAgendaAt / 60; + + const verticalPinchGesture = Gesture.Pinch() + .enabled(enableVerticalPinch) + .onUpdate((pinchEvt) => { + const newValue = savedVerticalScale.value * pinchEvt.scale; + const newHoursInDisplay = hoursInDisplay / newValue; + if ( + newHoursInDisplay > MIN_HOURS_IN_DISPLAY && + newHoursInDisplay < MAX_HOURS_IN_DISPLAY + ) { + currentVerticalScale.value = newValue; + } + }) + .onEnd(() => { + savedVerticalScale.value = currentVerticalScale.value; + }); + + const verticalResolution = useDerivedValue(() => { + const minutesInDisplay = (hoursInDisplay * 60) / currentVerticalScale.value; + const minutesResolution = windowHeight / minutesInDisplay; + return minutesResolution; + }); + const timeLabelHeight = useDerivedValue( + () => timeStep * verticalResolution.value, + ); + + return { + beginAgendaAt, // NOTE: passed down for convenience + verticalPinchGesture, + verticalResolution, + timeLabelHeight, + }; +}; + +export const useVerticalDimensionContext = () => { + const value = useContext(VerticalDimensionContext); + return value; +}; + +export const VerticalDimensionsProvider = ({ children, ...props }) => { + const value = useSetupDimensions({ ...props }); + return ( + + {children} + + ); +}; diff --git a/src/utils/dates.js b/src/utils/dates.js index 6431a871..1d8fa53f 100644 --- a/src/utils/dates.js +++ b/src/utils/dates.js @@ -19,18 +19,17 @@ export const getCurrentMonth = (date) => { /** * Get the amount of minutes in a day of a date. - * @param {Date} date + * @param {Date|Number} timestampOrDate * @returns amount of minutes in the day. */ -export const minutesInDay = (date) => { - const dateObj = moment(date); - if (!dateObj || !dateObj.isValid()) return 0; - return dateObj.hours() * 60 + dateObj.minutes(); -}; - -export const daysInBetweenInclusive = (startDate, endDate) => { - if (!startDate || !endDate) return 0; - return moment(endDate).diff(startDate, 'days') + 1; +export const minutesInDay = (timestampOrDate) => { + 'worklet'; + + const date = + timestampOrDate instanceof Date + ? timestampOrDate + : new Date(timestampOrDate); + return date.getHours() * 60 + date.getMinutes(); }; export const calculateDaysArray = (fromDate, numberOfDays, rightToLeft) => { diff --git a/src/utils/dimensions.js b/src/utils/dimensions.js index c1fc8a60..1316b94c 100644 --- a/src/utils/dimensions.js +++ b/src/utils/dimensions.js @@ -25,27 +25,11 @@ export const HEADER_HEIGHT = 50; export const CONTENT_TOP_PADDING = 16; -export const computeVerticalDimensions = ( - totalHeight, - hoursInDisplay, - minutesStep, -) => { - const minutesInDisplay = hoursInDisplay * 60; - const timeLabelsInDisplay = Math.ceil(minutesInDisplay / minutesStep); - - const minutesResolution = totalHeight / minutesInDisplay; - - return { - timeLabelHeight: totalHeight / timeLabelsInDisplay, - resolution: minutesResolution, - }; -}; - /** * Convert time in the day (expressed in minutes) to top (pixels in y dim). * * @param {Number} minutes Minutes to convert - * @param {Number} verticalResolution resolution in minutes + * @param {ReanimatedValue} verticalResolution resolution in minutes * @param {Number} minutesOffset offset, e.g. beginAgendaAt * @returns pixels */ @@ -57,19 +41,22 @@ export const minutesInDayToTop = ( 'worklet'; return ( - (minutes - (minutesOffset || 0)) * verticalResolution + CONTENT_TOP_PADDING + (minutes - (minutesOffset || 0)) * verticalResolution.value + + CONTENT_TOP_PADDING ); }; /** * Convert period of time (in minutes) to height (pixels in y dim). * - * @param {*} minutesDelta period of time in minutes - * @param {*} verticalResolution resolution in minutes + * @param {number} minutesDelta period of time in minutes + * @param {ReanimatedValue} verticalResolution resolution in minutes * @returns pixels */ export const minutesToHeight = (minutesDelta, verticalResolution) => { - return minutesDelta * verticalResolution; + 'worklet'; + + return minutesDelta * verticalResolution.value; }; /** @@ -78,7 +65,7 @@ export const minutesToHeight = (minutesDelta, verticalResolution) => { * The output precision is up to seconds (arbitrary choice). * * @param {Number} yValue top position in pixels - * @param {Number} verticalResolution resolution in minutes + * @param {ReanimatedValue} verticalResolution resolution in minutes * @param {Number} minutesOffset offset, e.g. beginAgendaAt * @returns amount of seconds */ @@ -87,7 +74,7 @@ export const topToSecondsInDay = ( verticalResolution, minutesOffset = 0, ) => { - const secondsResolution = verticalResolution / 60; + const secondsResolution = verticalResolution.value / 60; const secondsInDay = (yValue - CONTENT_TOP_PADDING) / secondsResolution; return secondsInDay + minutesOffset * 60; }; diff --git a/src/utils/types.js b/src/utils/types.js index f3c110f7..e073d97d 100644 --- a/src/utils/types.js +++ b/src/utils/types.js @@ -44,8 +44,8 @@ export const EventPropType = PropTypes.shape({ export const EventWithMetaPropType = PropTypes.shape({ ref: EventPropType.isRequired, box: PropTypes.shape({ - startDate: PropTypes.instanceOf(Date).isRequired, - endDate: PropTypes.instanceOf(Date).isRequired, + startTimestamp: PropTypes.number.isRequired, + endTimestamp: PropTypes.number.isRequired, }).isRequired, }); diff --git a/webdocs/docs/full-api/week-view-props.mdx b/webdocs/docs/full-api/week-view-props.mdx index ac230f0b..7f88e5eb 100644 --- a/webdocs/docs/full-api/week-view-props.mdx +++ b/webdocs/docs/full-api/week-view-props.mdx @@ -234,6 +234,14 @@ Number of minutes to use as step in the time labels at the left. Increasing this ## More configurations +### `enableVerticalPinch` + + +Whether or not to enable vertical pinch gesture. + + + + ### `showNowLine` diff --git a/webdocs/docs/guides/img/pinch.gif b/webdocs/docs/guides/img/pinch.gif new file mode 100644 index 00000000..d5177dc5 Binary files /dev/null and b/webdocs/docs/guides/img/pinch.gif differ diff --git a/webdocs/docs/guides/pinch.mdx b/webdocs/docs/guides/pinch.mdx new file mode 100644 index 00000000..2c7eb849 --- /dev/null +++ b/webdocs/docs/guides/pinch.mdx @@ -0,0 +1,17 @@ +--- +sidebar_position: 14 +sidebar_label: Pinch vertically +description: Resize or zoom vertically +--- + +# Pinch vertically + +You can enable vertical the pinch gesture (i.e. zoom) with the [`enableVerticalPinch` prop](/full-api/week-view-props.mdx#eventcomponent). + + + +```js title="Enable vertical pinch" + +``` + +