main
js 256 lines 6.99 KB
Raw
1 import React, {
2 useRef,
3 useEffect,
4 startTransition,
5 unstable_startGestureTransition as startGestureTransition,
6 } from 'react';
7
8 import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline';
9 import TouchPanTimeline from 'animation-timelines/touch-pan-timeline';
10
11 const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent;
12 const isSafariMobile =
13 ua.indexOf('Safari') !== -1 &&
14 (ua.indexOf('iPhone') !== -1 ||
15 ua.indexOf('iPad') !== -1 ||
16 ua.indexOf('iPod') !== -1);
17
18 // Example of a Component that can recognize swipe gestures using a ScrollTimeline
19 // without scrolling its own content. Allowing it to be used as an inert gesture
20 // recognizer to drive a View Transition.
21 export default function SwipeRecognizer({
22 action,
23 children,
24 direction,
25 gesture,
26 }) {
27 if (direction == null) {
28 direction = 'left';
29 }
30 const axis = direction === 'left' || direction === 'right' ? 'x' : 'y';
31
32 const scrollRef = useRef(null);
33 const activeGesture = useRef(null);
34 const touchTimeline = useRef(null);
35
36 function onTouchStart(event) {
37 if (!isSafariMobile && typeof ScrollTimeline === 'function') {
38 // If not Safari and native ScrollTimeline is supported, then we use that.
39 return;
40 }
41 if (touchTimeline.current) {
42 // We can catch the gesture before it settles.
43 return;
44 }
45 const scrollElement = scrollRef.current;
46 const bounds =
47 axis === 'x' ? scrollElement.clientWidth : scrollElement.clientHeight;
48 const range =
49 direction === 'left' || direction === 'up' ? [bounds, 0] : [0, -bounds];
50 const timeline = new TouchPanTimeline({
51 touch: event,
52 source: scrollElement,
53 axis: axis,
54 range: range,
55 snap: range,
56 });
57 touchTimeline.current = timeline;
58 timeline.settled.then(() => {
59 if (touchTimeline.current !== timeline) {
60 return;
61 }
62 touchTimeline.current = null;
63 const changed =
64 direction === 'left' || direction === 'up'
65 ? timeline.currentTime < 50
66 : timeline.currentTime > 50;
67 onGestureEnd(changed);
68 });
69 }
70
71 function onTouchEnd() {
72 if (activeGesture.current === null) {
73 // If we didn't start a gesture before we release, we can release our
74 // timeline.
75 touchTimeline.current = null;
76 }
77 }
78
79 function onScroll() {
80 if (activeGesture.current !== null) {
81 return;
82 }
83
84 let scrollTimeline;
85 if (touchTimeline.current) {
86 // We're in a polyfilled touch gesture. Let's use that timeline instead.
87 scrollTimeline = touchTimeline.current;
88 } else if (typeof ScrollTimeline === 'function') {
89 // eslint-disable-next-line no-undef
90 scrollTimeline = new ScrollTimeline({
91 source: scrollRef.current,
92 axis: axis,
93 });
94 } else {
95 scrollTimeline = new ScrollTimelinePolyfill({
96 source: scrollRef.current,
97 axis: axis,
98 });
99 }
100 activeGesture.current = startGestureTransition(
101 scrollTimeline,
102 () => {
103 gesture(direction);
104 },
105 direction === 'left' || direction === 'up'
106 ? {
107 rangeStart: 100,
108 rangeEnd: 0,
109 }
110 : {
111 rangeStart: 0,
112 rangeEnd: 100,
113 }
114 );
115 }
116 function onGestureEnd(changed) {
117 // We cancel the gesture before invoking side-effects to allow the gesture lane to fully commit
118 // before scheduling new updates.
119 if (activeGesture.current !== null) {
120 const cancelGesture = activeGesture.current;
121 activeGesture.current = null;
122 cancelGesture();
123 }
124 if (changed) {
125 // Trigger side-effects
126 startTransition(action);
127 }
128 }
129 function onScrollEnd() {
130 if (touchTimeline.current) {
131 // We have a touch gesture controlling the swipe.
132 return;
133 }
134 let changed;
135 const scrollElement = scrollRef.current;
136 if (axis === 'x') {
137 const halfway =
138 (scrollElement.scrollWidth - scrollElement.clientWidth) / 2;
139 changed =
140 direction === 'left'
141 ? scrollElement.scrollLeft < halfway
142 : scrollElement.scrollLeft > halfway;
143 } else {
144 const halfway =
145 (scrollElement.scrollHeight - scrollElement.clientHeight) / 2;
146 changed =
147 direction === 'up'
148 ? scrollElement.scrollTop < halfway
149 : scrollElement.scrollTop > halfway;
150 }
151 onGestureEnd(changed);
152 }
153
154 useEffect(() => {
155 const scrollElement = scrollRef.current;
156 switch (direction) {
157 case 'left':
158 scrollElement.scrollLeft =
159 scrollElement.scrollWidth - scrollElement.clientWidth;
160 break;
161 case 'right':
162 scrollElement.scrollLeft = 0;
163 break;
164 case 'up':
165 scrollElement.scrollTop =
166 scrollElement.scrollHeight - scrollElement.clientHeight;
167 break;
168 case 'down':
169 scrollElement.scrollTop = 0;
170 break;
171 default:
172 break;
173 }
174 }, [direction]);
175
176 const scrollStyle = {
177 position: 'relative',
178 padding: '0px',
179 margin: '0px',
180 border: '0px',
181 width: axis === 'x' ? '100%' : null,
182 height: axis === 'y' ? '100%' : null,
183 overflow: 'scroll hidden',
184 // Disable overscroll on Safari which moves the sticky content.
185 // Unfortunately, this also means that we disable chaining. We should only disable
186 // it if the parent is not scrollable in this axis.
187 overscrollBehaviorX: axis === 'x' ? 'none' : 'auto',
188 overscrollBehaviorY: axis === 'y' ? 'none' : 'auto',
189 scrollSnapType: axis + ' mandatory',
190 scrollbarWidth: 'none',
191 };
192
193 const overScrollStyle = {
194 position: 'relative',
195 padding: '0px',
196 margin: '0px',
197 border: '0px',
198 width: axis === 'x' ? '200%' : null,
199 height: axis === 'y' ? '200%' : null,
200 };
201
202 const snapStartStyle = {
203 position: 'absolute',
204 padding: '0px',
205 margin: '0px',
206 border: '0px',
207 width: axis === 'x' ? '50%' : '100%',
208 height: axis === 'y' ? '50%' : '100%',
209 left: '0px',
210 top: '0px',
211 scrollSnapAlign: 'center',
212 };
213
214 const snapEndStyle = {
215 position: 'absolute',
216 padding: '0px',
217 margin: '0px',
218 border: '0px',
219 width: axis === 'x' ? '50%' : '100%',
220 height: axis === 'y' ? '50%' : '100%',
221 right: '0px',
222 bottom: '0px',
223 scrollSnapAlign: 'center',
224 };
225
226 // By placing the content in a sticky box we ensure that it doesn't move when
227 // we scroll. Unless done so by the View Transition.
228 const stickyStyle = {
229 position: 'sticky',
230 padding: '0px',
231 margin: '0px',
232 border: '0px',
233 left: '0px',
234 top: '0px',
235 width: axis === 'x' ? '50%' : null,
236 height: axis === 'y' ? '50%' : null,
237 overflow: 'hidden',
238 };
239
240 return (
241 <div
242 style={scrollStyle}
243 onTouchStart={onTouchStart}
244 onTouchEnd={onTouchEnd}
245 onTouchCancel={onTouchEnd}
246 onScroll={onScroll}
247 onScrollEnd={onScrollEnd}
248 ref={scrollRef}>
249 <div style={overScrollStyle}>
250 <div style={snapStartStyle} />
251 <div style={snapEndStyle} />
252 <div style={stickyStyle}>{children}</div>
253 </div>
254 </div>
255 );
256 }