From 9551248ceef6ce3c237bdae3dadd20a2fd421e8a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 17 Feb 2025 21:52:17 -0500 Subject: [PATCH] Track the range offsets along the timeline where previous/current/next is Also, let you specify this as options. This lets you model more than three states along a timeline by clamping them. It also allows specifying the "current" offset as something different than what it was when the gesture started such as if it has to start scroll has already happened. --- packages/react-art/src/ReactFiberConfigART.js | 5 ++ .../src/client/ReactFiberConfigDOM.js | 22 +++++--- .../src/ReactFiberConfigNative.js | 5 ++ .../src/createReactNoop.js | 5 ++ .../src/ReactFiberConfigWithNoMutation.js | 1 + .../src/ReactFiberGestureScheduler.js | 55 ++++++++++++------- .../react-reconciler/src/ReactFiberHooks.js | 39 ++++++++++++- .../src/forks/ReactFiberConfig.custom.js | 1 + .../src/ReactFiberConfigTestHost.js | 5 ++ packages/shared/ReactTypes.js | 10 +++- 10 files changed, 118 insertions(+), 30 deletions(-) diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index 18cc84b18e3c8..a011bdf5c71c7 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -510,8 +510,13 @@ export function createViewTransitionInstance( export type GestureTimeline = null; +export function getCurrentGestureOffset(provider: GestureTimeline): number { + throw new Error('useSwipeTransition is not yet supported in react-art.'); +} + export function subscribeToGestureDirection( provider: GestureTimeline, + currentOffset: number, directionCallback: (direction: boolean) => void, ): () => void { throw new Error('useSwipeTransition is not yet supported in react-art.'); diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 626231a1fae6a..dbe3997127ff4 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -1480,17 +1480,21 @@ export function createViewTransitionInstance( export type GestureTimeline = AnimationTimeline; // TODO: More provider types. -export function subscribeToGestureDirection( - provider: GestureTimeline, - directionCallback: (direction: boolean) => void, -): () => void { +export function getCurrentGestureOffset(provider: GestureTimeline): number { const time = provider.currentTime; if (time === null) { throw new Error( 'Cannot start a gesture with a disconnected AnimationTimeline.', ); } - const startTime = typeof time === 'number' ? time : time.value; + return typeof time === 'number' ? time : time.value; +} + +export function subscribeToGestureDirection( + provider: GestureTimeline, + currentOffset: number, + directionCallback: (direction: boolean) => void, +): () => void { if ( typeof ScrollTimeline === 'function' && provider instanceof ScrollTimeline @@ -1502,8 +1506,8 @@ export function subscribeToGestureDirection( if (newTime !== null) { directionCallback( typeof newTime === 'number' - ? newTime > startTime - : newTime.value > startTime, + ? newTime > currentOffset + : newTime.value > currentOffset, ); } }; @@ -1519,8 +1523,8 @@ export function subscribeToGestureDirection( if (newTime !== null) { directionCallback( typeof newTime === 'number' - ? newTime > startTime - : newTime.value > startTime, + ? newTime > currentOffset + : newTime.value > currentOffset, ); } callbackID = requestAnimationFrame(rafCallback); diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 7929ed7bd87e3..00385547f23e7 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -607,8 +607,13 @@ export function createViewTransitionInstance( export type GestureTimeline = null; +export function getCurrentGestureOffset(provider: GestureTimeline): number { + throw new Error('useSwipeTransition is not yet supported in React Native.'); +} + export function subscribeToGestureDirection( provider: GestureTimeline, + currentOffset: number, directionCallback: (direction: boolean) => void, ): () => void { throw new Error('useSwipeTransition is not yet supported in React Native.'); diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 985c715bd4130..43853a864fb5e 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -796,8 +796,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return null; }, + getCurrentGestureOffset(provider: GestureTimeline): number { + return 0; + }, + subscribeToGestureDirection( provider: GestureTimeline, + currentOffset: number, directionCallback: (direction: boolean) => void, ): () => void { return () => {}; diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js index 26231df8bfe52..da34b26084de5 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js @@ -49,4 +49,5 @@ export const startViewTransition = shim; export type ViewTransitionInstance = null | {name: string, ...}; export const createViewTransitionInstance = shim; export type GestureTimeline = any; +export const getCurrentGestureOffset = shim; export const subscribeToGestureDirection = shim; diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index acbc6ab765850..fffb7d524d9e4 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -19,6 +19,9 @@ export type ScheduledGesture = { provider: GestureTimeline, count: number, // The number of times this same provider has been started. direction: boolean, // false = previous, true = next + rangePrevious: number, // The end along the timeline where the previous state is reached. + rangeCurrent: number, // The starting offset along the timeline. + rangeNext: number, // The end along the timeline where the next state is reached. cancel: () => void, // Cancel the subscription to direction change. prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root. next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root. @@ -28,6 +31,9 @@ export function scheduleGesture( root: FiberRoot, provider: GestureTimeline, initialDirection: boolean, + rangePrevious: number, + rangeCurrent: number, + rangeNext: number, ): ScheduledGesture { let prev = root.gestures; while (prev !== null) { @@ -42,32 +48,43 @@ export function scheduleGesture( } prev = next; } + const isFlippedDirection = rangePrevious > rangeNext; // Add new instance to the end of the queue. - const cancel = subscribeToGestureDirection(provider, (direction: boolean) => { - if (gesture.direction !== direction) { - gesture.direction = direction; - if (gesture.prev === null && root.gestures !== gesture) { - // This gesture is not in the schedule, meaning it was already rendered. - // We need to rerender in the new direction. Insert it into the first slot - // in case other gestures are queued after the on-going one. - const existing = root.gestures; - gesture.next = existing; - if (existing !== null) { - existing.prev = gesture; + const cancel = subscribeToGestureDirection( + provider, + rangeCurrent, + (direction: boolean) => { + if (isFlippedDirection) { + direction = !direction; + } + if (gesture.direction !== direction) { + gesture.direction = direction; + if (gesture.prev === null && root.gestures !== gesture) { + // This gesture is not in the schedule, meaning it was already rendered. + // We need to rerender in the new direction. Insert it into the first slot + // in case other gestures are queued after the on-going one. + const existing = root.gestures; + gesture.next = existing; + if (existing !== null) { + existing.prev = gesture; + } + root.gestures = gesture; + // Schedule the lane on the root. The Fibers will already be marked as + // long as the gesture is active on that Hook. + root.pendingLanes |= GestureLane; + ensureRootIsScheduled(root); } - root.gestures = gesture; - // Schedule the lane on the root. The Fibers will already be marked as - // long as the gesture is active on that Hook. - root.pendingLanes |= GestureLane; - ensureRootIsScheduled(root); + // TODO: If we're currently rendering this gesture, we need to restart it. } - // TODO: If we're currently rendering this gesture, we need to restart it. - } - }); + }, + ); const gesture: ScheduledGesture = { provider: provider, count: 1, direction: initialDirection, + rangePrevious: rangePrevious, + rangeCurrent: rangeCurrent, + rangeNext: rangeNext, cancel: cancel, prev: prev, next: null, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 16ceb167683b4..645c6ce0e6122 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -16,6 +16,7 @@ import type { Awaited, StartGesture, GestureProvider, + GestureOptions, } from 'shared/ReactTypes'; import type { Fiber, @@ -35,6 +36,7 @@ import { NotPendingTransition as NoPendingHostTransition, setCurrentUpdatePriority, getCurrentUpdatePriority, + getCurrentGestureOffset, } from './ReactFiberConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -3988,6 +3990,7 @@ function startGesture( fiber: Fiber, queue: SwipeTransitionUpdateQueue, gestureProvider: GestureProvider, + gestureOptions?: GestureOptions, ): () => void { const root = enqueueGestureRender(fiber); if (root === null) { @@ -3998,10 +4001,44 @@ function startGesture( }; } const gestureTimeline: GestureTimeline = gestureProvider; + const currentOffset = getCurrentGestureOffset(gestureTimeline); + const range = gestureOptions && gestureOptions.range; + const rangePrevious: number = range ? range[0] : 0; // If no range is provider we assume it's the starting point of the range. + const rangeCurrent: number = range ? range[1] : currentOffset; + const rangeNext: number = range ? range[2] : 100; // If no range is provider we assume it's the starting point of the range. + if (__DEV__) { + if ( + (rangePrevious > rangeCurrent && rangeNext > rangeCurrent) || + (rangePrevious < rangeCurrent && rangeNext < rangeCurrent) + ) { + console.error( + 'The range of a gesture needs "previous" and "next" to be on either side of ' + + 'the "current" offset. Both cannot be above current and both cannot be below current.', + ); + } + } + const isFlippedDirection = rangePrevious > rangeNext; + const initialDirection = + // If a range is specified we can imply initial direction if it's not the current + // value such as if the gesture starts after it has already moved. + currentOffset < rangeCurrent + ? isFlippedDirection + : currentOffset > rangeCurrent + ? !isFlippedDirection + : // Otherwise, look for an explicit option. + gestureOptions && gestureOptions.direction === 'next' + ? true + : gestureOptions && gestureOptions.direction === 'previous' + ? false + : // If no option is specified, imply from the values specified. + queue.initialDirection; const scheduledGesture = scheduleGesture( root, gestureTimeline, - queue.initialDirection, + initialDirection, + rangePrevious, + rangeCurrent, + rangeNext, ); // Add this particular instance to the queue. // We add multiple of the same timeline even if they get batched so diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 3a5cccc6d3695..6b2781c0ab2a5 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -145,6 +145,7 @@ export const wasInstanceInViewport = $$$config.wasInstanceInViewport; export const hasInstanceChanged = $$$config.hasInstanceChanged; export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent; export const startViewTransition = $$$config.startViewTransition; +export const getCurrentGestureOffset = $$$config.getCurrentGestureOffset; export const subscribeToGestureDirection = $$$config.subscribeToGestureDirection; export const createViewTransitionInstance = diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index 922155f35b8f3..8f4f81cabb9ce 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -393,8 +393,13 @@ export function getInstanceFromNode(mockNode: Object): Object | null { export type GestureTimeline = null; +export function getCurrentGestureOffset(provider: GestureTimeline): number { + return 0; +} + export function subscribeToGestureDirection( provider: GestureTimeline, + currentOffset: number, directionCallback: (direction: boolean) => void, ): () => void { return () => {}; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index fbf4e9b06fce9..521575a5041b4 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -172,7 +172,15 @@ export type ReactFormState = [ // renderer supports it. export type GestureProvider = any; -export type StartGesture = (gestureProvider: GestureProvider) => () => void; +export type StartGesture = ( + gestureProvider: GestureProvider, + gestureOptions: GestureOptions, +) => () => void; + +export type GestureOptions = { + direction?: 'previous' | 'next', + range?: [/*previous*/ number, /*current*/ number, /*next*/ number], +}; export type Awaited = T extends null | void ? T // special case for `null | undefined` when not in `--strictNullChecks` mode