Skip to content

Commit 27ba5e8

Browse files
authored
Add Example of a SwipeRecognizer (#32422)
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.
1 parent 662957c commit 27ba5e8

File tree

4 files changed

+180
-38
lines changed

4 files changed

+180
-38
lines changed

fixtures/view-transition/src/components/Chrome.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
html {
2+
touch-action: pan-x pan-y;
3+
}
4+
15
body {
26
margin: 10px;
37
padding: 0;

fixtures/view-transition/src/components/Page.css

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@
99

1010
.swipe-recognizer {
1111
width: 200px;
12-
overflow-x: scroll;
1312
border: 1px solid #333333;
1413
border-radius: 10px;
1514
}
16-
17-
.swipe-overscroll {
18-
width: 200%;
19-
}

fixtures/view-transition/src/components/Page.js

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import React, {
22
unstable_ViewTransition as ViewTransition,
33
unstable_Activity as Activity,
44
unstable_useSwipeTransition as useSwipeTransition,
5-
useRef,
6-
useLayoutEffect,
75
} from 'react';
86

7+
import SwipeRecognizer from './SwipeRecognizer';
8+
99
import './Page.css';
1010

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

52-
const swipeRecognizer = useRef(null);
53-
const activeGesture = useRef(null);
54-
function onScroll() {
55-
if (activeGesture.current !== null) {
56-
return;
57-
}
58-
// eslint-disable-next-line no-undef
59-
const scrollTimeline = new ScrollTimeline({
60-
source: swipeRecognizer.current,
61-
axis: 'x',
62-
});
63-
activeGesture.current = startGesture(scrollTimeline);
64-
}
65-
function onScrollEnd() {
66-
if (activeGesture.current !== null) {
67-
const cancelGesture = activeGesture.current;
68-
activeGesture.current = null;
69-
cancelGesture();
70-
}
71-
// Reset scroll
72-
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
52+
function swipeAction() {
53+
navigate(show ? '/?a' : '/?b');
7354
}
7455

75-
useLayoutEffect(() => {
76-
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
77-
}, [show]);
78-
7956
const exclamation = (
8057
<ViewTransition name="exclamation" onShare={onTransition}>
8158
<span>!</span>
@@ -122,12 +99,13 @@ export default function Page({url, navigate}) {
12299
<p></p>
123100
<p></p>
124101
<p></p>
125-
<div
126-
className="swipe-recognizer"
127-
onScroll={onScroll}
128-
onScrollEnd={onScrollEnd}
129-
ref={swipeRecognizer}>
130-
<div className="swipe-overscroll">Swipe me</div>
102+
<div className="swipe-recognizer">
103+
<SwipeRecognizer
104+
action={swipeAction}
105+
gesture={startGesture}
106+
direction={show ? 'left' : 'right'}>
107+
Swipe me
108+
</SwipeRecognizer>
131109
</div>
132110
<p></p>
133111
<p></p>
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import React, {useRef, useEffect, startTransition} from 'react';
2+
3+
// Example of a Component that can recognize swipe gestures using a ScrollTimeline
4+
// without scrolling its own content. Allowing it to be used as an inert gesture
5+
// recognizer to drive a View Transition.
6+
export default function SwipeRecognizer({
7+
action,
8+
children,
9+
direction,
10+
gesture,
11+
}) {
12+
if (direction == null) {
13+
direction = 'left';
14+
}
15+
const axis = direction === 'left' || direction === 'right' ? 'x' : 'y';
16+
17+
const scrollRef = useRef(null);
18+
const activeGesture = useRef(null);
19+
function onScroll() {
20+
if (activeGesture.current !== null) {
21+
return;
22+
}
23+
if (typeof ScrollTimeline !== 'function') {
24+
return;
25+
}
26+
// eslint-disable-next-line no-undef
27+
const scrollTimeline = new ScrollTimeline({
28+
source: scrollRef.current,
29+
axis: axis,
30+
});
31+
activeGesture.current = gesture(scrollTimeline, {
32+
range: [0, direction === 'left' || direction === 'up' ? 100 : 0, 100],
33+
});
34+
}
35+
function onScrollEnd() {
36+
if (activeGesture.current !== null) {
37+
const cancelGesture = activeGesture.current;
38+
activeGesture.current = null;
39+
cancelGesture();
40+
}
41+
let changed;
42+
const scrollElement = scrollRef.current;
43+
if (axis === 'x') {
44+
const halfway =
45+
(scrollElement.scrollWidth - scrollElement.clientWidth) / 2;
46+
changed =
47+
direction === 'left'
48+
? scrollElement.scrollLeft < halfway
49+
: scrollElement.scrollLeft > halfway;
50+
} else {
51+
const halfway =
52+
(scrollElement.scrollHeight - scrollElement.clientHeight) / 2;
53+
changed =
54+
direction === 'up'
55+
? scrollElement.scrollTop < halfway
56+
: scrollElement.scrollTop > halfway;
57+
}
58+
// Reset scroll
59+
if (changed) {
60+
// Trigger side-effects
61+
startTransition(action);
62+
}
63+
}
64+
65+
useEffect(() => {
66+
const scrollElement = scrollRef.current;
67+
switch (direction) {
68+
case 'left':
69+
scrollElement.scrollLeft =
70+
scrollElement.scrollWidth - scrollElement.clientWidth;
71+
break;
72+
case 'right':
73+
scrollElement.scrollLeft = 0;
74+
break;
75+
case 'up':
76+
scrollElement.scrollTop =
77+
scrollElement.scrollHeight - scrollElement.clientHeight;
78+
break;
79+
case 'down':
80+
scrollElement.scrollTop = 0;
81+
break;
82+
default:
83+
break;
84+
}
85+
}, [direction]);
86+
87+
const scrollStyle = {
88+
position: 'relative',
89+
padding: '0px',
90+
margin: '0px',
91+
border: '0px',
92+
width: axis === 'x' ? '100%' : null,
93+
height: axis === 'y' ? '100%' : null,
94+
overflow: 'scroll hidden',
95+
touchAction: 'pan-' + direction,
96+
// Disable overscroll on Safari which moves the sticky content.
97+
// Unfortunately, this also means that we disable chaining. We should only disable
98+
// it if the parent is not scrollable in this axis.
99+
overscrollBehaviorX: axis === 'x' ? 'none' : 'auto',
100+
overscrollBehaviorY: axis === 'y' ? 'none' : 'auto',
101+
scrollSnapType: axis + ' mandatory',
102+
scrollbarWidth: 'none',
103+
};
104+
105+
const overScrollStyle = {
106+
position: 'relative',
107+
padding: '0px',
108+
margin: '0px',
109+
border: '0px',
110+
width: axis === 'x' ? '200%' : null,
111+
height: axis === 'y' ? '200%' : null,
112+
};
113+
114+
const snapStartStyle = {
115+
position: 'absolute',
116+
padding: '0px',
117+
margin: '0px',
118+
border: '0px',
119+
width: axis === 'x' ? '50%' : '100%',
120+
height: axis === 'y' ? '50%' : '100%',
121+
left: '0px',
122+
top: '0px',
123+
scrollSnapAlign: 'center',
124+
};
125+
126+
const snapEndStyle = {
127+
position: 'absolute',
128+
padding: '0px',
129+
margin: '0px',
130+
border: '0px',
131+
width: axis === 'x' ? '50%' : '100%',
132+
height: axis === 'y' ? '50%' : '100%',
133+
right: '0px',
134+
bottom: '0px',
135+
scrollSnapAlign: 'center',
136+
};
137+
138+
// By placing the content in a sticky box we ensure that it doesn't move when
139+
// we scroll. Unless done so by the View Transition.
140+
const stickyStyle = {
141+
position: 'sticky',
142+
padding: '0px',
143+
margin: '0px',
144+
border: '0px',
145+
left: '0px',
146+
top: '0px',
147+
width: axis === 'x' ? '50%' : null,
148+
height: axis === 'y' ? '50%' : null,
149+
overflow: 'hidden',
150+
};
151+
152+
return (
153+
<div
154+
style={scrollStyle}
155+
onScroll={onScroll}
156+
onScrollEnd={onScrollEnd}
157+
ref={scrollRef}>
158+
<div style={overScrollStyle}>
159+
<div style={snapStartStyle} />
160+
<div style={snapEndStyle} />
161+
<div style={stickyStyle}>{children}</div>
162+
</div>
163+
</div>
164+
);
165+
}

0 commit comments

Comments
 (0)