Rerender useSwipeTransition when direction changes (#32379)

We can only render one direction at a time with View Transitions. When
the direction changes we need to do another render in the new direction
(returning previous or next).

To determine direction we store the position we started at and anything
moving to a lower value (left/up) is "previous" direction (`false`) and
anything else is "next" (`true`) direction.

For the very first render we won't know which direction you're going
since you're still on the initial position. It's useful to start the
render to allow the view transition to take control before anything
shifts around so we start from the original position. This is not
guaranteed though if the render suspends.

For now we start the first render by guessing the direction such as if
we know that prev/next are the same as current. With the upcoming auto
start mode we can guess more accurately there before we start. We can
also add explicit APIs to `startGesture` but ideally it wouldn't matter.
Ideally we could just start after the first change in direction from the
starting point.
This commit is contained in:
Sebastian Markbåge 2025-02-20 18:13:09 -05:00 committed by GitHub
parent 70f1d766e8
commit 88479c6fc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 201 additions and 33 deletions

View File

@ -614,6 +614,7 @@ module.exports = {
KeyframeAnimationOptions: 'readonly',
GetAnimationsOptions: 'readonly',
Animatable: 'readonly',
ScrollTimeline: 'readonly',
spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',

View File

@ -68,10 +68,12 @@ export default function Page({url, navigate}) {
activeGesture.current = null;
cancelGesture();
}
// Reset scroll
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
}
useLayoutEffect(() => {
swipeRecognizer.current.scrollLeft = show ? 0 : 10000;
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
}, [show]);
const exclamation = (

View File

@ -508,6 +508,15 @@ export function createViewTransitionInstance(
return null;
}
export type GestureTimeline = null;
export function subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
throw new Error('useSwipeTransition is not yet supported in react-art.');
}
export function clearContainer(container) {
// TODO Implement this
}

View File

@ -1478,6 +1478,60 @@ export function createViewTransitionInstance(
};
}
export type GestureTimeline = AnimationTimeline; // TODO: More provider types.
export function subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
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;
if (
typeof ScrollTimeline === 'function' &&
provider instanceof ScrollTimeline
) {
// For ScrollTimeline we optimize to only update the current time on scroll events.
const element = provider.source;
const scrollCallback = () => {
const newTime = provider.currentTime;
if (newTime !== null) {
directionCallback(
typeof newTime === 'number'
? newTime > startTime
: newTime.value > startTime,
);
}
};
element.addEventListener('scroll', scrollCallback, false);
return () => {
element.removeEventListener('scroll', scrollCallback, false);
};
} else {
// For other AnimationTimelines, such as DocumentTimeline, we just update every rAF.
// TODO: Optimize ViewTimeline using an IntersectionObserver if it becomes common.
const rafCallback = () => {
const newTime = provider.currentTime;
if (newTime !== null) {
directionCallback(
typeof newTime === 'number'
? newTime > startTime
: newTime.value > startTime,
);
}
callbackID = requestAnimationFrame(rafCallback);
};
let callbackID = requestAnimationFrame(rafCallback);
return () => {
cancelAnimationFrame(callbackID);
};
}
}
export function clearContainer(container: Container): void {
const nodeType = container.nodeType;
if (nodeType === DOCUMENT_NODE) {

View File

@ -605,6 +605,15 @@ export function createViewTransitionInstance(
return null;
}
export type GestureTimeline = null;
export function subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
throw new Error('useSwipeTransition is not yet supported in React Native.');
}
export function clearContainer(container: Container): void {
// TODO Implement this for React Native
// UIManager does not expose a "remove all" type method.

View File

@ -95,6 +95,8 @@ export type FormInstance = Instance;
export type ViewTransitionInstance = null | {name: string, ...};
export type GestureTimeline = null;
const NO_CONTEXT = {};
const UPPERCASE_CONTEXT = {};
if (__DEV__) {
@ -794,6 +796,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return null;
},
subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
return () => {};
},
resetTextContent(instance: Instance): void {
instance.text = null;
},

View File

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

View File

@ -8,22 +8,26 @@
*/
import type {FiberRoot} from './ReactInternalTypes';
import type {GestureProvider} from 'shared/ReactTypes';
import type {GestureTimeline} from './ReactFiberConfig';
import {GestureLane} from './ReactFiberLane';
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
import {subscribeToGestureDirection} from './ReactFiberConfig';
// This type keeps track of any scheduled or active gestures.
export type ScheduledGesture = {
provider: GestureProvider,
provider: GestureTimeline,
count: number, // The number of times this same provider has been started.
direction: boolean, // false = previous, true = next
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.
};
export function scheduleGesture(
root: FiberRoot,
provider: GestureProvider,
provider: GestureTimeline,
initialDirection: boolean,
): ScheduledGesture {
let prev = root.gestures;
while (prev !== null) {
@ -39,9 +43,32 @@ export function scheduleGesture(
prev = next;
}
// 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;
}
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 = {
provider: provider,
count: 1,
direction: initialDirection,
cancel: cancel,
prev: prev,
next: null,
};
@ -60,8 +87,12 @@ export function cancelScheduledGesture(
): void {
gesture.count--;
if (gesture.count === 0) {
const cancelDirectionSubscription = gesture.cancel;
cancelDirectionSubscription();
// Delete the scheduled gesture from the queue.
deleteScheduledGesture(root, gesture);
// TODO: If we're currently rendering this gesture, we need to restart the render
// on a different gesture or cancel the render..
}
}

View File

@ -27,7 +27,7 @@ import type {
import type {Lanes, Lane} from './ReactFiberLane';
import type {HookFlags} from './ReactHookEffectTags';
import type {Flags} from './ReactFiberFlags';
import type {TransitionStatus} from './ReactFiberConfig';
import type {TransitionStatus, GestureTimeline} from './ReactFiberConfig';
import type {ScheduledGesture} from './ReactFiberGestureScheduler';
import {
@ -3981,6 +3981,7 @@ type SwipeTransitionGestureUpdate = {
type SwipeTransitionUpdateQueue = {
pending: null | SwipeTransitionGestureUpdate,
dispatch: StartGesture,
initialDirection: boolean,
};
function startGesture(
@ -3996,9 +3997,14 @@ function startGesture(
// Noop.
};
}
const scheduledGesture = scheduleGesture(root, gestureProvider);
const gestureTimeline: GestureTimeline = gestureProvider;
const scheduledGesture = scheduleGesture(
root,
gestureTimeline,
queue.initialDirection,
);
// Add this particular instance to the queue.
// We add multiple of the same provider even if they get batched so
// We add multiple of the same timeline even if they get batched so
// that if we cancel one but not the other we can keep track of this.
// Order doesn't matter but we insert in the beginning to avoid two fields.
const update: SwipeTransitionGestureUpdate = {
@ -4041,6 +4047,7 @@ function mountSwipeTransition<T>(
const queue: SwipeTransitionUpdateQueue = {
pending: null,
dispatch: (null: any),
initialDirection: previous === current,
};
const startGestureOnHook: StartGesture = (queue.dispatch = (startGesture.bind(
null,
@ -4062,6 +4069,7 @@ function updateSwipeTransition<T>(
const startGestureOnHook: StartGesture = queue.dispatch;
const rootRenderLanes = getWorkInProgressRootRenderLanes();
let value = current;
if (queue.pending !== null) {
if (isGestureRender(rootRenderLanes)) {
// We're inside a gesture render. We'll traverse the queue to see if
// this specific Hook is part of this gesture and, if so, which
@ -4074,19 +4082,21 @@ function updateSwipeTransition<T>(
}
// We assume that the currently rendering gesture is the one first in the queue.
const rootRenderGesture = root.gestures;
if (rootRenderGesture !== null) {
let update = queue.pending;
while (update !== null) {
if (rootRenderGesture === update.gesture) {
// We had a match, meaning we're currently rendering a direction of this
// hook for this gesture.
// TODO: Determine which direction this gesture is currently rendering.
value = previous;
value = rootRenderGesture.direction ? next : previous;
break;
}
update = update.next;
}
}
if (queue.pending !== null) {
// This lane cannot be cleared as long as we have active gestures.
markWorkInProgressReceivedUpdate();
}
// As long as there are any active gestures we need to leave the lane on
// in case we need to render it later. Since a gesture render doesn't commit
// the only time it really fully gets cleared is if something else rerenders
@ -4096,6 +4106,11 @@ function updateSwipeTransition<T>(
GestureLane,
);
}
// By default, we don't know which direction we should start until a movement
// has happened. However, if one direction has the same value as current we
// know that it's probably not that direction since it won't do anything anyway.
// TODO: Add an explicit option to provide this.
queue.initialDirection = previous === current;
return [value, startGestureOnHook];
}

View File

@ -43,6 +43,7 @@ export opaque type FormInstance = mixed;
export type ViewTransitionInstance = null | {name: string, ...};
export opaque type InstanceMeasurement = mixed;
export type EventResponder = any;
export type GestureTimeline = any;
export const rendererVersion = $$$config.rendererVersion;
export const rendererPackageName = $$$config.rendererPackageName;
@ -144,6 +145,8 @@ export const wasInstanceInViewport = $$$config.wasInstanceInViewport;
export const hasInstanceChanged = $$$config.hasInstanceChanged;
export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
export const startViewTransition = $$$config.startViewTransition;
export const subscribeToGestureDirection =
$$$config.subscribeToGestureDirection;
export const createViewTransitionInstance =
$$$config.createViewTransitionInstance;
export const clearContainer = $$$config.clearContainer;

View File

@ -391,6 +391,15 @@ export function getInstanceFromNode(mockNode: Object): Object | null {
return null;
}
export type GestureTimeline = null;
export function subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
return () => {};
}
export function beforeActiveInstanceBlur(internalInstanceHandle: Object) {
// noop
}

View File

@ -170,7 +170,7 @@ export type ReactFormState<S, ReferenceId> = [
// Intrinsic GestureProvider. This type varies by Environment whether a particular
// renderer supports it.
export type GestureProvider = AnimationTimeline; // TODO: More provider types.
export type GestureProvider = any;
export type StartGesture = (gestureProvider: GestureProvider) => () => void;

View File

@ -533,5 +533,8 @@
"545": "The %s tag may only be rendered once.",
"546": "useEffect CRUD overload is not enabled in this build of React.",
"547": "startGesture cannot be called during server rendering.",
"548": "Finished rendering the gesture lane but there were no pending gestures. React should not have started a render in this case. This is a bug in React."
"548": "Finished rendering the gesture lane but there were no pending gestures. React should not have started a render in this case. This is a bug in React.",
"549": "Cannot start a gesture with a disconnected AnimationTimeline.",
"550": "useSwipeTransition is not yet supported in react-art.",
"551": "useSwipeTransition is not yet supported in React Native."
}

View File

@ -33,6 +33,18 @@ declare interface ConsoleTask {
run<T>(f: () => T): T;
}
type ScrollTimelineOptions = {
source: Element,
axis?: 'block' | 'inline' | 'x' | 'y',
...
};
declare class ScrollTimeline extends AnimationTimeline {
constructor(options?: ScrollTimelineOptions): void;
axis: 'block' | 'inline' | 'x' | 'y';
source: Element;
}
// Flow hides the props of React$Element, this overrides it to unhide
// them for React internals.
// prettier-ignore

View File

@ -34,6 +34,8 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
// Vendor specific
MSApp: 'readonly',
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',

View File

@ -32,6 +32,7 @@ module.exports = {
Reflect: 'readonly',
globalThis: 'readonly',
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
// Vendor specific
MSApp: 'readonly',
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',

View File

@ -34,6 +34,8 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
// Vendor specific
MSApp: 'readonly',
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',

View File

@ -34,6 +34,8 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
// Vendor specific
MSApp: 'readonly',
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',

View File

@ -34,6 +34,8 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
// Vendor specific
MSApp: 'readonly',
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',