UNPKG

11.6 kBPlain TextView Raw
1/* global document */
2import * as React from "react";
3import {
4 AttachTouch,
5 SwipeDirections,
6 DOWN,
7 SwipeEventData,
8 HandledEvents,
9 LEFT,
10 RIGHT,
11 Setter,
12 ConfigurationOptions,
13 SwipeableDirectionCallbacks,
14 SwipeableHandlers,
15 SwipeableProps,
16 SwipeablePropsWithDefaultOptions,
17 SwipeableState,
18 SwipeCallback,
19 TapCallback,
20 UP,
21 Vector2,
22} from "./types";
23
24export {
25 LEFT,
26 RIGHT,
27 UP,
28 DOWN,
29 SwipeDirections,
30 SwipeEventData,
31 SwipeableDirectionCallbacks,
32 SwipeCallback,
33 TapCallback,
34 SwipeableHandlers,
35 SwipeableProps,
36 Vector2,
37};
38
39const defaultProps: ConfigurationOptions = {
40 delta: 10,
41 preventScrollOnSwipe: false,
42 rotationAngle: 0,
43 trackMouse: false,
44 trackTouch: true,
45 swipeDuration: Infinity,
46 touchEventOptions: { passive: true },
47};
48const initialState: SwipeableState = {
49 first: true,
50 initial: [0, 0],
51 start: 0,
52 swiping: false,
53 xy: [0, 0],
54};
55const mouseMove = "mousemove";
56const mouseUp = "mouseup";
57const touchEnd = "touchend";
58const touchMove = "touchmove";
59const touchStart = "touchstart";
60
61function getDirection(
62 absX: number,
63 absY: number,
64 deltaX: number,
65 deltaY: number
66): SwipeDirections {
67 if (absX > absY) {
68 if (deltaX > 0) {
69 return RIGHT;
70 }
71 return LEFT;
72 } else if (deltaY > 0) {
73 return DOWN;
74 }
75 return UP;
76}
77
78function rotateXYByAngle(pos: Vector2, angle: number): Vector2 {
79 if (angle === 0) return pos;
80 const angleInRadians = (Math.PI / 180) * angle;
81 const x =
82 pos[0] * Math.cos(angleInRadians) + pos[1] * Math.sin(angleInRadians);
83 const y =
84 pos[1] * Math.cos(angleInRadians) - pos[0] * Math.sin(angleInRadians);
85 return [x, y];
86}
87
88function getHandlers(
89 set: Setter,
90 handlerProps: { trackMouse: boolean | undefined }
91): [
92 {
93 ref: (element: HTMLElement | null) => void;
94 onMouseDown?: (event: React.MouseEvent) => void;
95 },
96 AttachTouch
97] {
98 const onStart = (event: HandledEvents) => {
99 const isTouch = "touches" in event;
100 // if more than a single touch don't track, for now...
101 if (isTouch && event.touches.length > 1) return;
102
103 set((state, props) => {
104 // setup mouse listeners on document to track swipe since swipe can leave container
105 if (props.trackMouse && !isTouch) {
106 document.addEventListener(mouseMove, onMove);
107 document.addEventListener(mouseUp, onUp);
108 }
109 const { clientX, clientY } = isTouch ? event.touches[0] : event;
110 const xy = rotateXYByAngle([clientX, clientY], props.rotationAngle);
111
112 props.onTouchStartOrOnMouseDown &&
113 props.onTouchStartOrOnMouseDown({ event });
114
115 return {
116 ...state,
117 ...initialState,
118 initial: xy.slice() as Vector2,
119 xy,
120 start: event.timeStamp || 0,
121 };
122 });
123 };
124
125 const onMove = (event: HandledEvents) => {
126 set((state, props) => {
127 const isTouch = "touches" in event;
128 // Discount a swipe if additional touches are present after
129 // a swipe has started.
130 if (isTouch && event.touches.length > 1) {
131 return state;
132 }
133
134 // if swipe has exceeded duration stop tracking
135 if (event.timeStamp - state.start > props.swipeDuration) {
136 return state.swiping ? { ...state, swiping: false } : state;
137 }
138
139 const { clientX, clientY } = isTouch ? event.touches[0] : event;
140 const [x, y] = rotateXYByAngle([clientX, clientY], props.rotationAngle);
141 const deltaX = x - state.xy[0];
142 const deltaY = y - state.xy[1];
143 const absX = Math.abs(deltaX);
144 const absY = Math.abs(deltaY);
145 const time = (event.timeStamp || 0) - state.start;
146 const velocity = Math.sqrt(absX * absX + absY * absY) / (time || 1);
147 const vxvy: Vector2 = [deltaX / (time || 1), deltaY / (time || 1)];
148
149 const dir = getDirection(absX, absY, deltaX, deltaY);
150
151 // if swipe is under delta and we have not started to track a swipe: skip update
152 const delta =
153 typeof props.delta === "number"
154 ? props.delta
155 : props.delta[dir.toLowerCase() as Lowercase<SwipeDirections>] ||
156 defaultProps.delta;
157 if (absX < delta && absY < delta && !state.swiping) return state;
158
159 const eventData = {
160 absX,
161 absY,
162 deltaX,
163 deltaY,
164 dir,
165 event,
166 first: state.first,
167 initial: state.initial,
168 velocity,
169 vxvy,
170 };
171
172 // call onSwipeStart if present and is first swipe event
173 eventData.first && props.onSwipeStart && props.onSwipeStart(eventData);
174
175 // call onSwiping if present
176 props.onSwiping && props.onSwiping(eventData);
177
178 // track if a swipe is cancelable (handler for swiping or swiped(dir) exists)
179 // so we can call preventDefault if needed
180 let cancelablePageSwipe = false;
181 if (
182 props.onSwiping ||
183 props.onSwiped ||
184 props[`onSwiped${dir}` as keyof SwipeableDirectionCallbacks]
185 ) {
186 cancelablePageSwipe = true;
187 }
188
189 if (
190 cancelablePageSwipe &&
191 props.preventScrollOnSwipe &&
192 props.trackTouch &&
193 event.cancelable
194 ) {
195 event.preventDefault();
196 }
197
198 return {
199 ...state,
200 // first is now always false
201 first: false,
202 eventData,
203 swiping: true,
204 };
205 });
206 };
207
208 const onEnd = (event: HandledEvents) => {
209 set((state, props) => {
210 let eventData: SwipeEventData | undefined;
211 if (state.swiping && state.eventData) {
212 // if swipe is less than duration fire swiped callbacks
213 if (event.timeStamp - state.start < props.swipeDuration) {
214 eventData = { ...state.eventData, event };
215 props.onSwiped && props.onSwiped(eventData);
216
217 const onSwipedDir =
218 props[
219 `onSwiped${eventData.dir}` as keyof SwipeableDirectionCallbacks
220 ];
221 onSwipedDir && onSwipedDir(eventData);
222 }
223 } else {
224 props.onTap && props.onTap({ event });
225 }
226
227 props.onTouchEndOrOnMouseUp && props.onTouchEndOrOnMouseUp({ event });
228
229 return { ...state, ...initialState, eventData };
230 });
231 };
232
233 const cleanUpMouse = () => {
234 // safe to just call removeEventListener
235 document.removeEventListener(mouseMove, onMove);
236 document.removeEventListener(mouseUp, onUp);
237 };
238
239 const onUp = (e: HandledEvents) => {
240 cleanUpMouse();
241 onEnd(e);
242 };
243
244 /**
245 * The value of passive on touchMove depends on `preventScrollOnSwipe`:
246 * - true => { passive: false }
247 * - false => { passive: true } // Default
248 *
249 * NOTE: When preventScrollOnSwipe is true, we attempt to call preventDefault to prevent scroll.
250 *
251 * props.touchEventOptions can also be set for all touch event listeners,
252 * but for `touchmove` specifically when `preventScrollOnSwipe` it will
253 * supersede and force passive to false.
254 *
255 */
256 const attachTouch: AttachTouch = (el, props) => {
257 let cleanup = () => {};
258 if (el && el.addEventListener) {
259 const baseOptions = {
260 ...defaultProps.touchEventOptions,
261 ...props.touchEventOptions,
262 };
263 // attach touch event listeners and handlers
264 const tls: [
265 typeof touchStart | typeof touchMove | typeof touchEnd,
266 (e: HandledEvents) => void,
267 { passive: boolean }
268 ][] = [
269 [touchStart, onStart, baseOptions],
270 // preventScrollOnSwipe option supersedes touchEventOptions.passive
271 [
272 touchMove,
273 onMove,
274 {
275 ...baseOptions,
276 ...(props.preventScrollOnSwipe ? { passive: false } : {}),
277 },
278 ],
279 [touchEnd, onEnd, baseOptions],
280 ];
281 tls.forEach(([e, h, o]) => el.addEventListener(e, h, o));
282 // return properly scoped cleanup method for removing listeners, options not required
283 cleanup = () => tls.forEach(([e, h]) => el.removeEventListener(e, h));
284 }
285 return cleanup;
286 };
287
288 const onRef = (el: HTMLElement | null) => {
289 // "inline" ref functions are called twice on render, once with null then again with DOM element
290 // ignore null here
291 if (el === null) return;
292 set((state, props) => {
293 // if the same DOM el as previous just return state
294 if (state.el === el) return state;
295
296 const addState: { cleanUpTouch?: () => void } = {};
297 // if new DOM el clean up old DOM and reset cleanUpTouch
298 if (state.el && state.el !== el && state.cleanUpTouch) {
299 state.cleanUpTouch();
300 addState.cleanUpTouch = void 0;
301 }
302 // only attach if we want to track touch
303 if (props.trackTouch && el) {
304 addState.cleanUpTouch = attachTouch(el, props);
305 }
306
307 // store event attached DOM el for comparison, clean up, and re-attachment
308 return { ...state, el, ...addState };
309 });
310 };
311
312 // set ref callback to attach touch event listeners
313 const output: { ref: typeof onRef; onMouseDown?: typeof onStart } = {
314 ref: onRef,
315 };
316
317 // if track mouse attach mouse down listener
318 if (handlerProps.trackMouse) {
319 output.onMouseDown = onStart;
320 }
321
322 return [output, attachTouch];
323}
324
325function updateTransientState(
326 state: SwipeableState,
327 props: SwipeablePropsWithDefaultOptions,
328 previousProps: SwipeablePropsWithDefaultOptions,
329 attachTouch: AttachTouch
330) {
331 // if trackTouch is off or there is no el, then remove handlers if necessary and exit
332 if (!props.trackTouch || !state.el) {
333 if (state.cleanUpTouch) {
334 state.cleanUpTouch();
335 }
336
337 return {
338 ...state,
339 cleanUpTouch: undefined,
340 };
341 }
342
343 // trackTouch is on, so if there are no handlers attached, attach them and exit
344 if (!state.cleanUpTouch) {
345 return {
346 ...state,
347 cleanUpTouch: attachTouch(state.el, props),
348 };
349 }
350
351 // trackTouch is on and handlers are already attached, so if preventScrollOnSwipe changes value,
352 // remove and reattach handlers (this is required to update the passive option when attaching
353 // the handlers)
354 if (
355 props.preventScrollOnSwipe !== previousProps.preventScrollOnSwipe ||
356 props.touchEventOptions.passive !== previousProps.touchEventOptions.passive
357 ) {
358 state.cleanUpTouch();
359
360 return {
361 ...state,
362 cleanUpTouch: attachTouch(state.el, props),
363 };
364 }
365
366 return state;
367}
368
369export function useSwipeable(options: SwipeableProps): SwipeableHandlers {
370 const { trackMouse } = options;
371 const transientState = React.useRef({ ...initialState });
372 const transientProps = React.useRef<SwipeablePropsWithDefaultOptions>({
373 ...defaultProps,
374 });
375
376 // track previous rendered props
377 const previousProps = React.useRef<SwipeablePropsWithDefaultOptions>({
378 ...transientProps.current,
379 });
380 previousProps.current = { ...transientProps.current };
381
382 // update current render props & defaults
383 transientProps.current = {
384 ...defaultProps,
385 ...options,
386 };
387 // Force defaults for config properties
388 let defaultKey: keyof ConfigurationOptions;
389 for (defaultKey in defaultProps) {
390 if (transientProps.current[defaultKey] === void 0) {
391 (transientProps.current[defaultKey] as any) = defaultProps[defaultKey];
392 }
393 }
394
395 const [handlers, attachTouch] = React.useMemo(
396 () =>
397 getHandlers(
398 (stateSetter) =>
399 (transientState.current = stateSetter(
400 transientState.current,
401 transientProps.current
402 )),
403 { trackMouse }
404 ),
405 [trackMouse]
406 );
407
408 transientState.current = updateTransientState(
409 transientState.current,
410 transientProps.current,
411 previousProps.current,
412 attachTouch
413 );
414
415 return handlers;
416}