UNPKG

9.15 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 SwipeableCallbacks,
13 SwipeableHandlers,
14 SwipeableProps,
15 SwipeablePropsWithDefaultOptions,
16 SwipeableState,
17 SwipeCallback,
18 TapCallback,
19 UP,
20 Vector2,
21} from "./types";
22
23export {
24 LEFT,
25 RIGHT,
26 UP,
27 DOWN,
28 SwipeDirections,
29 SwipeEventData,
30 SwipeCallback,
31 TapCallback,
32 SwipeableHandlers,
33 SwipeableProps,
34 Vector2,
35};
36
37const defaultProps = {
38 delta: 10,
39 preventDefaultTouchmoveEvent: false,
40 rotationAngle: 0,
41 trackMouse: false,
42 trackTouch: true,
43};
44const initialState: SwipeableState = {
45 first: true,
46 initial: [0, 0],
47 start: 0,
48 swiping: false,
49 xy: [0, 0],
50};
51const mouseMove = "mousemove";
52const mouseUp = "mouseup";
53const touchEnd = "touchend";
54const touchMove = "touchmove";
55const touchStart = "touchstart";
56
57function getDirection(
58 absX: number,
59 absY: number,
60 deltaX: number,
61 deltaY: number
62): SwipeDirections {
63 if (absX > absY) {
64 if (deltaX > 0) {
65 return RIGHT;
66 }
67 return LEFT;
68 } else if (deltaY > 0) {
69 return DOWN;
70 }
71 return UP;
72}
73
74function rotateXYByAngle(pos: Vector2, angle: number): Vector2 {
75 if (angle === 0) return pos;
76 const angleInRadians = (Math.PI / 180) * angle;
77 const x =
78 pos[0] * Math.cos(angleInRadians) + pos[1] * Math.sin(angleInRadians);
79 const y =
80 pos[1] * Math.cos(angleInRadians) - pos[0] * Math.sin(angleInRadians);
81 return [x, y];
82}
83
84function getHandlers(
85 set: Setter,
86 handlerProps: { trackMouse: boolean | undefined }
87): [
88 {
89 ref: (element: HTMLElement | null) => void;
90 onMouseDown?: (event: React.MouseEvent) => void;
91 },
92 AttachTouch
93] {
94 const onStart = (event: HandledEvents) => {
95 // if more than a single touch don't track, for now...
96 if (event && "touches" in event && event.touches.length > 1) return;
97
98 set((state, props) => {
99 // setup mouse listeners on document to track swipe since swipe can leave container
100 if (props.trackMouse) {
101 document.addEventListener(mouseMove, onMove);
102 document.addEventListener(mouseUp, onUp);
103 }
104 const { clientX, clientY } =
105 "touches" in event ? event.touches[0] : event;
106 const xy = rotateXYByAngle([clientX, clientY], props.rotationAngle);
107 return {
108 ...state,
109 ...initialState,
110 initial: [...xy],
111 xy,
112 start: event.timeStamp || 0,
113 };
114 });
115 };
116
117 const onMove = (event: HandledEvents) => {
118 set((state, props) => {
119 // Discount a swipe if additional touches are present after
120 // a swipe has started.
121 if ("touches" in event && event.touches.length > 1) {
122 return state;
123 }
124 const { clientX, clientY } =
125 "touches" in event ? event.touches[0] : event;
126 const [x, y] = rotateXYByAngle([clientX, clientY], props.rotationAngle);
127 const deltaX = x - state.xy[0];
128 const deltaY = y - state.xy[1];
129 const absX = Math.abs(deltaX);
130 const absY = Math.abs(deltaY);
131 const time = (event.timeStamp || 0) - state.start;
132 const velocity = Math.sqrt(absX * absX + absY * absY) / (time || 1);
133 const vxvy: Vector2 = [deltaX / (time || 1), deltaY / (time || 1)];
134
135 const dir = getDirection(absX, absY, deltaX, deltaY);
136
137 // if swipe is under delta and we have not started to track a swipe: skip update
138 const delta =
139 typeof props.delta === "number"
140 ? props.delta
141 : props.delta[dir.toLowerCase() as Lowercase<SwipeDirections>] ||
142 defaultProps.delta;
143 if (absX < delta && absY < delta && !state.swiping) return state;
144
145 const eventData = {
146 absX,
147 absY,
148 deltaX,
149 deltaY,
150 dir,
151 event,
152 first: state.first,
153 initial: state.initial,
154 velocity,
155 vxvy,
156 };
157
158 // call onSwipeStart if present and is first swipe event
159 eventData.first && props.onSwipeStart && props.onSwipeStart(eventData);
160
161 // Call onSwiping if present
162 props.onSwiping && props.onSwiping(eventData);
163
164 // track if a swipe is cancelable(handler for swiping or swiped(dir) exists)
165 // so we can call preventDefault if needed
166 let cancelablePageSwipe = false;
167 if (props.onSwiping || props.onSwiped || `onSwiped${dir}` in props) {
168 cancelablePageSwipe = true;
169 }
170
171 if (
172 cancelablePageSwipe &&
173 props.preventDefaultTouchmoveEvent &&
174 props.trackTouch &&
175 event.cancelable
176 )
177 event.preventDefault();
178
179 return {
180 ...state,
181 // first is now always false
182 first: false,
183 eventData,
184 swiping: true,
185 };
186 });
187 };
188
189 const onEnd = (event: HandledEvents) => {
190 set((state, props) => {
191 let eventData: SwipeEventData | undefined;
192 if (state.swiping && state.eventData) {
193 eventData = { ...state.eventData, event };
194 props.onSwiped && props.onSwiped(eventData);
195
196 const onSwipedDir =
197 props[`onSwiped${eventData.dir}` as keyof SwipeableCallbacks];
198 onSwipedDir && onSwipedDir(eventData);
199 } else {
200 props.onTap && props.onTap({ event });
201 }
202 return { ...state, ...initialState, eventData };
203 });
204 };
205
206 const cleanUpMouse = () => {
207 // safe to just call removeEventListener
208 document.removeEventListener(mouseMove, onMove);
209 document.removeEventListener(mouseUp, onUp);
210 };
211
212 const onUp = (e: HandledEvents) => {
213 cleanUpMouse();
214 onEnd(e);
215 };
216
217 /**
218 * Switch of "passive" property for now.
219 * When `preventDefaultTouchmoveEvent` is:
220 * - true => { passive: false }
221 * - false => { passive: true }
222 *
223 * Could take entire `addEventListener` options object as a param later?
224 */
225 const attachTouch: AttachTouch = (el, passive) => {
226 let cleanup = () => {};
227 if (el && el.addEventListener) {
228 // attach touch event listeners and handlers
229 const tls: [
230 typeof touchStart | typeof touchMove | typeof touchEnd,
231 (e: HandledEvents) => void
232 ][] = [
233 [touchStart, onStart],
234 [touchMove, onMove],
235 [touchEnd, onEnd],
236 ];
237 tls.forEach(([e, h]) => el.addEventListener(e, h, { passive }));
238 // return properly scoped cleanup method for removing listeners, options not required
239 cleanup = () => tls.forEach(([e, h]) => el.removeEventListener(e, h));
240 }
241 return cleanup;
242 };
243
244 const onRef = (el: HTMLElement | null) => {
245 // "inline" ref functions are called twice on render, once with null then again with DOM element
246 // ignore null here
247 if (el === null) return;
248 set((state, props) => {
249 // if the same DOM el as previous just return state
250 if (state.el === el) return state;
251
252 const addState: { cleanUpTouch?: () => void } = {};
253 // if new DOM el clean up old DOM and reset cleanUpTouch
254 if (state.el && state.el !== el && state.cleanUpTouch) {
255 state.cleanUpTouch();
256 addState.cleanUpTouch = undefined;
257 }
258 // only attach if we want to track touch
259 if (props.trackTouch && el) {
260 addState.cleanUpTouch = attachTouch(
261 el,
262 !props.preventDefaultTouchmoveEvent
263 );
264 }
265
266 // store event attached DOM el for comparison, clean up, and re-attachment
267 return { ...state, el, ...addState };
268 });
269 };
270
271 // set ref callback to attach touch event listeners
272 const output: { ref: typeof onRef; onMouseDown?: typeof onStart } = {
273 ref: onRef,
274 };
275
276 // if track mouse attach mouse down listener
277 if (handlerProps.trackMouse) {
278 output.onMouseDown = onStart;
279 }
280
281 return [output, attachTouch];
282}
283
284function updateTransientState(
285 state: SwipeableState,
286 props: SwipeableProps,
287 attachTouch: AttachTouch
288) {
289 const addState: { cleanUpTouch?(): void } = {};
290 // clean up touch handlers if no longer tracking touches
291 if (!props.trackTouch && state.cleanUpTouch) {
292 state.cleanUpTouch();
293 addState.cleanUpTouch = undefined;
294 } else if (props.trackTouch && !state.cleanUpTouch) {
295 // attach/re-attach touch handlers
296 if (state.el) {
297 addState.cleanUpTouch = attachTouch(
298 state.el,
299 !props.preventDefaultTouchmoveEvent
300 );
301 }
302 }
303 return { ...state, ...addState };
304}
305
306export function useSwipeable(options: SwipeableProps): SwipeableHandlers {
307 const { trackMouse } = options;
308 const transientState = React.useRef({ ...initialState });
309 const transientProps = React.useRef<SwipeablePropsWithDefaultOptions>({
310 ...defaultProps,
311 });
312 transientProps.current = { ...defaultProps, ...options };
313
314 const [handlers, attachTouch] = React.useMemo(
315 () =>
316 getHandlers(
317 (stateSetter) =>
318 (transientState.current = stateSetter(
319 transientState.current,
320 transientProps.current
321 )),
322 { trackMouse }
323 ),
324 [trackMouse]
325 );
326
327 transientState.current = updateTransientState(
328 transientState.current,
329 transientProps.current,
330 attachTouch
331 );
332
333 return handlers;
334}