From d3e4329435c905acd61daa637e5a80218194c257 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 18 Jan 2026 22:08:19 -0500 Subject: [PATCH] Support onGestureEnter/Exit/Share/Update events This is like the onEnter/Exit/Share/Update events but for gestures. It allows manually controlling the animation using the passed timeline. --- .../view-transition/src/components/Page.js | 47 +++++++++++++- .../src/ReactFiberApplyGesture.js | 10 ++- .../src/ReactFiberCommitViewTransitions.js | 9 ++- .../src/ReactFiberWorkLoop.js | 65 ++++++++++++++++++- packages/shared/ReactTypes.js | 29 +++++++++ 5 files changed, 154 insertions(+), 6 deletions(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 6227e9ebc5f..962ef168b4c 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -86,6 +86,48 @@ export default function Page({url, navigate}) { viewTransition.new.animate(keyframes, 250); } + function onGestureTransition( + timeline, + {rangeStart, rangeEnd}, + viewTransition, + types + ) { + const keyframes = [ + {rotate: '0deg', transformOrigin: '30px 8px'}, + {rotate: '360deg', transformOrigin: '30px 8px'}, + ]; + const reverse = rangeStart > rangeEnd; + if (timeline instanceof AnimationTimeline) { + // Native Timeline + const options = { + timeline: timeline, + direction: reverse ? 'normal' : 'reverse', + rangeStart: (reverse ? rangeEnd : rangeStart) + '%', + rangeEnd: (reverse ? rangeStart : rangeEnd) + '%', + }; + viewTransition.old.animate(keyframes, options); + viewTransition.new.animate(keyframes, options); + } else { + // Custom Timeline + const options = { + direction: reverse ? 'normal' : 'reverse', + // We set the delay and duration to represent the span of the range. + delay: reverse ? rangeEnd : rangeStart, + duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart, + }; + const animation1 = viewTransition.old.animate(keyframes, options); + const animation2 = viewTransition.new.animate(keyframes, options); + // Let the custom timeline take control of driving the animations. + const cleanup1 = timeline.animate(animation1); + const cleanup2 = timeline.animate(animation2); + // TODO: Support returning a clean up function from ViewTransition events. + // return () => { + // cleanup1(); + // cleanup2(); + // }; + } + } + function swipeAction() { navigate(show ? '/?a' : '/?b'); } @@ -131,7 +173,10 @@ export default function Page({url, navigate}) { ); const exclamation = ( - +
!
diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index f18e33b57ba..40bdeb7a342 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -82,6 +82,7 @@ import { enableComponentPerformanceTrack, } from 'shared/ReactFeatureFlags'; import {trackAnimatingTask} from './ReactProfilerTimer'; +import {scheduleGestureTransitionEvent} from './ReactFiberWorkLoop'; let didWarnForRootClone = false; @@ -280,6 +281,7 @@ function applyAppearingPairViewTransition(child: Fiber): void { if (clones !== null) { applyViewTransitionToClones(name, className, clones, child); } + scheduleGestureTransitionEvent(child, props.onGestureShare); } } } @@ -310,6 +312,11 @@ function applyExitViewTransition(placement: Fiber): void { if (clones !== null) { applyViewTransitionToClones(name, className, clones, placement); } + if (state.paired) { + scheduleGestureTransitionEvent(placement, props.onGestureShare); + } else { + scheduleGestureTransitionEvent(placement, props.onGestureExit); + } } } @@ -1123,7 +1130,8 @@ function applyViewTransitionsOnFiber(finishedWork: Fiber) { // TODO: If this doesn't end up canceled, because a parent animates, // then we should probably issue an event since this instance is part of it. } else { - // TODO: Schedule gesture events. + const props: ViewTransitionProps = finishedWork.memoizedProps; + scheduleGestureTransitionEvent(finishedWork, props.onGestureUpdate); // If this boundary did update, we cannot cancel its children so those are dropped. popViewTransitionCancelableScope(prevCancelableChildren); } diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index 64940c31957..a9edc0c84d2 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -34,7 +34,10 @@ import { hasInstanceAffectedParent, wasInstanceInViewport, } from './ReactFiberConfig'; -import {scheduleViewTransitionEvent} from './ReactFiberWorkLoop'; +import { + scheduleViewTransitionEvent, + scheduleGestureTransitionEvent, +} from './ReactFiberWorkLoop'; import { getViewTransitionName, getViewTransitionClassName, @@ -312,7 +315,7 @@ export function commitEnterViewTransitions( if (!state.paired) { if (gesture) { - // TODO: Schedule gesture events. + scheduleGestureTransitionEvent(placement, props.onGestureEnter); } else { scheduleViewTransitionEvent(placement, props.onEnter); } @@ -848,7 +851,7 @@ export function measureNestedViewTransitions( // Nothing changed. } else { if (gesture) { - // TODO: Schedule gesture events. + scheduleGestureTransitionEvent(child, props.onGestureUpdate); } else { scheduleViewTransitionEvent(child, props.onUpdate); } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 5bc334364ad..206bf797d60 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -9,7 +9,11 @@ import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols'; -import type {Wakeable, Thenable} from 'shared/ReactTypes'; +import type { + Wakeable, + Thenable, + GestureOptionsRequired, +} from 'shared/ReactTypes'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane'; import type {ActivityState} from './ReactFiberActivityComponent'; @@ -26,6 +30,7 @@ import type { Resource, ViewTransitionInstance, RunningViewTransition, + GestureTimeline, SuspendedState, } from './ReactFiberConfig'; import type {RootState} from './ReactFiberRoot'; @@ -913,6 +918,42 @@ export function scheduleViewTransitionEvent( } } +export function scheduleGestureTransitionEvent( + fiber: Fiber, + callback: ?( + timeline: GestureTimeline, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, +): void { + if (enableGestureTransition) { + if (callback != null) { + const applyingGesture = pendingEffectsRoot.pendingGestures; + if (applyingGesture !== null) { + const state: ViewTransitionState = fiber.stateNode; + let instance = state.ref; + if (instance === null) { + instance = state.ref = createViewTransitionInstance( + getViewTransitionName(fiber.memoizedProps, state), + ); + } + const timeline = applyingGesture.provider; + const options = { + rangeStart: applyingGesture.rangeStart, + rangeEnd: applyingGesture.rangeEnd, + }; + if (pendingViewTransitionEvents === null) { + pendingViewTransitionEvents = []; + } + pendingViewTransitionEvents.push( + callback.bind(null, timeline, options, instance), + ); + } + } + } +} + export function peekDeferredLane(): Lane { return workInProgressDeferredLane; } @@ -4352,6 +4393,8 @@ function applyGestureOnRoot( startAnimating(pendingEffectsLanes); } + pendingViewTransitionEvents = null; + const prevTransition = ReactSharedInternals.T; ReactSharedInternals.T = null; const previousPriority = getCurrentUpdatePriority(); @@ -4476,6 +4519,26 @@ function flushGestureAnimations(): void { ReactSharedInternals.T = prevTransition; } + if (enableViewTransition) { + // We should now be after the startGestureTransition's .ready call which is late enough + // to start animating any pseudo-elements. We have also already applied any adjustments + // we do to the built-in animations which can now be read by the refs. + const pendingEvents = pendingViewTransitionEvents; + let pendingTypes = pendingTransitionTypes; + pendingTransitionTypes = null; + if (pendingEvents !== null) { + pendingViewTransitionEvents = null; + if (pendingTypes === null) { + // Normalize the type. This is lazily created only for events. + pendingTypes = []; + } + for (let i = 0; i < pendingEvents.length; i++) { + const viewTransitionEvent = pendingEvents[i]; + viewTransitionEvent(pendingTypes); + } + } + } + if (enableProfilerTimer && enableComponentPerformanceTrack) { finalizeRender(lanes, commitEndTime); } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 65ed43c063c..2596f02c995 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -290,6 +290,11 @@ export type ViewTransitionClass = | string | ViewTransitionClassPerType; +export type GestureOptionsRequired = { + rangeStart: number, + rangeEnd: number, +}; + export type ViewTransitionProps = { name?: string, children?: ReactNodeList, @@ -302,6 +307,30 @@ export type ViewTransitionProps = { onExit?: (instance: ViewTransitionInstance, types: Array) => void, onShare?: (instance: ViewTransitionInstance, types: Array) => void, onUpdate?: (instance: ViewTransitionInstance, types: Array) => void, + onGestureEnter?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, + onGestureExit?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, + onGestureShare?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, + onGestureUpdate?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, }; export type ActivityProps = {