@samitouri / QOS-React / commits / d20c2802b4

Adjust range start/end based on the duration and delay of the animation (#32790)

When different animations in a View Transition have different durations, we shouldn't stretch them out to run the full range of swipe. Because then they wouldn't line up the same way as when played using plain time. This adjusts the range start/end to be what it would've been when played by time. Except since we are playing animations in reverse, the animation-delay is actually applied from the range end and then the duration from there to get closer to the start. Reverse the range if the original animation was reversed. Interestingly, the range it takes can be adjusted by what is in the viewport since if a long duration animation is excluded then everything else adjusts too. I left some todos too. We really should also handle if the original animation has multiple iterations. Currently we only play those once.

Sebastian Markbåge committed Apr 1, 2025 at 11:44 UTC d20c2802b4a2a316424d8cea995354156037a77c
1 file changed +45 -10
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+45 -10
@@ -1880,6 +1880,7 @@ function animateGesture(
1880 // keyframe. Otherwise it applies to every keyframe.
1881 moveOldFrameIntoViewport(keyframes[0]);
1882 }
1883 + // TODO: Reverse the reverse if the original direction is reverse.
1884 const reverse = rangeStart > rangeEnd;
1885 targetElement.animate(keyframes, {
1886 pseudoElement: pseudoElement,
@@ -1890,7 +1891,7 @@ function animateGesture(
1891 // from scroll bouncing.
1892 easing: 'linear',
1893 // We fill in both direction for overscroll.
1893 - fill: 'both',
1894 + fill: 'both', // TODO: Should we preserve the fill instead?
1895 // We play all gestures in reverse, except if we're in reverse direction
1896 // in which case we need to play it in reverse of the reverse.
1897 direction: reverse ? 'normal' : 'reverse',
@@ -1935,18 +1936,33 @@ export function startGestureTransition(
1936 // up if they exist later.
1937 const foundGroups: Set<string> = new Set();
1938 const foundNews: Set<string> = new Set();
1939 + // Collect the longest duration of any view-transition animation including delay.
1940 + let longestDuration = 0;
1941 for (let i = 0; i < animations.length; i++) {
1942 + const effect: KeyframeEffect = (animations[i].effect: any);
1943 // $FlowFixMe
1940 - const pseudoElement: ?string = animations[i].effect.pseudoElement;
1944 + const pseudoElement: ?string = effect.pseudoElement;
1945 if (pseudoElement == null) {
1942 - } else if (pseudoElement.startsWith('::view-transition-group')) {
1943 - foundGroups.add(pseudoElement.slice(23));
1944 - } else if (pseudoElement.startsWith('::view-transition-new')) {
1945 - // TODO: This is not really a sufficient detection because if the new
1946 - // pseudo element might exist but have animations disabled on it.
1947 - foundNews.add(pseudoElement.slice(21));
1946 + } else if (pseudoElement.startsWith('::view-transition')) {
1947 + const timing = effect.getTiming();
1948 + const duration =
1949 + typeof timing.duration === 'number' ? timing.duration : 0;
1950 + // TODO: Consider interation count higher than 1.
1951 + const durationWithDelay = timing.delay + duration;
1952 + if (durationWithDelay > longestDuration) {
1953 + longestDuration = durationWithDelay;
1954 + }
1955 + if (pseudoElement.startsWith('::view-transition-group')) {
1956 + foundGroups.add(pseudoElement.slice(23));
1957 + } else if (pseudoElement.startsWith('::view-transition-new')) {
1958 + // TODO: This is not really a sufficient detection because if the new
1959 + // pseudo element might exist but have animations disabled on it.
1960 + foundNews.add(pseudoElement.slice(21));
1961 + }
1962 }
1963 }
1964 + const durationToRangeMultipler =
1965 + (rangeEnd - rangeStart) / longestDuration;
1966 for (let i = 0; i < animations.length; i++) {
1967 const anim = animations[i];
1968 if (anim.playState !== 'running') {
@@ -1986,14 +2002,33 @@ export function startGestureTransition(
2002 }
2003 // TODO: If this has only an old state and no new state,
2004 }
2005 + // Adjust the range based on how long the animation would've ran as time based.
2006 + // Since we're running animations in reverse from how they normally would run,
2007 + // therefore the timing is from the rangeEnd to the start.
2008 + const timing = effect.getTiming();
2009 + const duration =
2010 + typeof timing.duration === 'number' ? timing.duration : 0;
2011 + let adjustedRangeStart =
2012 + rangeEnd - (duration + timing.delay) * durationToRangeMultipler;
2013 + let adjustedRangeEnd =
2014 + rangeEnd - timing.delay * durationToRangeMultipler;
2015 + if (
2016 + timing.direction === 'reverse' ||
2017 + timing.direction === 'alternate-reverse'
2018 + ) {
2019 + // This animation was originally in reverse so we have to play it in flipped range.
2020 + const temp = adjustedRangeStart;
2021 + adjustedRangeStart = adjustedRangeEnd;
2022 + adjustedRangeEnd = temp;
2023 + }
2024 animateGesture(
2025 effect.getKeyframes(),
2026 // $FlowFixMe: Always documentElement atm.
2027 effect.target,
2028 pseudoElement,
2029 timeline,
1995 - rangeStart,
1996 - rangeEnd,
2030 + adjustedRangeStart,
2031 + adjustedRangeEnd,
2032 isGeneratedGroupAnim,
2033 isExitGroupAnim,
2034 );