mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 00:20:04 +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',
|
||||
EventListenerOptionsOrUseCapture: 'readonly',
|
||||
FocusOptions: 'readonly',
|
||||
OptionalEffectTiming: 'readonly',
|
||||
|
||||
spyOnDev: '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",
|
||||
"ignore-styles": "^5.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"animation-timelines": "^0.0.4"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
|
@ -27,8 +28,8 @@
|
|||
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;",
|
||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:client": "BROWSER=none PORT=3001 react-scripts start",
|
||||
"dev:server": "NODE_ENV=development node server",
|
||||
"start": "react-scripts build && NODE_ENV=production node server",
|
||||
"dev:server": "NODE_ENV=development node --experimental-loader ./loader/server.js server",
|
||||
"start": "react-scripts build && NODE_ENV=production node --experimental-loader ./loader/server.js server",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test --env=jsdom",
|
||||
"eject": "react-scripts eject"
|
||||
|
|
|
|||
|
|
@ -20,13 +20,15 @@ if (process.env.NODE_ENV === 'development') {
|
|||
for (var key in require.cache) {
|
||||
delete require.cache[key];
|
||||
}
|
||||
const render = require('./render').default;
|
||||
render(req.url, res);
|
||||
import('./render.js').then(({default: render}) => {
|
||||
render(req.url, res);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const render = require('./render').default;
|
||||
app.get('/', function (req, res) {
|
||||
render(req.url, res);
|
||||
import('./render.js').then(({default: render}) => {
|
||||
app.get('/', function (req, res) {
|
||||
render(req.url, res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import {renderToPipeableStream} from 'react-dom/server';
|
||||
|
||||
import App from '../src/components/App';
|
||||
import App from '../src/components/App.js';
|
||||
|
||||
let assets;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import React, {
|
|||
unstable_addTransitionType as addTransitionType,
|
||||
} from 'react';
|
||||
|
||||
import Chrome from './Chrome';
|
||||
import Page from './Page';
|
||||
import Chrome from './Chrome.js';
|
||||
import Page from './Page.js';
|
||||
|
||||
const enableNavigationAPI = typeof navigation === 'object';
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import React, {
|
|||
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
import SwipeRecognizer from './SwipeRecognizer';
|
||||
import SwipeRecognizer from './SwipeRecognizer.js';
|
||||
|
||||
import './Page.css';
|
||||
|
||||
import transitions from './Transitions.module.css';
|
||||
import NestedReveal from './NestedReveal';
|
||||
import NestedReveal from './NestedReveal.js';
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import React, {
|
|||
unstable_startGestureTransition as startGestureTransition,
|
||||
} from 'react';
|
||||
|
||||
import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline';
|
||||
|
||||
// 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
|
||||
// recognizer to drive a View Transition.
|
||||
|
|
@ -25,14 +27,20 @@ export default function SwipeRecognizer({
|
|||
if (activeGesture.current !== null) {
|
||||
return;
|
||||
}
|
||||
if (typeof ScrollTimeline !== 'function') {
|
||||
return;
|
||||
|
||||
let scrollTimeline;
|
||||
if (typeof ScrollTimeline === 'function') {
|
||||
// eslint-disable-next-line no-undef
|
||||
scrollTimeline = new ScrollTimeline({
|
||||
source: scrollRef.current,
|
||||
axis: axis,
|
||||
});
|
||||
} else {
|
||||
scrollTimeline = new ScrollTimelinePolyfill({
|
||||
source: scrollRef.current,
|
||||
axis: axis,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
const scrollTimeline = new ScrollTimeline({
|
||||
source: scrollRef.current,
|
||||
axis: axis,
|
||||
});
|
||||
activeGesture.current = startGestureTransition(
|
||||
scrollTimeline,
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import {hydrateRoot} from 'react-dom/client';
|
||||
|
||||
import App from './components/App';
|
||||
import App from './components/App.js';
|
||||
|
||||
hydrateRoot(
|
||||
document,
|
||||
|
|
|
|||
|
|
@ -2427,6 +2427,11 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.9.0:
|
|||
json-schema-traverse "^1.0.0"
|
||||
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:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
|
||||
|
|
|
|||
|
|
@ -2213,7 +2213,8 @@ function animateGesture(
|
|||
keyframes: any,
|
||||
targetElement: Element,
|
||||
pseudoElement: string,
|
||||
timeline: AnimationTimeline,
|
||||
timeline: GestureTimeline,
|
||||
customTimelineCleanup: Array<() => void>,
|
||||
rangeStart: number,
|
||||
rangeEnd: number,
|
||||
moveFirstFrameIntoViewport: boolean,
|
||||
|
|
@ -2274,24 +2275,49 @@ function animateGesture(
|
|||
}
|
||||
// TODO: Reverse the reverse if the original direction is reverse.
|
||||
const reverse = rangeStart > rangeEnd;
|
||||
targetElement.animate(keyframes, {
|
||||
pseudoElement: pseudoElement,
|
||||
// Set the timeline to the current gesture timeline to drive the updates.
|
||||
timeline: timeline,
|
||||
// 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',
|
||||
// Range start needs to be higher than range end. If it goes in reverse
|
||||
// we reverse the whole animation below.
|
||||
rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
|
||||
rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
|
||||
});
|
||||
if (timeline instanceof AnimationTimeline) {
|
||||
// Native Timeline
|
||||
targetElement.animate(keyframes, {
|
||||
pseudoElement: pseudoElement,
|
||||
// Set the timeline to the current gesture timeline to drive the updates.
|
||||
timeline: timeline,
|
||||
// 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',
|
||||
// Range start needs to be higher than range end. If it goes in reverse
|
||||
// we reverse the whole animation below.
|
||||
rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
|
||||
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(
|
||||
|
|
@ -2320,6 +2346,7 @@ export function startGestureTransition(
|
|||
});
|
||||
// $FlowFixMe[prop-missing]
|
||||
ownerDocument.__reactViewTransition = transition;
|
||||
const customTimelineCleanup: Array<() => void> = []; // Cleanup Animations started in a CustomTimeline
|
||||
const readyCallback = () => {
|
||||
const documentElement: Element = (ownerDocument.documentElement: any);
|
||||
// Loop through all View Transition Animations.
|
||||
|
|
@ -2419,6 +2446,7 @@ export function startGestureTransition(
|
|||
effect.target,
|
||||
pseudoElement,
|
||||
timeline,
|
||||
customTimelineCleanup,
|
||||
adjustedRangeStart,
|
||||
adjustedRangeEnd,
|
||||
isGeneratedGroupAnim,
|
||||
|
|
@ -2445,6 +2473,7 @@ export function startGestureTransition(
|
|||
effect.target,
|
||||
pseudoElementName,
|
||||
timeline,
|
||||
customTimelineCleanup,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
false,
|
||||
|
|
@ -2494,6 +2523,10 @@ export function startGestureTransition(
|
|||
transition.ready.then(readyForAnimations, handleError);
|
||||
transition.finished.finally(() => {
|
||||
cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
|
||||
for (let i = 0; i < customTimelineCleanup.length; i++) {
|
||||
const cleanup = customTimelineCleanup[i];
|
||||
cleanup();
|
||||
}
|
||||
// $FlowFixMe[prop-missing]
|
||||
if (ownerDocument.__reactViewTransition === transition) {
|
||||
// $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 {
|
||||
const time = provider.currentTime;
|
||||
export type GestureTimeline = AnimationTimeline | CustomTimeline;
|
||||
|
||||
export function getCurrentGestureOffset(timeline: GestureTimeline): number {
|
||||
const time = timeline.currentTime;
|
||||
if (time === null) {
|
||||
throw new Error(
|
||||
'Cannot start a gesture with a disconnected AnimationTimeline.',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user