UNPKG

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