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).
This commit is contained in:
Sebastian Markbåge 2025-02-21 11:03:04 -05:00 committed by GitHub
parent 88479c6fc3
commit 662957cc73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 123 additions and 37 deletions

View File

@ -510,8 +510,13 @@ export function createViewTransitionInstance(
export type GestureTimeline = null; export type GestureTimeline = null;
export function getCurrentGestureOffset(provider: GestureTimeline): number {
throw new Error('useSwipeTransition is not yet supported in react-art.');
}
export function subscribeToGestureDirection( export function subscribeToGestureDirection(
provider: GestureTimeline, provider: GestureTimeline,
currentOffset: number,
directionCallback: (direction: boolean) => void, directionCallback: (direction: boolean) => void,
): () => void { ): () => void {
throw new Error('useSwipeTransition is not yet supported in react-art.'); throw new Error('useSwipeTransition is not yet supported in react-art.');

View File

@ -1480,17 +1480,21 @@ export function createViewTransitionInstance(
export type GestureTimeline = AnimationTimeline; // TODO: More provider types. export type GestureTimeline = AnimationTimeline; // TODO: More provider types.
export function subscribeToGestureDirection( export function getCurrentGestureOffset(provider: GestureTimeline): number {
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
const time = provider.currentTime; const time = provider.currentTime;
if (time === null) { if (time === null) {
throw new Error( throw new Error(
'Cannot start a gesture with a disconnected AnimationTimeline.', '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 ( if (
typeof ScrollTimeline === 'function' && typeof ScrollTimeline === 'function' &&
provider instanceof ScrollTimeline provider instanceof ScrollTimeline
@ -1500,11 +1504,10 @@ export function subscribeToGestureDirection(
const scrollCallback = () => { const scrollCallback = () => {
const newTime = provider.currentTime; const newTime = provider.currentTime;
if (newTime !== null) { if (newTime !== null) {
directionCallback( const newValue = typeof newTime === 'number' ? newTime : newTime.value;
typeof newTime === 'number' if (newValue !== currentOffset) {
? newTime > startTime directionCallback(newValue > currentOffset);
: newTime.value > startTime, }
);
} }
}; };
element.addEventListener('scroll', scrollCallback, false); element.addEventListener('scroll', scrollCallback, false);
@ -1517,11 +1520,10 @@ export function subscribeToGestureDirection(
const rafCallback = () => { const rafCallback = () => {
const newTime = provider.currentTime; const newTime = provider.currentTime;
if (newTime !== null) { if (newTime !== null) {
directionCallback( const newValue = typeof newTime === 'number' ? newTime : newTime.value;
typeof newTime === 'number' if (newValue !== currentOffset) {
? newTime > startTime directionCallback(newValue > currentOffset);
: newTime.value > startTime, }
);
} }
callbackID = requestAnimationFrame(rafCallback); callbackID = requestAnimationFrame(rafCallback);
}; };

View File

@ -607,8 +607,13 @@ export function createViewTransitionInstance(
export type GestureTimeline = null; export type GestureTimeline = null;
export function getCurrentGestureOffset(provider: GestureTimeline): number {
throw new Error('useSwipeTransition is not yet supported in React Native.');
}
export function subscribeToGestureDirection( export function subscribeToGestureDirection(
provider: GestureTimeline, provider: GestureTimeline,
currentOffset: number,
directionCallback: (direction: boolean) => void, directionCallback: (direction: boolean) => void,
): () => void { ): () => void {
throw new Error('useSwipeTransition is not yet supported in React Native.'); throw new Error('useSwipeTransition is not yet supported in React Native.');

View File

@ -796,8 +796,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return null; return null;
}, },
getCurrentGestureOffset(provider: GestureTimeline): number {
return 0;
},
subscribeToGestureDirection( subscribeToGestureDirection(
provider: GestureTimeline, provider: GestureTimeline,
currentOffset: number,
directionCallback: (direction: boolean) => void, directionCallback: (direction: boolean) => void,
): () => void { ): () => void {
return () => {}; return () => {};

View File

@ -49,4 +49,5 @@ export const startViewTransition = shim;
export type ViewTransitionInstance = null | {name: string, ...}; export type ViewTransitionInstance = null | {name: string, ...};
export const createViewTransitionInstance = shim; export const createViewTransitionInstance = shim;
export type GestureTimeline = any; export type GestureTimeline = any;
export const getCurrentGestureOffset = shim;
export const subscribeToGestureDirection = shim; export const subscribeToGestureDirection = shim;

View File

@ -19,6 +19,9 @@ export type ScheduledGesture = {
provider: GestureTimeline, provider: GestureTimeline,
count: number, // The number of times this same provider has been started. count: number, // The number of times this same provider has been started.
direction: boolean, // false = previous, true = next 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. cancel: () => void, // Cancel the subscription to direction change.
prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root. 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. next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
@ -28,6 +31,9 @@ export function scheduleGesture(
root: FiberRoot, root: FiberRoot,
provider: GestureTimeline, provider: GestureTimeline,
initialDirection: boolean, initialDirection: boolean,
rangePrevious: number,
rangeCurrent: number,
rangeNext: number,
): ScheduledGesture { ): ScheduledGesture {
let prev = root.gestures; let prev = root.gestures;
while (prev !== null) { while (prev !== null) {
@ -42,32 +48,43 @@ export function scheduleGesture(
} }
prev = next; prev = next;
} }
const isFlippedDirection = rangePrevious > rangeNext;
// Add new instance to the end of the queue. // Add new instance to the end of the queue.
const cancel = subscribeToGestureDirection(provider, (direction: boolean) => { const cancel = subscribeToGestureDirection(
if (gesture.direction !== direction) { provider,
gesture.direction = direction; rangeCurrent,
if (gesture.prev === null && root.gestures !== gesture) { (direction: boolean) => {
// This gesture is not in the schedule, meaning it was already rendered. if (isFlippedDirection) {
// We need to rerender in the new direction. Insert it into the first slot direction = !direction;
// 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);
} }
// TODO: If we're currently rendering this gesture, we need to restart it. 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);
}
// TODO: If we're currently rendering this gesture, we need to restart it.
}
},
);
const gesture: ScheduledGesture = { const gesture: ScheduledGesture = {
provider: provider, provider: provider,
count: 1, count: 1,
direction: initialDirection, direction: initialDirection,
rangePrevious: rangePrevious,
rangeCurrent: rangeCurrent,
rangeNext: rangeNext,
cancel: cancel, cancel: cancel,
prev: prev, prev: prev,
next: null, next: null,

View File

@ -16,6 +16,7 @@ import type {
Awaited, Awaited,
StartGesture, StartGesture,
GestureProvider, GestureProvider,
GestureOptions,
} from 'shared/ReactTypes'; } from 'shared/ReactTypes';
import type { import type {
Fiber, Fiber,
@ -35,6 +36,7 @@ import {
NotPendingTransition as NoPendingHostTransition, NotPendingTransition as NoPendingHostTransition,
setCurrentUpdatePriority, setCurrentUpdatePriority,
getCurrentUpdatePriority, getCurrentUpdatePriority,
getCurrentGestureOffset,
} from './ReactFiberConfig'; } from './ReactFiberConfig';
import ReactSharedInternals from 'shared/ReactSharedInternals'; import ReactSharedInternals from 'shared/ReactSharedInternals';
import { import {
@ -3988,6 +3990,7 @@ function startGesture(
fiber: Fiber, fiber: Fiber,
queue: SwipeTransitionUpdateQueue, queue: SwipeTransitionUpdateQueue,
gestureProvider: GestureProvider, gestureProvider: GestureProvider,
gestureOptions?: GestureOptions,
): () => void { ): () => void {
const root = enqueueGestureRender(fiber); const root = enqueueGestureRender(fiber);
if (root === null) { if (root === null) {
@ -3998,10 +4001,44 @@ function startGesture(
}; };
} }
const gestureTimeline: GestureTimeline = gestureProvider; 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( const scheduledGesture = scheduleGesture(
root, root,
gestureTimeline, gestureTimeline,
queue.initialDirection, initialDirection,
rangePrevious,
rangeCurrent,
rangeNext,
); );
// Add this particular instance to the queue. // Add this particular instance to the queue.
// We add multiple of the same timeline even if they get batched so // We add multiple of the same timeline even if they get batched so

View File

@ -145,6 +145,7 @@ export const wasInstanceInViewport = $$$config.wasInstanceInViewport;
export const hasInstanceChanged = $$$config.hasInstanceChanged; export const hasInstanceChanged = $$$config.hasInstanceChanged;
export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent; export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
export const startViewTransition = $$$config.startViewTransition; export const startViewTransition = $$$config.startViewTransition;
export const getCurrentGestureOffset = $$$config.getCurrentGestureOffset;
export const subscribeToGestureDirection = export const subscribeToGestureDirection =
$$$config.subscribeToGestureDirection; $$$config.subscribeToGestureDirection;
export const createViewTransitionInstance = export const createViewTransitionInstance =

View File

@ -393,8 +393,13 @@ export function getInstanceFromNode(mockNode: Object): Object | null {
export type GestureTimeline = null; export type GestureTimeline = null;
export function getCurrentGestureOffset(provider: GestureTimeline): number {
return 0;
}
export function subscribeToGestureDirection( export function subscribeToGestureDirection(
provider: GestureTimeline, provider: GestureTimeline,
currentOffset: number,
directionCallback: (direction: boolean) => void, directionCallback: (direction: boolean) => void,
): () => void { ): () => void {
return () => {}; return () => {};

View File

@ -172,7 +172,15 @@ export type ReactFormState<S, ReferenceId> = [
// renderer supports it. // renderer supports it.
export type GestureProvider = any; 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> = T extends null | void export type Awaited<T> = T extends null | void
? T // special case for `null | undefined` when not in `--strictNullChecks` mode ? T // special case for `null | undefined` when not in `--strictNullChecks` mode