mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
Add ScrollTimeline Polyfill for Swipe Recognizer using a new CustomTimeline protocol (#33501)
The React API is just that we now accept this protocol as an alternative
to a native `AnimationTimeline` to be passed to
`startGestureTransition`. This is specifically the DOM version.
```js
interface CustomTimeline {
currentTime: number;
animate(animation: Animation): void | (() => void);
}
```
Instead, of passing this to the `Animation` that we start to control the
View Transition keyframes, we instead inverse the control and pass the
`Animation` to this one. It lets any custom implementation drive the
updates. It can do so by updating the time every frame or letting it run
a time based animation (such as momentum scroll).
In this case I added a basic polyfill for `ScrollTimeline` in the
example but we'll need a better one.
This commit is contained in:
parent
73aa744b70
commit
fc41c24aa6
|
|
@ -622,6 +622,7 @@ module.exports = {
|
||||||
ScrollTimeline: 'readonly',
|
ScrollTimeline: 'readonly',
|
||||||
EventListenerOptionsOrUseCapture: 'readonly',
|
EventListenerOptionsOrUseCapture: 'readonly',
|
||||||
FocusOptions: 'readonly',
|
FocusOptions: 'readonly',
|
||||||
|
OptionalEffectTiming: 'readonly',
|
||||||
|
|
||||||
spyOnDev: 'readonly',
|
spyOnDev: 'readonly',
|
||||||
spyOnDevAndProd: 'readonly',
|
spyOnDevAndProd: 'readonly',
|
||||||
|
|
|
||||||
3
fixtures/view-transition/loader/package.json
Normal file
3
fixtures/view-transition/loader/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
54
fixtures/view-transition/loader/server.js
Normal file
54
fixtures/view-transition/loader/server.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import babel from '@babel/core';
|
||||||
|
|
||||||
|
const babelOptions = {
|
||||||
|
babelrc: false,
|
||||||
|
ignore: [/\/(build|node_modules)\//],
|
||||||
|
plugins: [
|
||||||
|
'@babel/plugin-syntax-import-meta',
|
||||||
|
'@babel/plugin-transform-react-jsx',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function load(url, context, defaultLoad) {
|
||||||
|
if (url.endsWith('.css')) {
|
||||||
|
return {source: 'export default {}', format: 'module', shortCircuit: true};
|
||||||
|
}
|
||||||
|
const {format} = context;
|
||||||
|
const result = await defaultLoad(url, context, defaultLoad);
|
||||||
|
if (result.format === 'module') {
|
||||||
|
const opt = Object.assign({filename: url}, babelOptions);
|
||||||
|
const newResult = await babel.transformAsync(result.source, opt);
|
||||||
|
if (!newResult) {
|
||||||
|
if (typeof result.source === 'string') {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
source: Buffer.from(result.source).toString('utf8'),
|
||||||
|
format: 'module',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {source: newResult.code, format: 'module'};
|
||||||
|
}
|
||||||
|
return defaultLoad(url, context, defaultLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function babelTransformSource(source, context, defaultTransformSource) {
|
||||||
|
const {format} = context;
|
||||||
|
if (format === 'module') {
|
||||||
|
const opt = Object.assign({filename: context.url}, babelOptions);
|
||||||
|
const newResult = await babel.transformAsync(source, opt);
|
||||||
|
if (!newResult) {
|
||||||
|
if (typeof source === 'string') {
|
||||||
|
return {source};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
source: Buffer.from(source).toString('utf8'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {source: newResult.code};
|
||||||
|
}
|
||||||
|
return defaultTransformSource(source, context, defaultTransformSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transformSource =
|
||||||
|
process.version < 'v16' ? babelTransformSource : undefined;
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
"express": "^4.14.0",
|
"express": "^4.14.0",
|
||||||
"ignore-styles": "^5.0.1",
|
"ignore-styles": "^5.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"animation-timelines": "^0.0.4"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
|
|
@ -27,8 +28,8 @@
|
||||||
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;",
|
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;",
|
||||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
"dev:client": "BROWSER=none PORT=3001 react-scripts start",
|
"dev:client": "BROWSER=none PORT=3001 react-scripts start",
|
||||||
"dev:server": "NODE_ENV=development node server",
|
"dev:server": "NODE_ENV=development node --experimental-loader ./loader/server.js server",
|
||||||
"start": "react-scripts build && NODE_ENV=production node server",
|
"start": "react-scripts build && NODE_ENV=production node --experimental-loader ./loader/server.js server",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test --env=jsdom",
|
"test": "react-scripts test --env=jsdom",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,16 @@ if (process.env.NODE_ENV === 'development') {
|
||||||
for (var key in require.cache) {
|
for (var key in require.cache) {
|
||||||
delete require.cache[key];
|
delete require.cache[key];
|
||||||
}
|
}
|
||||||
const render = require('./render').default;
|
import('./render.js').then(({default: render}) => {
|
||||||
render(req.url, res);
|
render(req.url, res);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const render = require('./render').default;
|
import('./render.js').then(({default: render}) => {
|
||||||
app.get('/', function (req, res) {
|
app.get('/', function (req, res) {
|
||||||
render(req.url, res);
|
render(req.url, res);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static resources
|
// Static resources
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {renderToPipeableStream} from 'react-dom/server';
|
import {renderToPipeableStream} from 'react-dom/server';
|
||||||
|
|
||||||
import App from '../src/components/App';
|
import App from '../src/components/App.js';
|
||||||
|
|
||||||
let assets;
|
let assets;
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import React, {
|
||||||
unstable_addTransitionType as addTransitionType,
|
unstable_addTransitionType as addTransitionType,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import Chrome from './Chrome';
|
import Chrome from './Chrome.js';
|
||||||
import Page from './Page';
|
import Page from './Page.js';
|
||||||
|
|
||||||
const enableNavigationAPI = typeof navigation === 'object';
|
const enableNavigationAPI = typeof navigation === 'object';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ import React, {
|
||||||
|
|
||||||
import {createPortal} from 'react-dom';
|
import {createPortal} from 'react-dom';
|
||||||
|
|
||||||
import SwipeRecognizer from './SwipeRecognizer';
|
import SwipeRecognizer from './SwipeRecognizer.js';
|
||||||
|
|
||||||
import './Page.css';
|
import './Page.css';
|
||||||
|
|
||||||
import transitions from './Transitions.module.css';
|
import transitions from './Transitions.module.css';
|
||||||
import NestedReveal from './NestedReveal';
|
import NestedReveal from './NestedReveal.js';
|
||||||
|
|
||||||
async function sleep(ms) {
|
async function sleep(ms) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import React, {
|
||||||
unstable_startGestureTransition as startGestureTransition,
|
unstable_startGestureTransition as startGestureTransition,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline';
|
||||||
|
|
||||||
// Example of a Component that can recognize swipe gestures using a ScrollTimeline
|
// Example of a Component that can recognize swipe gestures using a ScrollTimeline
|
||||||
// without scrolling its own content. Allowing it to be used as an inert gesture
|
// without scrolling its own content. Allowing it to be used as an inert gesture
|
||||||
// recognizer to drive a View Transition.
|
// recognizer to drive a View Transition.
|
||||||
|
|
@ -25,14 +27,20 @@ export default function SwipeRecognizer({
|
||||||
if (activeGesture.current !== null) {
|
if (activeGesture.current !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof ScrollTimeline !== 'function') {
|
|
||||||
return;
|
let scrollTimeline;
|
||||||
}
|
if (typeof ScrollTimeline === 'function') {
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
const scrollTimeline = new ScrollTimeline({
|
scrollTimeline = new ScrollTimeline({
|
||||||
source: scrollRef.current,
|
source: scrollRef.current,
|
||||||
axis: axis,
|
axis: axis,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
scrollTimeline = new ScrollTimelinePolyfill({
|
||||||
|
source: scrollRef.current,
|
||||||
|
axis: axis,
|
||||||
|
});
|
||||||
|
}
|
||||||
activeGesture.current = startGestureTransition(
|
activeGesture.current = startGestureTransition(
|
||||||
scrollTimeline,
|
scrollTimeline,
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {hydrateRoot} from 'react-dom/client';
|
import {hydrateRoot} from 'react-dom/client';
|
||||||
|
|
||||||
import App from './components/App';
|
import App from './components/App.js';
|
||||||
|
|
||||||
hydrateRoot(
|
hydrateRoot(
|
||||||
document,
|
document,
|
||||||
|
|
|
||||||
|
|
@ -2427,6 +2427,11 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.9.0:
|
||||||
json-schema-traverse "^1.0.0"
|
json-schema-traverse "^1.0.0"
|
||||||
require-from-string "^2.0.2"
|
require-from-string "^2.0.2"
|
||||||
|
|
||||||
|
animation-timelines@^0.0.4:
|
||||||
|
version "0.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/animation-timelines/-/animation-timelines-0.0.4.tgz#7ac4614bae73c4d1ea2ff18d5d87a518793258af"
|
||||||
|
integrity sha512-HwCE3m1nM8ZdLbwDwD1j5ZNKmY+3J2CliXJNIsf3y1Si927SIaWpfxkycTg5nWLJSHgjsYxrmOy2Jbo4JR1e9A==
|
||||||
|
|
||||||
ansi-escapes@^4.2.1, ansi-escapes@^4.3.1:
|
ansi-escapes@^4.2.1, ansi-escapes@^4.3.1:
|
||||||
version "4.3.2"
|
version "4.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
|
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
|
||||||
|
|
|
||||||
|
|
@ -2213,7 +2213,8 @@ function animateGesture(
|
||||||
keyframes: any,
|
keyframes: any,
|
||||||
targetElement: Element,
|
targetElement: Element,
|
||||||
pseudoElement: string,
|
pseudoElement: string,
|
||||||
timeline: AnimationTimeline,
|
timeline: GestureTimeline,
|
||||||
|
customTimelineCleanup: Array<() => void>,
|
||||||
rangeStart: number,
|
rangeStart: number,
|
||||||
rangeEnd: number,
|
rangeEnd: number,
|
||||||
moveFirstFrameIntoViewport: boolean,
|
moveFirstFrameIntoViewport: boolean,
|
||||||
|
|
@ -2274,6 +2275,8 @@ function animateGesture(
|
||||||
}
|
}
|
||||||
// TODO: Reverse the reverse if the original direction is reverse.
|
// TODO: Reverse the reverse if the original direction is reverse.
|
||||||
const reverse = rangeStart > rangeEnd;
|
const reverse = rangeStart > rangeEnd;
|
||||||
|
if (timeline instanceof AnimationTimeline) {
|
||||||
|
// Native Timeline
|
||||||
targetElement.animate(keyframes, {
|
targetElement.animate(keyframes, {
|
||||||
pseudoElement: pseudoElement,
|
pseudoElement: pseudoElement,
|
||||||
// Set the timeline to the current gesture timeline to drive the updates.
|
// Set the timeline to the current gesture timeline to drive the updates.
|
||||||
|
|
@ -2292,6 +2295,29 @@ function animateGesture(
|
||||||
rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
|
rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
|
||||||
rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
|
rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Custom Timeline
|
||||||
|
const animation = targetElement.animate(keyframes, {
|
||||||
|
pseudoElement: pseudoElement,
|
||||||
|
// We reset all easing functions to linear so that it feels like you
|
||||||
|
// have direct impact on the transition and to avoid double bouncing
|
||||||
|
// from scroll bouncing.
|
||||||
|
easing: 'linear',
|
||||||
|
// We fill in both direction for overscroll.
|
||||||
|
fill: 'both', // TODO: Should we preserve the fill instead?
|
||||||
|
// We play all gestures in reverse, except if we're in reverse direction
|
||||||
|
// in which case we need to play it in reverse of the reverse.
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
// Let the custom timeline take control of driving the animation.
|
||||||
|
const cleanup = timeline.animate(animation);
|
||||||
|
if (cleanup) {
|
||||||
|
customTimelineCleanup.push(cleanup);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startGestureTransition(
|
export function startGestureTransition(
|
||||||
|
|
@ -2320,6 +2346,7 @@ export function startGestureTransition(
|
||||||
});
|
});
|
||||||
// $FlowFixMe[prop-missing]
|
// $FlowFixMe[prop-missing]
|
||||||
ownerDocument.__reactViewTransition = transition;
|
ownerDocument.__reactViewTransition = transition;
|
||||||
|
const customTimelineCleanup: Array<() => void> = []; // Cleanup Animations started in a CustomTimeline
|
||||||
const readyCallback = () => {
|
const readyCallback = () => {
|
||||||
const documentElement: Element = (ownerDocument.documentElement: any);
|
const documentElement: Element = (ownerDocument.documentElement: any);
|
||||||
// Loop through all View Transition Animations.
|
// Loop through all View Transition Animations.
|
||||||
|
|
@ -2419,6 +2446,7 @@ export function startGestureTransition(
|
||||||
effect.target,
|
effect.target,
|
||||||
pseudoElement,
|
pseudoElement,
|
||||||
timeline,
|
timeline,
|
||||||
|
customTimelineCleanup,
|
||||||
adjustedRangeStart,
|
adjustedRangeStart,
|
||||||
adjustedRangeEnd,
|
adjustedRangeEnd,
|
||||||
isGeneratedGroupAnim,
|
isGeneratedGroupAnim,
|
||||||
|
|
@ -2445,6 +2473,7 @@ export function startGestureTransition(
|
||||||
effect.target,
|
effect.target,
|
||||||
pseudoElementName,
|
pseudoElementName,
|
||||||
timeline,
|
timeline,
|
||||||
|
customTimelineCleanup,
|
||||||
rangeStart,
|
rangeStart,
|
||||||
rangeEnd,
|
rangeEnd,
|
||||||
false,
|
false,
|
||||||
|
|
@ -2494,6 +2523,10 @@ export function startGestureTransition(
|
||||||
transition.ready.then(readyForAnimations, handleError);
|
transition.ready.then(readyForAnimations, handleError);
|
||||||
transition.finished.finally(() => {
|
transition.finished.finally(() => {
|
||||||
cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
|
cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
|
||||||
|
for (let i = 0; i < customTimelineCleanup.length; i++) {
|
||||||
|
const cleanup = customTimelineCleanup[i];
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
// $FlowFixMe[prop-missing]
|
// $FlowFixMe[prop-missing]
|
||||||
if (ownerDocument.__reactViewTransition === transition) {
|
if (ownerDocument.__reactViewTransition === transition) {
|
||||||
// $FlowFixMe[prop-missing]
|
// $FlowFixMe[prop-missing]
|
||||||
|
|
@ -2597,10 +2630,15 @@ export function createViewTransitionInstance(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GestureTimeline = AnimationTimeline; // TODO: More provider types.
|
interface CustomTimeline {
|
||||||
|
currentTime: number;
|
||||||
|
animate(animation: Animation): void | (() => void);
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentGestureOffset(provider: GestureTimeline): number {
|
export type GestureTimeline = AnimationTimeline | CustomTimeline;
|
||||||
const time = provider.currentTime;
|
|
||||||
|
export function getCurrentGestureOffset(timeline: GestureTimeline): number {
|
||||||
|
const time = timeline.currentTime;
|
||||||
if (time === null) {
|
if (time === null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Cannot start a gesture with a disconnected AnimationTimeline.',
|
'Cannot start a gesture with a disconnected AnimationTimeline.',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user