@samitouri / QOS-React / commits / 662957cc73

Allow passing range option to useSwipeTransition (#32412)

Stacked on #32379 Track the range offsets along the timeline where previous/current/next is. This can also be specified as an option. This lets you model more than three states along a timeline by clamping them and then updating the "current" as you go. 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 after scroll has already happened (such as what happens if you listen to the "scroll" event).

Sebastian Markbåge committed Feb 21, 2025 at 11:03 UTC 662957cc735a7f630b497ada45af8ff39684d2b9
10 files changed +122 -36
packages/react-art/src/ReactFiberConfigART.js
+5
@@ -510,8 +510,13 @@ export function createViewTransitionInstance(
510
511 export type GestureTimeline = null;
512
513 +export function getCurrentGestureOffset(provider: GestureTimeline): number {
514 + throw new Error('useSwipeTransition is not yet supported in react-art.');
515 +}
516 +
517 export function subscribeToGestureDirection(
518 provider: GestureTimeline,
519 + currentOffset: number,
520 directionCallback: (direction: boolean) => void,
521 ): () => void {
522 throw new Error('useSwipeTransition is not yet supported in react-art.');
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+17 -15
@@ -1480,17 +1480,21 @@ export function createViewTransitionInstance(
1480
1481 export type GestureTimeline = AnimationTimeline; // TODO: More provider types.
1482
1483 -export function subscribeToGestureDirection(
1484 - provider: GestureTimeline,
1485 - directionCallback: (direction: boolean) => void,
1486 -): () => void {
1483 +export function getCurrentGestureOffset(provider: GestureTimeline): number {
1484 const time = provider.currentTime;
1485 if (time === null) {
1486 throw new Error(
1487 'Cannot start a gesture with a disconnected AnimationTimeline.',
1488 );
1489 }
1493 - const startTime = typeof time === 'number' ? time : time.value;
1490 + return typeof time === 'number' ? time : time.value;
1491 +}
1492 +
1493 +export function subscribeToGestureDirection(
1494 + provider: GestureTimeline,
1495 + currentOffset: number,
1496 + directionCallback: (direction: boolean) => void,
1497 +): () => void {
1498 if (
1499 typeof ScrollTimeline === 'function' &&
1500 provider instanceof ScrollTimeline
@@ -1500,11 +1504,10 @@ export function subscribeToGestureDirection(
1504 const scrollCallback = () => {
1505 const newTime = provider.currentTime;
1506 if (newTime !== null) {
1503 - directionCallback(
1504 - typeof newTime === 'number'
1505 - ? newTime > startTime
1506 - : newTime.value > startTime,
1507 - );
1507 + const newValue = typeof newTime === 'number' ? newTime : newTime.value;
1508 + if (newValue !== currentOffset) {
1509 + directionCallback(newValue > currentOffset);
1510 + }
1511 }
1512 };
1513 element.addEventListener('scroll', scrollCallback, false);
@@ -1517,11 +1520,10 @@ export function subscribeToGestureDirection(
1520 const rafCallback = () => {
1521 const newTime = provider.currentTime;
1522 if (newTime !== null) {
1520 - directionCallback(
1521 - typeof newTime === 'number'
1522 - ? newTime > startTime
1523 - : newTime.value > startTime,
1524 - );
1523 + const newValue = typeof newTime === 'number' ? newTime : newTime.value;
1524 + if (newValue !== currentOffset) {
1525 + directionCallback(newValue > currentOffset);
1526 + }
1527 }
1528 callbackID = requestAnimationFrame(rafCallback);
1529 };
packages/react-native-renderer/src/ReactFiberConfigNative.js
+5
@@ -607,8 +607,13 @@ export function createViewTransitionInstance(
607
608 export type GestureTimeline = null;
609
610 +export function getCurrentGestureOffset(provider: GestureTimeline): number {
611 + throw new Error('useSwipeTransition is not yet supported in React Native.');
612 +}
613 +
614 export function subscribeToGestureDirection(
615 provider: GestureTimeline,
616 + currentOffset: number,
617 directionCallback: (direction: boolean) => void,
618 ): () => void {
619 throw new Error('useSwipeTransition is not yet supported in React Native.');
packages/react-noop-renderer/src/createReactNoop.js
+5
@@ -796,8 +796,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
796 return null;
797 },
798
799 + getCurrentGestureOffset(provider: GestureTimeline): number {
800 + return 0;
801 + },
802 +
803 subscribeToGestureDirection(
804 provider: GestureTimeline,
805 + currentOffset: number,
806 directionCallback: (direction: boolean) => void,
807 ): () => void {
808 return () => {};
packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js
+1
@@ -49,4 +49,5 @@ export const startViewTransition = shim;
49 export type ViewTransitionInstance = null | {name: string, ...};
50 export const createViewTransitionInstance = shim;
51 export type GestureTimeline = any;
52 +export const getCurrentGestureOffset = shim;
53 export const subscribeToGestureDirection = shim;
packages/react-reconciler/src/ReactFiberGestureScheduler.js
+36 -19
@@ -19,6 +19,9 @@ export type ScheduledGesture = {
19 provider: GestureTimeline,
20 count: number, // The number of times this same provider has been started.
21 direction: boolean, // false = previous, true = next
22 + rangePrevious: number, // The end along the timeline where the previous state is reached.
23 + rangeCurrent: number, // The starting offset along the timeline.
24 + rangeNext: number, // The end along the timeline where the next state is reached.
25 cancel: () => void, // Cancel the subscription to direction change.
26 prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
27 next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
@@ -28,6 +31,9 @@ export function scheduleGesture(
31 root: FiberRoot,
32 provider: GestureTimeline,
33 initialDirection: boolean,
34 + rangePrevious: number,
35 + rangeCurrent: number,
36 + rangeNext: number,
37 ): ScheduledGesture {
38 let prev = root.gestures;
39 while (prev !== null) {
@@ -42,32 +48,43 @@ export function scheduleGesture(
48 }
49 prev = next;
50 }
51 + const isFlippedDirection = rangePrevious > rangeNext;
52 // Add new instance to the end of the queue.
46 - const cancel = subscribeToGestureDirection(provider, (direction: boolean) => {
47 - if (gesture.direction !== direction) {
48 - gesture.direction = direction;
49 - if (gesture.prev === null && root.gestures !== gesture) {
50 - // This gesture is not in the schedule, meaning it was already rendered.
51 - // We need to rerender in the new direction. Insert it into the first slot
52 - // in case other gestures are queued after the on-going one.
53 - const existing = root.gestures;
54 - gesture.next = existing;
55 - if (existing !== null) {
56 - existing.prev = gesture;
53 + const cancel = subscribeToGestureDirection(
54 + provider,
55 + rangeCurrent,
56 + (direction: boolean) => {
57 + if (isFlippedDirection) {
58 + direction = !direction;
59 + }
60 + if (gesture.direction !== direction) {
61 + gesture.direction = direction;
62 + if (gesture.prev === null && root.gestures !== gesture) {
63 + // This gesture is not in the schedule, meaning it was already rendered.
64 + // We need to rerender in the new direction. Insert it into the first slot
65 + // in case other gestures are queued after the on-going one.
66 + const existing = root.gestures;
67 + gesture.next = existing;
68 + if (existing !== null) {
69 + existing.prev = gesture;
70 + }
71 + root.gestures = gesture;
72 + // Schedule the lane on the root. The Fibers will already be marked as
73 + // long as the gesture is active on that Hook.
74 + root.pendingLanes |= GestureLane;
75 + ensureRootIsScheduled(root);
76 }
58 - root.gestures = gesture;
59 - // Schedule the lane on the root. The Fibers will already be marked as
60 - // long as the gesture is active on that Hook.
61 - root.pendingLanes |= GestureLane;
62 - ensureRootIsScheduled(root);
77 + // TODO: If we're currently rendering this gesture, we need to restart it.
78 }
64 - // TODO: If we're currently rendering this gesture, we need to restart it.
65 - }
66 - });
79 + },
80 + );
81 const gesture: ScheduledGesture = {
82 provider: provider,
83 count: 1,
84 direction: initialDirection,
85 + rangePrevious: rangePrevious,
86 + rangeCurrent: rangeCurrent,
87 + rangeNext: rangeNext,
88 cancel: cancel,
89 prev: prev,
90 next: null,
packages/react-reconciler/src/ReactFiberHooks.js
+38 -1
@@ -16,6 +16,7 @@ import type {
16 Awaited,
17 StartGesture,
18 GestureProvider,
19 + GestureOptions,
20 } from 'shared/ReactTypes';
21 import type {
22 Fiber,
@@ -35,6 +36,7 @@ import {
36 NotPendingTransition as NoPendingHostTransition,
37 setCurrentUpdatePriority,
38 getCurrentUpdatePriority,
39 + getCurrentGestureOffset,
40 } from './ReactFiberConfig';
41 import ReactSharedInternals from 'shared/ReactSharedInternals';
42 import {
@@ -3988,6 +3990,7 @@ function startGesture(
3990 fiber: Fiber,
3991 queue: SwipeTransitionUpdateQueue,
3992 gestureProvider: GestureProvider,
3993 + gestureOptions?: GestureOptions,
3994 ): () => void {
3995 const root = enqueueGestureRender(fiber);
3996 if (root === null) {
@@ -3998,10 +4001,44 @@ function startGesture(
4001 };
4002 }
4003 const gestureTimeline: GestureTimeline = gestureProvider;
4004 + const currentOffset = getCurrentGestureOffset(gestureTimeline);
4005 + const range = gestureOptions && gestureOptions.range;
4006 + const rangePrevious: number = range ? range[0] : 0; // If no range is provider we assume it's the starting point of the range.
4007 + const rangeCurrent: number = range ? range[1] : currentOffset;
4008 + const rangeNext: number = range ? range[2] : 100; // If no range is provider we assume it's the starting point of the range.
4009 + if (__DEV__) {
4010 + if (
4011 + (rangePrevious > rangeCurrent && rangeNext > rangeCurrent) ||
4012 + (rangePrevious < rangeCurrent && rangeNext < rangeCurrent)
4013 + ) {
4014 + console.error(
4015 + 'The range of a gesture needs "previous" and "next" to be on either side of ' +
4016 + 'the "current" offset. Both cannot be above current and both cannot be below current.',
4017 + );
4018 + }
4019 + }
4020 + const isFlippedDirection = rangePrevious > rangeNext;
4021 + const initialDirection =
4022 + // If a range is specified we can imply initial direction if it's not the current
4023 + // value such as if the gesture starts after it has already moved.
4024 + currentOffset < rangeCurrent
4025 + ? isFlippedDirection
4026 + : currentOffset > rangeCurrent
4027 + ? !isFlippedDirection
4028 + : // Otherwise, look for an explicit option.
4029 + gestureOptions && gestureOptions.direction === 'next'
4030 + ? true
4031 + : gestureOptions && gestureOptions.direction === 'previous'
4032 + ? false
4033 + : // If no option is specified, imply from the values specified.
4034 + queue.initialDirection;
4035 const scheduledGesture = scheduleGesture(
4036 root,
4037 gestureTimeline,
4004 - queue.initialDirection,
4038 + initialDirection,
4039 + rangePrevious,
4040 + rangeCurrent,
4041 + rangeNext,
4042 );
4043 // Add this particular instance to the queue.
4044 // We add multiple of the same timeline even if they get batched so
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+1
@@ -145,6 +145,7 @@ export const wasInstanceInViewport = $$$config.wasInstanceInViewport;
145 export const hasInstanceChanged = $$$config.hasInstanceChanged;
146 export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
147 export const startViewTransition = $$$config.startViewTransition;
148 +export const getCurrentGestureOffset = $$$config.getCurrentGestureOffset;
149 export const subscribeToGestureDirection =
150 $$$config.subscribeToGestureDirection;
151 export const createViewTransitionInstance =
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+5
@@ -393,8 +393,13 @@ export function getInstanceFromNode(mockNode: Object): Object | null {
393
394 export type GestureTimeline = null;
395
396 +export function getCurrentGestureOffset(provider: GestureTimeline): number {
397 + return 0;
398 +}
399 +
400 export function subscribeToGestureDirection(
401 provider: GestureTimeline,
402 + currentOffset: number,
403 directionCallback: (direction: boolean) => void,
404 ): () => void {
405 return () => {};
packages/shared/ReactTypes.js
+9 -1
@@ -172,7 +172,15 @@ export type ReactFormState<S, ReferenceId> = [
172 // renderer supports it.
173 export type GestureProvider = any;
174
175 -export type StartGesture = (gestureProvider: GestureProvider) => () => void;
175 +export type StartGesture = (
176 + gestureProvider: GestureProvider,
177 + gestureOptions: GestureOptions,
178 +) => () => void;
179 +
180 +export type GestureOptions = {
181 + direction?: 'previous' | 'next',
182 + range?: [/*previous*/ number, /*current*/ number, /*next*/ number],
183 +};
184
185 export type Awaited<T> = T extends null | void
186 ? T // special case for `null | undefined` when not in `--strictNullChecks` mode