Skip to content

Commit

Permalink
Add Example of a SwipeRecognizer (#32422)
Browse files Browse the repository at this point in the history
Stacked on #32412.

To effectively `useSwipeTransition` you need something to start and stop
the gesture as well as triggering an Action.

This adds an example Gesture Recognizer to the fixture. Instead of
having this built-in to React itself, instead the idea is to leave this
to various user space Component libraries. It can be done in different
ways for different use cases. It could use JS driven or native
ScrollTimeline or both.

This example uses a native scroll with scroll snapping to two edges. If
you swipe far enough to snap to the other edge, it triggers an Action at
the end.

This particular example uses a `position: sticky` to wrap the content of
the Gesture Recognizer. This means that it's inert by itself. It doesn't
scroll its content just like a plain JS recognizer using pointer events
would. This is useful because it means that scrolling doesn't affect
content before we start (the "scroll" event fires after scrolling has
already started) so we don't have to both trying to start it earlier. It
also means that scrolling doesn't affect the live content which can lead
to unexpected effects on the View Transition.

I find the inert recognizer the most useful pairing with
`useSwipeTransition` but it's not the only way to do it. E.g. you can
also have a scrollable surface that uses plain scrolling with snapping
and then just progressively enhances swiping between steps.
  • Loading branch information
sebmarkbage authored Feb 21, 2025
1 parent 662957c commit 27ba5e8
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 38 deletions.
4 changes: 4 additions & 0 deletions fixtures/view-transition/src/components/Chrome.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
html {
touch-action: pan-x pan-y;
}

body {
margin: 10px;
padding: 0;
Expand Down
5 changes: 0 additions & 5 deletions fixtures/view-transition/src/components/Page.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@

.swipe-recognizer {
width: 200px;
overflow-x: scroll;
border: 1px solid #333333;
border-radius: 10px;
}

.swipe-overscroll {
width: 200%;
}
44 changes: 11 additions & 33 deletions fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import React, {
unstable_ViewTransition as ViewTransition,
unstable_Activity as Activity,
unstable_useSwipeTransition as useSwipeTransition,
useRef,
useLayoutEffect,
} from 'react';

import SwipeRecognizer from './SwipeRecognizer';

import './Page.css';

import transitions from './Transitions.module.css';
Expand Down Expand Up @@ -49,33 +49,10 @@ export default function Page({url, navigate}) {
viewTransition.new.animate(keyframes, 250);
}

const swipeRecognizer = useRef(null);
const activeGesture = useRef(null);
function onScroll() {
if (activeGesture.current !== null) {
return;
}
// eslint-disable-next-line no-undef
const scrollTimeline = new ScrollTimeline({
source: swipeRecognizer.current,
axis: 'x',
});
activeGesture.current = startGesture(scrollTimeline);
}
function onScrollEnd() {
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
// Reset scroll
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
function swipeAction() {
navigate(show ? '/?a' : '/?b');
}

useLayoutEffect(() => {
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
}, [show]);

const exclamation = (
<ViewTransition name="exclamation" onShare={onTransition}>
<span>!</span>
Expand Down Expand Up @@ -122,12 +99,13 @@ export default function Page({url, navigate}) {
<p></p>
<p></p>
<p></p>
<div
className="swipe-recognizer"
onScroll={onScroll}
onScrollEnd={onScrollEnd}
ref={swipeRecognizer}>
<div className="swipe-overscroll">Swipe me</div>
<div className="swipe-recognizer">
<SwipeRecognizer
action={swipeAction}
gesture={startGesture}
direction={show ? 'left' : 'right'}>
Swipe me
</SwipeRecognizer>
</div>
<p></p>
<p></p>
Expand Down
165 changes: 165 additions & 0 deletions fixtures/view-transition/src/components/SwipeRecognizer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import React, {useRef, useEffect, startTransition} from 'react';

// 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.
export default function SwipeRecognizer({
action,
children,
direction,
gesture,
}) {
if (direction == null) {
direction = 'left';
}
const axis = direction === 'left' || direction === 'right' ? 'x' : 'y';

const scrollRef = useRef(null);
const activeGesture = useRef(null);
function onScroll() {
if (activeGesture.current !== null) {
return;
}
if (typeof ScrollTimeline !== 'function') {
return;
}
// eslint-disable-next-line no-undef
const scrollTimeline = new ScrollTimeline({
source: scrollRef.current,
axis: axis,
});
activeGesture.current = gesture(scrollTimeline, {
range: [0, direction === 'left' || direction === 'up' ? 100 : 0, 100],
});
}
function onScrollEnd() {
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
let changed;
const scrollElement = scrollRef.current;
if (axis === 'x') {
const halfway =
(scrollElement.scrollWidth - scrollElement.clientWidth) / 2;
changed =
direction === 'left'
? scrollElement.scrollLeft < halfway
: scrollElement.scrollLeft > halfway;
} else {
const halfway =
(scrollElement.scrollHeight - scrollElement.clientHeight) / 2;
changed =
direction === 'up'
? scrollElement.scrollTop < halfway
: scrollElement.scrollTop > halfway;
}
// Reset scroll
if (changed) {
// Trigger side-effects
startTransition(action);
}
}

useEffect(() => {
const scrollElement = scrollRef.current;
switch (direction) {
case 'left':
scrollElement.scrollLeft =
scrollElement.scrollWidth - scrollElement.clientWidth;
break;
case 'right':
scrollElement.scrollLeft = 0;
break;
case 'up':
scrollElement.scrollTop =
scrollElement.scrollHeight - scrollElement.clientHeight;
break;
case 'down':
scrollElement.scrollTop = 0;
break;
default:
break;
}
}, [direction]);

const scrollStyle = {
position: 'relative',
padding: '0px',
margin: '0px',
border: '0px',
width: axis === 'x' ? '100%' : null,
height: axis === 'y' ? '100%' : null,
overflow: 'scroll hidden',
touchAction: 'pan-' + direction,
// Disable overscroll on Safari which moves the sticky content.
// Unfortunately, this also means that we disable chaining. We should only disable
// it if the parent is not scrollable in this axis.
overscrollBehaviorX: axis === 'x' ? 'none' : 'auto',
overscrollBehaviorY: axis === 'y' ? 'none' : 'auto',
scrollSnapType: axis + ' mandatory',
scrollbarWidth: 'none',
};

const overScrollStyle = {
position: 'relative',
padding: '0px',
margin: '0px',
border: '0px',
width: axis === 'x' ? '200%' : null,
height: axis === 'y' ? '200%' : null,
};

const snapStartStyle = {
position: 'absolute',
padding: '0px',
margin: '0px',
border: '0px',
width: axis === 'x' ? '50%' : '100%',
height: axis === 'y' ? '50%' : '100%',
left: '0px',
top: '0px',
scrollSnapAlign: 'center',
};

const snapEndStyle = {
position: 'absolute',
padding: '0px',
margin: '0px',
border: '0px',
width: axis === 'x' ? '50%' : '100%',
height: axis === 'y' ? '50%' : '100%',
right: '0px',
bottom: '0px',
scrollSnapAlign: 'center',
};

// By placing the content in a sticky box we ensure that it doesn't move when
// we scroll. Unless done so by the View Transition.
const stickyStyle = {
position: 'sticky',
padding: '0px',
margin: '0px',
border: '0px',
left: '0px',
top: '0px',
width: axis === 'x' ? '50%' : null,
height: axis === 'y' ? '50%' : null,
overflow: 'hidden',
};

return (
<div
style={scrollStyle}
onScroll={onScroll}
onScrollEnd={onScrollEnd}
ref={scrollRef}>
<div style={overScrollStyle}>
<div style={snapStartStyle} />
<div style={snapEndStyle} />
<div style={stickyStyle}>{children}</div>
</div>
</div>
);
}

0 comments on commit 27ba5e8

Please sign in to comment.