UNPKG

19.9 kBJavaScriptView Raw
1import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
2import _extends from "@babel/runtime/helpers/esm/extends";
3import * as React from 'react';
4import PropTypes from 'prop-types';
5import * as ReactDOM from 'react-dom';
6import { elementTypeAcceptingRef } from '@material-ui/utils';
7import { getThemeProps } from '@material-ui/styles';
8import Drawer, { getAnchor, isHorizontal } from '../Drawer/Drawer';
9import ownerDocument from '../utils/ownerDocument';
10import useEventCallback from '../utils/useEventCallback';
11import { duration } from '../styles/transitions';
12import useTheme from '../styles/useTheme';
13import { getTransitionProps } from '../transitions/utils';
14import NoSsr from '../NoSsr';
15import SwipeArea from './SwipeArea'; // This value is closed to what browsers are using internally to
16// trigger a native scroll.
17
18const UNCERTAINTY_THRESHOLD = 3; // px
19// We can only have one node at the time claiming ownership for handling the swipe.
20// Otherwise, the UX would be confusing.
21// That's why we use a singleton here.
22
23let nodeThatClaimedTheSwipe = null; // Exported for test purposes.
24
25export function reset() {
26 nodeThatClaimedTheSwipe = null;
27}
28
29function calculateCurrentX(anchor, touches) {
30 return anchor === 'right' ? document.body.offsetWidth - touches[0].pageX : touches[0].pageX;
31}
32
33function calculateCurrentY(anchor, touches) {
34 return anchor === 'bottom' ? window.innerHeight - touches[0].clientY : touches[0].clientY;
35}
36
37function getMaxTranslate(horizontalSwipe, paperInstance) {
38 return horizontalSwipe ? paperInstance.clientWidth : paperInstance.clientHeight;
39}
40
41function getTranslate(currentTranslate, startLocation, open, maxTranslate) {
42 return Math.min(Math.max(open ? startLocation - currentTranslate : maxTranslate + startLocation - currentTranslate, 0), maxTranslate);
43}
44
45function getDomTreeShapes(element, rootNode) {
46 // Adapted from https://github.com/oliviertassinari/react-swipeable-views/blob/7666de1dba253b896911adf2790ce51467670856/packages/react-swipeable-views/src/SwipeableViews.js#L129
47 let domTreeShapes = [];
48
49 while (element && element !== rootNode) {
50 const style = window.getComputedStyle(element);
51
52 if ( // Ignore the scroll children if the element is absolute positioned.
53 style.getPropertyValue('position') === 'absolute' || // Ignore the scroll children if the element has an overflowX hidden
54 style.getPropertyValue('overflow-x') === 'hidden') {
55 domTreeShapes = [];
56 } else if (element.clientWidth > 0 && element.scrollWidth > element.clientWidth || element.clientHeight > 0 && element.scrollHeight > element.clientHeight) {
57 // Ignore the nodes that have no width.
58 // Keep elements with a scroll
59 domTreeShapes.push(element);
60 }
61
62 element = element.parentElement;
63 }
64
65 return domTreeShapes;
66}
67
68function findNativeHandler({
69 domTreeShapes,
70 start,
71 current,
72 anchor
73}) {
74 // Adapted from https://github.com/oliviertassinari/react-swipeable-views/blob/7666de1dba253b896911adf2790ce51467670856/packages/react-swipeable-views/src/SwipeableViews.js#L175
75 const axisProperties = {
76 scrollPosition: {
77 x: 'scrollLeft',
78 y: 'scrollTop'
79 },
80 scrollLength: {
81 x: 'scrollWidth',
82 y: 'scrollHeight'
83 },
84 clientLength: {
85 x: 'clientWidth',
86 y: 'clientHeight'
87 }
88 };
89 return domTreeShapes.some(shape => {
90 // Determine if we are going backward or forward.
91 let goingForward = current >= start;
92
93 if (anchor === 'top' || anchor === 'left') {
94 goingForward = !goingForward;
95 }
96
97 const axis = anchor === 'left' || anchor === 'right' ? 'x' : 'y';
98 const scrollPosition = shape[axisProperties.scrollPosition[axis]];
99 const areNotAtStart = scrollPosition > 0;
100 const areNotAtEnd = scrollPosition + shape[axisProperties.clientLength[axis]] < shape[axisProperties.scrollLength[axis]];
101
102 if (goingForward && areNotAtEnd || !goingForward && areNotAtStart) {
103 return shape;
104 }
105
106 return null;
107 });
108}
109
110const iOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
111const transitionDurationDefault = {
112 enter: duration.enteringScreen,
113 exit: duration.leavingScreen
114};
115const useEnhancedEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
116const SwipeableDrawer = /*#__PURE__*/React.forwardRef(function SwipeableDrawer(inProps, ref) {
117 const theme = useTheme();
118 const props = getThemeProps({
119 name: 'MuiSwipeableDrawer',
120 props: _extends({}, inProps),
121 theme
122 });
123
124 const {
125 anchor = 'left',
126 disableBackdropTransition = false,
127 disableDiscovery = false,
128 disableSwipeToOpen = iOS,
129 hideBackdrop,
130 hysteresis = 0.52,
131 minFlingVelocity = 450,
132 ModalProps: {
133 BackdropProps
134 } = {},
135 onClose,
136 onOpen,
137 open,
138 PaperProps = {},
139 SwipeAreaProps,
140 swipeAreaWidth = 20,
141 transitionDuration = transitionDurationDefault,
142 variant = 'temporary'
143 } = props,
144 ModalPropsProp = _objectWithoutPropertiesLoose(props.ModalProps, ["BackdropProps"]),
145 other = _objectWithoutPropertiesLoose(props, ["anchor", "disableBackdropTransition", "disableDiscovery", "disableSwipeToOpen", "hideBackdrop", "hysteresis", "minFlingVelocity", "ModalProps", "onClose", "onOpen", "open", "PaperProps", "SwipeAreaProps", "swipeAreaWidth", "transitionDuration", "variant"]);
146
147 const [maybeSwiping, setMaybeSwiping] = React.useState(false);
148 const swipeInstance = React.useRef({
149 isSwiping: null
150 });
151 const swipeAreaRef = React.useRef();
152 const backdropRef = React.useRef();
153 const paperRef = React.useRef();
154 const touchDetected = React.useRef(false); // Ref for transition duration based on / to match swipe speed
155
156 const calculatedDurationRef = React.useRef(); // Use a ref so the open value used is always up to date inside useCallback.
157
158 useEnhancedEffect(() => {
159 calculatedDurationRef.current = null;
160 }, [open]);
161 const setPosition = React.useCallback((translate, options = {}) => {
162 const {
163 mode = null,
164 changeTransition = true
165 } = options;
166 const anchorRtl = getAnchor(theme, anchor);
167 const rtlTranslateMultiplier = ['right', 'bottom'].indexOf(anchorRtl) !== -1 ? 1 : -1;
168 const horizontalSwipe = isHorizontal(anchor);
169 const transform = horizontalSwipe ? `translate(${rtlTranslateMultiplier * translate}px, 0)` : `translate(0, ${rtlTranslateMultiplier * translate}px)`;
170 const drawerStyle = paperRef.current.style;
171 drawerStyle.webkitTransform = transform;
172 drawerStyle.transform = transform;
173 let transition = '';
174
175 if (mode) {
176 transition = theme.transitions.create('all', getTransitionProps({
177 timeout: transitionDuration
178 }, {
179 mode
180 }));
181 }
182
183 if (changeTransition) {
184 drawerStyle.webkitTransition = transition;
185 drawerStyle.transition = transition;
186 }
187
188 if (!disableBackdropTransition && !hideBackdrop) {
189 const backdropStyle = backdropRef.current.style;
190 backdropStyle.opacity = 1 - translate / getMaxTranslate(horizontalSwipe, paperRef.current);
191
192 if (changeTransition) {
193 backdropStyle.webkitTransition = transition;
194 backdropStyle.transition = transition;
195 }
196 }
197 }, [anchor, disableBackdropTransition, hideBackdrop, theme, transitionDuration]);
198 const handleBodyTouchEnd = useEventCallback(event => {
199 if (!touchDetected.current) {
200 return;
201 }
202
203 nodeThatClaimedTheSwipe = null;
204 touchDetected.current = false;
205 setMaybeSwiping(false); // The swipe wasn't started.
206
207 if (!swipeInstance.current.isSwiping) {
208 swipeInstance.current.isSwiping = null;
209 return;
210 }
211
212 swipeInstance.current.isSwiping = null;
213 const anchorRtl = getAnchor(theme, anchor);
214 const horizontal = isHorizontal(anchor);
215 let current;
216
217 if (horizontal) {
218 current = calculateCurrentX(anchorRtl, event.changedTouches);
219 } else {
220 current = calculateCurrentY(anchorRtl, event.changedTouches);
221 }
222
223 const startLocation = horizontal ? swipeInstance.current.startX : swipeInstance.current.startY;
224 const maxTranslate = getMaxTranslate(horizontal, paperRef.current);
225 const currentTranslate = getTranslate(current, startLocation, open, maxTranslate);
226 const translateRatio = currentTranslate / maxTranslate;
227
228 if (Math.abs(swipeInstance.current.velocity) > minFlingVelocity) {
229 // Calculate transition duration to match swipe speed
230 calculatedDurationRef.current = Math.abs((maxTranslate - currentTranslate) / swipeInstance.current.velocity) * 1000;
231 }
232
233 if (open) {
234 if (swipeInstance.current.velocity > minFlingVelocity || translateRatio > hysteresis) {
235 onClose();
236 } else {
237 // Reset the position, the swipe was aborted.
238 setPosition(0, {
239 mode: 'exit'
240 });
241 }
242
243 return;
244 }
245
246 if (swipeInstance.current.velocity < -minFlingVelocity || 1 - translateRatio > hysteresis) {
247 onOpen();
248 } else {
249 // Reset the position, the swipe was aborted.
250 setPosition(getMaxTranslate(horizontal, paperRef.current), {
251 mode: 'enter'
252 });
253 }
254 });
255 const handleBodyTouchMove = useEventCallback(event => {
256 // the ref may be null when a parent component updates while swiping
257 if (!paperRef.current || !touchDetected.current) {
258 return;
259 } // We are not supposed to handle this touch move because the swipe was started in a scrollable container in the drawer
260
261
262 if (nodeThatClaimedTheSwipe != null && nodeThatClaimedTheSwipe !== swipeInstance.current) {
263 return;
264 }
265
266 const anchorRtl = getAnchor(theme, anchor);
267 const horizontalSwipe = isHorizontal(anchor);
268 const currentX = calculateCurrentX(anchorRtl, event.touches);
269 const currentY = calculateCurrentY(anchorRtl, event.touches);
270
271 if (open && paperRef.current.contains(event.target) && nodeThatClaimedTheSwipe == null) {
272 const domTreeShapes = getDomTreeShapes(event.target, paperRef.current);
273 const nativeHandler = findNativeHandler({
274 domTreeShapes,
275 start: horizontalSwipe ? swipeInstance.current.startX : swipeInstance.current.startY,
276 current: horizontalSwipe ? currentX : currentY,
277 anchor
278 });
279
280 if (nativeHandler) {
281 nodeThatClaimedTheSwipe = nativeHandler;
282 return;
283 }
284
285 nodeThatClaimedTheSwipe = swipeInstance.current;
286 } // We don't know yet.
287
288
289 if (swipeInstance.current.isSwiping == null) {
290 const dx = Math.abs(currentX - swipeInstance.current.startX);
291 const dy = Math.abs(currentY - swipeInstance.current.startY); // We are likely to be swiping, let's prevent the scroll event on iOS.
292
293 if (dx > dy) {
294 if (event.cancelable) {
295 event.preventDefault();
296 }
297 }
298
299 const definitelySwiping = horizontalSwipe ? dx > dy && dx > UNCERTAINTY_THRESHOLD : dy > dx && dy > UNCERTAINTY_THRESHOLD;
300
301 if (definitelySwiping === true || (horizontalSwipe ? dy > UNCERTAINTY_THRESHOLD : dx > UNCERTAINTY_THRESHOLD)) {
302 swipeInstance.current.isSwiping = definitelySwiping;
303
304 if (!definitelySwiping) {
305 handleBodyTouchEnd(event);
306 return;
307 } // Shift the starting point.
308
309
310 swipeInstance.current.startX = currentX;
311 swipeInstance.current.startY = currentY; // Compensate for the part of the drawer displayed on touch start.
312
313 if (!disableDiscovery && !open) {
314 if (horizontalSwipe) {
315 swipeInstance.current.startX -= swipeAreaWidth;
316 } else {
317 swipeInstance.current.startY -= swipeAreaWidth;
318 }
319 }
320 }
321 }
322
323 if (!swipeInstance.current.isSwiping) {
324 return;
325 }
326
327 const maxTranslate = getMaxTranslate(horizontalSwipe, paperRef.current);
328 let startLocation = horizontalSwipe ? swipeInstance.current.startX : swipeInstance.current.startY;
329
330 if (open && !swipeInstance.current.paperHit) {
331 startLocation = Math.min(startLocation, maxTranslate);
332 }
333
334 const translate = getTranslate(horizontalSwipe ? currentX : currentY, startLocation, open, maxTranslate);
335
336 if (open) {
337 if (!swipeInstance.current.paperHit) {
338 const paperHit = horizontalSwipe ? currentX < maxTranslate : currentY < maxTranslate;
339
340 if (paperHit) {
341 swipeInstance.current.paperHit = true;
342 swipeInstance.current.startX = currentX;
343 swipeInstance.current.startY = currentY;
344 } else {
345 return;
346 }
347 } else if (translate === 0) {
348 swipeInstance.current.startX = currentX;
349 swipeInstance.current.startY = currentY;
350 }
351 }
352
353 if (swipeInstance.current.lastTranslate === null) {
354 swipeInstance.current.lastTranslate = translate;
355 swipeInstance.current.lastTime = performance.now() + 1;
356 }
357
358 const velocity = (translate - swipeInstance.current.lastTranslate) / (performance.now() - swipeInstance.current.lastTime) * 1e3; // Low Pass filter.
359
360 swipeInstance.current.velocity = swipeInstance.current.velocity * 0.4 + velocity * 0.6;
361 swipeInstance.current.lastTranslate = translate;
362 swipeInstance.current.lastTime = performance.now(); // We are swiping, let's prevent the scroll event on iOS.
363
364 if (event.cancelable) {
365 event.preventDefault();
366 }
367
368 setPosition(translate);
369 });
370 const handleBodyTouchStart = useEventCallback(event => {
371 // We are not supposed to handle this touch move.
372 // Example of use case: ignore the event if there is a Slider.
373 if (event.defaultPrevented) {
374 return;
375 } // We can only have one node at the time claiming ownership for handling the swipe.
376
377
378 if (event.muiHandled) {
379 return;
380 } // At least one element clogs the drawer interaction zone.
381
382
383 if (open && !backdropRef.current.contains(event.target) && !paperRef.current.contains(event.target)) {
384 return;
385 }
386
387 const anchorRtl = getAnchor(theme, anchor);
388 const horizontalSwipe = isHorizontal(anchor);
389 const currentX = calculateCurrentX(anchorRtl, event.touches);
390 const currentY = calculateCurrentY(anchorRtl, event.touches);
391
392 if (!open) {
393 if (disableSwipeToOpen || event.target !== swipeAreaRef.current) {
394 return;
395 }
396
397 if (horizontalSwipe) {
398 if (currentX > swipeAreaWidth) {
399 return;
400 }
401 } else if (currentY > swipeAreaWidth) {
402 return;
403 }
404 }
405
406 event.muiHandled = true;
407 nodeThatClaimedTheSwipe = null;
408 swipeInstance.current.startX = currentX;
409 swipeInstance.current.startY = currentY;
410 setMaybeSwiping(true);
411
412 if (!open && paperRef.current) {
413 // The ref may be null when a parent component updates while swiping.
414 setPosition(getMaxTranslate(horizontalSwipe, paperRef.current) + (disableDiscovery ? 20 : -swipeAreaWidth), {
415 changeTransition: false
416 });
417 }
418
419 swipeInstance.current.velocity = 0;
420 swipeInstance.current.lastTime = null;
421 swipeInstance.current.lastTranslate = null;
422 swipeInstance.current.paperHit = false;
423 touchDetected.current = true;
424 });
425 React.useEffect(() => {
426 if (variant === 'temporary') {
427 const doc = ownerDocument(paperRef.current);
428 doc.addEventListener('touchstart', handleBodyTouchStart);
429 doc.addEventListener('touchmove', handleBodyTouchMove, {
430 passive: false
431 });
432 doc.addEventListener('touchend', handleBodyTouchEnd);
433 return () => {
434 doc.removeEventListener('touchstart', handleBodyTouchStart);
435 doc.removeEventListener('touchmove', handleBodyTouchMove, {
436 passive: false
437 });
438 doc.removeEventListener('touchend', handleBodyTouchEnd);
439 };
440 }
441
442 return undefined;
443 }, [variant, handleBodyTouchStart, handleBodyTouchMove, handleBodyTouchEnd]);
444 React.useEffect(() => () => {
445 // We need to release the lock.
446 if (nodeThatClaimedTheSwipe === swipeInstance.current) {
447 nodeThatClaimedTheSwipe = null;
448 }
449 }, []);
450 React.useEffect(() => {
451 if (!open) {
452 setMaybeSwiping(false);
453 }
454 }, [open]);
455 const handleBackdropRef = React.useCallback(instance => {
456 // #StrictMode ready
457 backdropRef.current = ReactDOM.findDOMNode(instance);
458 }, []);
459 return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Drawer, _extends({
460 open: variant === 'temporary' && maybeSwiping ? true : open,
461 variant: variant,
462 ModalProps: _extends({
463 BackdropProps: _extends({}, BackdropProps, {
464 ref: handleBackdropRef
465 })
466 }, ModalPropsProp),
467 PaperProps: _extends({}, PaperProps, {
468 style: _extends({
469 pointerEvents: variant === 'temporary' && !open ? 'none' : ''
470 }, PaperProps.style),
471 ref: paperRef
472 }),
473 anchor: anchor,
474 transitionDuration: calculatedDurationRef.current || transitionDuration,
475 onClose: onClose,
476 ref: ref
477 }, other)), !disableSwipeToOpen && variant === 'temporary' && /*#__PURE__*/React.createElement(NoSsr, null, /*#__PURE__*/React.createElement(SwipeArea, _extends({
478 anchor: anchor,
479 ref: swipeAreaRef,
480 width: swipeAreaWidth
481 }, SwipeAreaProps))));
482});
483process.env.NODE_ENV !== "production" ? SwipeableDrawer.propTypes = {
484 /**
485 * @ignore
486 */
487 anchor: PropTypes.oneOf(['left', 'top', 'right', 'bottom']),
488
489 /**
490 * The content of the component.
491 */
492 children: PropTypes.node,
493
494 /**
495 * Disable the backdrop transition.
496 * This can improve the FPS on low-end devices.
497 */
498 disableBackdropTransition: PropTypes.bool,
499
500 /**
501 * If `true`, touching the screen near the edge of the drawer will not slide in the drawer a bit
502 * to promote accidental discovery of the swipe gesture.
503 */
504 disableDiscovery: PropTypes.bool,
505
506 /**
507 * If `true`, swipe to open is disabled. This is useful in browsers where swiping triggers
508 * navigation actions. Swipe to open is disabled on iOS browsers by default.
509 */
510 disableSwipeToOpen: PropTypes.bool,
511
512 /**
513 * @ignore
514 */
515 hideBackdrop: PropTypes.bool,
516
517 /**
518 * Affects how far the drawer must be opened/closed to change his state.
519 * Specified as percent (0-1) of the width of the drawer
520 */
521 hysteresis: PropTypes.number,
522
523 /**
524 * Defines, from which (average) velocity on, the swipe is
525 * defined as complete although hysteresis isn't reached.
526 * Good threshold is between 250 - 1000 px/s
527 */
528 minFlingVelocity: PropTypes.number,
529
530 /**
531 * @ignore
532 */
533 ModalProps: PropTypes.shape({
534 BackdropProps: PropTypes.shape({
535 component: elementTypeAcceptingRef
536 })
537 }),
538
539 /**
540 * Callback fired when the component requests to be closed.
541 *
542 * @param {object} event The event source of the callback.
543 */
544 onClose: PropTypes.func.isRequired,
545
546 /**
547 * Callback fired when the component requests to be opened.
548 *
549 * @param {object} event The event source of the callback.
550 */
551 onOpen: PropTypes.func.isRequired,
552
553 /**
554 * If `true`, the drawer is open.
555 */
556 open: PropTypes.bool.isRequired,
557
558 /**
559 * @ignore
560 */
561 PaperProps: PropTypes.shape({
562 component: elementTypeAcceptingRef,
563 style: PropTypes.object
564 }),
565
566 /**
567 * The element is used to intercept the touch events on the edge.
568 */
569 SwipeAreaProps: PropTypes.object,
570
571 /**
572 * The width of the left most (or right most) area in pixels where the
573 * drawer can be swiped open from.
574 */
575 swipeAreaWidth: PropTypes.number,
576
577 /**
578 * The duration for the transition, in milliseconds.
579 * You may specify a single timeout for all transitions, or individually with an object.
580 */
581 transitionDuration: PropTypes.oneOfType([PropTypes.number, PropTypes.shape({
582 enter: PropTypes.number,
583 exit: PropTypes.number
584 })]),
585
586 /**
587 * @ignore
588 */
589 variant: PropTypes.oneOf(['permanent', 'persistent', 'temporary'])
590} : void 0;
591export default SwipeableDrawer;
\No newline at end of file