Skip to content

Commit 88479c6

Browse files
authored
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.
1 parent 70f1d76 commit 88479c6

File tree

19 files changed

+200
-32
lines changed

19 files changed

+200
-32
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,7 @@ module.exports = {
614614
KeyframeAnimationOptions: 'readonly',
615615
GetAnimationsOptions: 'readonly',
616616
Animatable: 'readonly',
617+
ScrollTimeline: 'readonly',
617618

618619
spyOnDev: 'readonly',
619620
spyOnDevAndProd: 'readonly',

fixtures/view-transition/src/components/Page.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,12 @@ export default function Page({url, navigate}) {
6868
activeGesture.current = null;
6969
cancelGesture();
7070
}
71+
// Reset scroll
72+
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
7173
}
7274

7375
useLayoutEffect(() => {
74-
swipeRecognizer.current.scrollLeft = show ? 0 : 10000;
76+
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
7577
}, [show]);
7678

7779
const exclamation = (

packages/react-art/src/ReactFiberConfigART.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,15 @@ export function createViewTransitionInstance(
508508
return null;
509509
}
510510

511+
export type GestureTimeline = null;
512+
513+
export function subscribeToGestureDirection(
514+
provider: GestureTimeline,
515+
directionCallback: (direction: boolean) => void,
516+
): () => void {
517+
throw new Error('useSwipeTransition is not yet supported in react-art.');
518+
}
519+
511520
export function clearContainer(container) {
512521
// TODO Implement this
513522
}

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,60 @@ export function createViewTransitionInstance(
14781478
};
14791479
}
14801480

