Repository: facebook/react
Pull request: 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.



Changed files:
filename: fixtures/view-transition/src/components/Page.js
patch: @@ -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 = (
-    <ViewTransition name="exclamation" onShare={onTransition}>
+    <ViewTransition
+      name="exclamation"
+      onShare={onTransition}
+      onGestureShare={onGestureTransition}>
       <span>
         <div>!</div>
       </span>

filename: packages/react-reconciler/src/ReactFiberApplyGesture.js
patch: @@ -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);
       }

filename: packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
patch: @@ -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);
         }

filename: packages/react-reconciler/src/ReactFiberWorkLoop.js
patch: @@ -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<string>,
+  ) => 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);
   }

filename: packages/shared/ReactTypes.js
patch: @@ -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<string>) => void,
   onShare?: (instance: ViewTransitionInstance, types: Array<string>) => void,
   onUpdate?: (instance: ViewTransitionInstance, types: Array<string>) => void,
+  onGestureEnter?: (
+    timeline: GestureProvider,
+    options: GestureOptionsRequired,
+    instance: ViewTransitionInstance,
+    types: Array<string>,
+  ) => void,
+  onGestureExit?: (
+    timeline: GestureProvider,
+    options: GestureOptionsRequired,
+    instance: ViewTransitionInstance,
+    types: Array<string>,
+  ) => void,
+  onGestureShare?: (
+    timeline: GestureProvider,
+    options: GestureOptionsRequired,
+    instance: ViewTransitionInstance,
+    types: Array<string>,
+  ) => void,
+  onGestureUpdate?: (
+    timeline: GestureProvider,
+    options: GestureOptionsRequired,
+    instance: ViewTransitionInstance,
+    types: Array<string>,
+  ) => void,
 };
 
 export type ActivityProps = {