1481+
export type GestureTimeline = AnimationTimeline; // TODO: More provider types.
1482+
1483+
export function subscribeToGestureDirection(
1484+
provider: GestureTimeline,
1485+
directionCallback: (direction: boolean) => void,
1486+
): () => void {
1487+
const time = provider.currentTime;
1488+
if (time === null) {
1489+
throw new Error(
1490+
'Cannot start a gesture with a disconnected AnimationTimeline.',
1491+
);
1492+
}
1493+
const startTime = typeof time === 'number' ? time : time.value;
1494+
if (
1495+
typeof ScrollTimeline === 'function' &&
1496+
provider instanceof ScrollTimeline
1497+
) {
1498+
// For ScrollTimeline we optimize to only update the current time on scroll events.
1499+
const element = provider.source;
1500+
const scrollCallback = () => {
1501+
const newTime = provider.currentTime;
1502+
if (newTime !== null) {
1503+
directionCallback(
1504+
typeof newTime === 'number'
1505+
? newTime > startTime
1506+
: newTime.value > startTime,
1507+
);
1508+
}
1509+
};
1510+
element.addEventListener('scroll', scrollCallback, false);
1511+
return () => {
1512+
element.removeEventListener('scroll', scrollCallback, false);
1513+
};
1514+
} else {
1515+
// For other AnimationTimelines, such as DocumentTimeline, we just update every rAF.
1516+
// TODO: Optimize ViewTimeline using an IntersectionObserver if it becomes common.
1517+
const rafCallback = () => {
1518+
const newTime = provider.currentTime;
1519+
if (newTime !== null) {
1520+
directionCallback(
1521+
typeof newTime === 'number'
1522+
? newTime > startTime
1523+
: newTime.value > startTime,
1524+
);
1525+
}
1526+
callbackID = requestAnimationFrame(rafCallback);
1527+
};
1528+
let callbackID = requestAnimationFrame(rafCallback);
1529+
return () => {
1530+
cancelAnimationFrame(callbackID);
1531+
};
1532+
}
1533+
}
1534+
14811535
export function clearContainer(container: Container): void {
14821536
const nodeType = container.nodeType;
14831537
if (nodeType === DOCUMENT_NODE) {

packages/react-native-renderer/src/ReactFiberConfigNative.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,15 @@ export function createViewTransitionInstance(
605605
return null;
606606
}
607607

608+
export type GestureTimeline = null;
609+
610+
export function subscribeToGestureDirection(
611+
provider: GestureTimeline,
612+
directionCallback: (direction: boolean) => void,
613+
): () => void {
614+
throw new Error('useSwipeTransition is not yet supported in React Native.');
615+
}
616+
608617
export function clearContainer(container: Container): void {
609618
// TODO Implement this for React Native
610619
// UIManager does not expose a "remove all" type method.

packages/react-noop-renderer/src/createReactNoop.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export type FormInstance = Instance;
9595

9696
export type ViewTransitionInstance = null | {name: string, ...};
9797

98+
export type GestureTimeline = null;
99+
98100
const NO_CONTEXT = {};
99101
const UPPERCASE_CONTEXT = {};
100102
if (__DEV__) {
@@ -794,6 +796,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
794796
return null;
795797
},
796798

799+
subscribeToGestureDirection(
800+
provider: GestureTimeline,
801+
directionCallback: (direction: boolean) => void,
802+
): () => void {
803+
return () => {};
804+
},
805+
797806
resetTextContent(instance: Instance): void {
798807
instance.text = null;
799808
},

packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ export const hasInstanceAffectedParent = shim;
4848
export const startViewTransition = shim;
4949
export type ViewTransitionInstance = null | {name: string, ...};
5050
export const createViewTransitionInstance = shim;
51+
export type GestureTimeline = any;
52+
export const subscribeToGestureDirection = shim;

packages/react-reconciler/src/ReactFiberGestureScheduler.js

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,26 @@
88
*/
99

1010
import type {FiberRoot} from './ReactInternalTypes';
11-
import type {GestureProvider} from 'shared/ReactTypes';
11+
import type {GestureTimeline} from './ReactFiberConfig';
1212

1313
import {GestureLane} from './ReactFiberLane';
1414
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
15+
import {subscribeToGestureDirection} from './ReactFiberConfig';
1516

1617
// This type keeps track of any scheduled or active gestures.
1718
export type ScheduledGesture = {
18-
provider: GestureProvider,
19+
provider: GestureTimeline,
1920
count: number, // The number of times this same provider has been started.
21+
direction: boolean, // false = previous, true = next
22+
cancel: () => void, // Cancel the subscription to direction change.
2023
prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
2124
next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
2225
};
2326

2427
export function scheduleGesture(
2528
root: FiberRoot,
26-
provider: GestureProvider,
29+
provider: GestureTimeline,
30+
initialDirection: boolean,
2731
): ScheduledGesture {
2832
let prev = root.gestures;
2933
while (prev !== null) {
@@ -39,9 +43,32 @@ export function scheduleGesture(
3943
prev = next;
4044
}
4145
// 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;
57+
}
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);
63+
}
64+
// TODO: If we're currently rendering this gesture, we need to restart it.
65+
}
66+
});
4267
const gesture: ScheduledGesture = {
4368
provider: provider,
4469
count: 1,
70+
direction: initialDirection,
71+
cancel: cancel,
4572
prev: prev,
4673
next: null,
4774
};
@@ -60,8 +87,12 @@ export function cancelScheduledGesture(
6087
): void {
6188
gesture.count--;
6289
if (gesture.count === 0) {
90+
const cancelDirectionSubscription = gesture.cancel;
91+
cancelDirectionSubscription();
6392
// Delete the scheduled gesture from the queue.
6493
deleteScheduledGesture(root, gesture);
94+
// TODO: If we're currently rendering this gesture, we need to restart the render
95+
// on a different gesture or cancel the render..
6596
}
6697
}
6798

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import type {
2727
import type {Lanes, Lane} from './ReactFiberLane';
2828
import type {HookFlags} from './ReactHookEffectTags';
2929
import type {Flags} from './ReactFiberFlags';
30-
import type {TransitionStatus} from './ReactFiberConfig';
30+
import type {TransitionStatus, GestureTimeline} from './ReactFiberConfig';
3131
import type {ScheduledGesture} from './ReactFiberGestureScheduler';
3232

3333
import {
@@ -3981,6 +3981,7 @@ type SwipeTransitionGestureUpdate = {
39813981
type SwipeTransitionUpdateQueue = {
39823982
pending: null | SwipeTransitionGestureUpdate,
39833983
dispatch: StartGesture,
3984+
initialDirection: boolean,
39843985
};
39853986

39863987
function startGesture(
@@ -3996,9 +3997,14 @@ function startGesture(
39963997
// Noop.
39973998
};
39983999
}
3999-
const scheduledGesture = scheduleGesture(root, gestureProvider);
4000+
const gestureTimeline: GestureTimeline = gestureProvider;
4001+
const scheduledGesture = scheduleGesture(
4002+
root,
4003+
gestureTimeline,
4004+
queue.initialDirection,
4005+
);
40004006
// Add this particular instance to the queue.
4001-
// We add multiple of the same provider even if they get batched so
4007+
// We add multiple of the same timeline even if they get batched so
40024008
// that if we cancel one but not the other we can keep track of this.
40034009
// Order doesn't matter but we insert in the beginning to avoid two fields.
40044010
const update: SwipeTransitionGestureUpdate = {
@@ -4041,6 +4047,7 @@ function mountSwipeTransition<T>(
40414047
const queue: SwipeTransitionUpdateQueue = {
40424048
pending: null,
40434049
dispatch: (null: any),
4050+
initialDirection: previous === current,
40444051
};
40454052
const startGestureOnHook: StartGesture = (queue.dispatch = (startGesture.bind(
40464053
null,
@@ -4062,31 +4069,34 @@ function updateSwipeTransition<T>(
40624069
const startGestureOnHook: StartGesture = queue.dispatch;
40634070
const rootRenderLanes = getWorkInProgressRootRenderLanes();
40644071
let value = current;
4065-
if (isGestureRender(rootRenderLanes)) {
4066-
// We're inside a gesture render. We'll traverse the queue to see if
4067-
// this specific Hook is part of this gesture and, if so, which
4068-
// direction to render.
4069-
const root: FiberRoot | null = getWorkInProgressRoot();
4070-
if (root === null) {
4071-
throw new Error(
4072-
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
4073-
);
4074-
}
4075-
// We assume that the currently rendering gesture is the one first in the queue.
4076-
const rootRenderGesture = root.gestures;
4077-
let update = queue.pending;
4078-
while (update !== null) {
4079-
if (rootRenderGesture === update.gesture) {
4080-
// We had a match, meaning we're currently rendering a direction of this
4081-
// hook for this gesture.
4082-
// TODO: Determine which direction this gesture is currently rendering.
4083-
value = previous;
4084-
break;
4072+
if (queue.pending !== null) {
4073+
if (isGestureRender(rootRenderLanes)) {
4074+
// We're inside a gesture render. We'll traverse the queue to see if
4075+
// this specific Hook is part of this gesture and, if so, which
4076+
// direction to render.
4077+
const root: FiberRoot | null = getWorkInProgressRoot();
4078+
if (root === null) {
4079+
throw new Error(
4080+
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
4081+
);
40854082
}
4086-
update = update.next;
4083+
// We assume that the currently rendering gesture is the one first in the queue.
4084+
const rootRenderGesture = root.gestures;
4085+
if (rootRenderGesture !== null) {
4086+
let update = queue.pending;
4087+
while (update !== null) {
4088+
if (rootRenderGesture === update.gesture) {
4089+
// We had a match, meaning we're currently rendering a direction of this
4090+
// hook for this gesture.
4091+
value = rootRenderGesture.direction ? next : previous;
4092+
break;
4093+
}
4094+
update = update.next;
4095+
}
4096+
}
4097+
// This lane cannot be cleared as long as we have active gestures.
4098+
markWorkInProgressReceivedUpdate();
40874099
}
4088-
}
4089-
if (queue.pending !== null) {
40904100
// As long as there are any active gestures we need to leave the lane on
40914101
// in case we need to render it later. Since a gesture render doesn't commit
40924102
// the only time it really fully gets cleared is if something else rerenders
@@ -4096,6 +4106,11 @@ function updateSwipeTransition<T>(
40964106
GestureLane,
40974107
);
40984108
}
4109+
// By default, we don't know which direction we should start until a movement
4110+
// has happened. However, if one direction has the same value as current we
4111+
// know that it's probably not that direction since it won't do anything anyway.
4112+
// TODO: Add an explicit option to provide this.
4113+
queue.initialDirection = previous === current;
40994114
return [value, startGestureOnHook];
41004115
}
41014116

packages/react-reconciler/src/forks/ReactFiberConfig.custom.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export opaque type FormInstance = mixed;
4343
export type ViewTransitionInstance = null | {name: string, ...};
4444
export opaque type InstanceMeasurement = mixed;
4545
export type EventResponder = any;
46+
export type GestureTimeline = any;
4647

4748
export const rendererVersion = $$$config.rendererVersion;
4849
export const rendererPackageName = $$$config.rendererPackageName;
@@ -144,6 +145,8 @@ export const wasInstanceInViewport = $$$config.wasInstanceInViewport;
144145
export const hasInstanceChanged = $$$config.hasInstanceChanged;
145146
export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
146147
export const startViewTransition = $$$config.startViewTransition;
148+
export const subscribeToGestureDirection =
149+
$$$config.subscribeToGestureDirection;
147150
export const createViewTransitionInstance =
148151
$$$config.createViewTransitionInstance;
149152
export const clearContainer = $$$config.clearContainer;

0 commit comments

Comments
 (0)