diff --git a/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.js b/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.js index 862aaf47e8e6a6..56df40bac7af66 100644 --- a/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.js +++ b/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import { elementTypeAcceptingRef } from '@material-ui/utils'; import Drawer, { getAnchor, isHorizontal } from '../Drawer/Drawer'; +import useEventCallback from '../utils/useEventCallback'; import { duration } from '../styles/transitions'; import useTheme from '../styles/useTheme'; import { getTransitionProps } from '../transitions/utils'; @@ -13,16 +14,6 @@ import SwipeArea from './SwipeArea'; // trigger a native scroll. const UNCERTAINTY_THRESHOLD = 3; // px -// We can only have one node at the time claiming ownership for handling the swipe. -// Otherwise, the UX would be confusing. -// That's why we use a singleton here. -let nodeThatClaimedTheSwipe = null; - -// Exported for test purposes. -export function reset() { - nodeThatClaimedTheSwipe = null; -} - function calculateCurrentX(anchor, touches) { return anchor === 'right' ? document.body.offsetWidth - touches[0].pageX : touches[0].pageX; } @@ -82,14 +73,12 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(props, ref) { const paperRef = React.useRef(); const touchDetected = React.useRef(false); - const openRef = React.useRef(open); // Ref for transition duration based on / to match swipe speed const calculatedDurationRef = React.useRef(); // Use a ref so the open value used is always up to date inside useCallback. useEnhancedEffect(() => { - openRef.current = open; calculatedDurationRef.current = null; }, [open]); @@ -142,214 +131,230 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(props, ref) { [anchor, disableBackdropTransition, hideBackdrop, theme, transitionDuration], ); - const handleBodyTouchEnd = React.useCallback( - event => { - if (!touchDetected.current) { - return; - } - nodeThatClaimedTheSwipe = null; - touchDetected.current = false; - setMaybeSwiping(false); - - // The swipe wasn't started. - if (!swipeInstance.current.isSwiping) { - swipeInstance.current.isSwiping = null; - return; - } + const handleBodyTouchEnd = useEventCallback(event => { + if (!touchDetected.current) { + return; + } + touchDetected.current = false; + setMaybeSwiping(false); + // The swipe wasn't started. + if (!swipeInstance.current.isSwiping) { swipeInstance.current.isSwiping = null; + return; + } - const anchorRtl = getAnchor(theme, anchor); - const horizontal = isHorizontal(anchor); - let current; - if (horizontal) { - current = calculateCurrentX(anchorRtl, event.changedTouches); - } else { - current = calculateCurrentY(anchorRtl, event.changedTouches); - } + swipeInstance.current.isSwiping = null; - const startLocation = horizontal - ? swipeInstance.current.startX - : swipeInstance.current.startY; - const maxTranslate = getMaxTranslate(horizontal, paperRef.current); - const currentTranslate = getTranslate(current, startLocation, openRef.current, maxTranslate); - const translateRatio = currentTranslate / maxTranslate; - - if (Math.abs(swipeInstance.current.velocity) > minFlingVelocity) { - // Calculate transition duration to match swipe speed - calculatedDurationRef.current = - Math.abs((maxTranslate - currentTranslate) / swipeInstance.current.velocity) * 1000; - } + const anchorRtl = getAnchor(theme, anchor); + const horizontal = isHorizontal(anchor); + let current; + if (horizontal) { + current = calculateCurrentX(anchorRtl, event.changedTouches); + } else { + current = calculateCurrentY(anchorRtl, event.changedTouches); + } - if (openRef.current) { - if (swipeInstance.current.velocity > minFlingVelocity || translateRatio > hysteresis) { - onClose(); - } else { - // Reset the position, the swipe was aborted. - setPosition(0, { - mode: 'exit', - }); - } + const startLocation = horizontal ? swipeInstance.current.startX : swipeInstance.current.startY; + const maxTranslate = getMaxTranslate(horizontal, paperRef.current); + const currentTranslate = getTranslate(current, startLocation, open, maxTranslate); + const translateRatio = currentTranslate / maxTranslate; - return; - } + if (Math.abs(swipeInstance.current.velocity) > minFlingVelocity) { + // Calculate transition duration to match swipe speed + calculatedDurationRef.current = + Math.abs((maxTranslate - currentTranslate) / swipeInstance.current.velocity) * 1000; + } - if (swipeInstance.current.velocity < -minFlingVelocity || 1 - translateRatio > hysteresis) { - onOpen(); + if (open) { + if (swipeInstance.current.velocity > minFlingVelocity || translateRatio > hysteresis) { + onClose(); } else { // Reset the position, the swipe was aborted. - setPosition(getMaxTranslate(horizontal, paperRef.current), { - mode: 'enter', + setPosition(0, { + mode: 'exit', }); } - }, - [anchor, hysteresis, minFlingVelocity, onClose, onOpen, setPosition, theme], - ); - const handleBodyTouchMove = React.useCallback( - event => { - // the ref may be null when a parent component updates while swiping - if (!paperRef.current || !touchDetected.current) { - return; - } + return; + } - const anchorRtl = getAnchor(theme, anchor); - const horizontalSwipe = isHorizontal(anchor); + if (swipeInstance.current.velocity < -minFlingVelocity || 1 - translateRatio > hysteresis) { + onOpen(); + } else { + // Reset the position, the swipe was aborted. + setPosition(getMaxTranslate(horizontal, paperRef.current), { + mode: 'enter', + }); + } + }); - const currentX = calculateCurrentX(anchorRtl, event.touches); - const currentY = calculateCurrentY(anchorRtl, event.touches); + const handleBodyTouchMove = useEventCallback(event => { + // the ref may be null when a parent component updates while swiping + if (!paperRef.current || !touchDetected.current) { + return; + } - // We don't know yet. - if (swipeInstance.current.isSwiping == null) { - const dx = Math.abs(currentX - swipeInstance.current.startX); - const dy = Math.abs(currentY - swipeInstance.current.startY); + const anchorRtl = getAnchor(theme, anchor); + const horizontalSwipe = isHorizontal(anchor); - // We are likely to be swiping, let's prevent the scroll event on iOS. - if (dx > dy) { - if (event.cancelable) { - event.preventDefault(); - } + const currentX = calculateCurrentX(anchorRtl, event.touches); + const currentY = calculateCurrentY(anchorRtl, event.touches); + + // We don't know yet. + if (swipeInstance.current.isSwiping == null) { + const dx = Math.abs(currentX - swipeInstance.current.startX); + const dy = Math.abs(currentY - swipeInstance.current.startY); + + // We are likely to be swiping, let's prevent the scroll event on iOS. + if (dx > dy) { + if (event.cancelable) { + event.preventDefault(); } + } - const definitelySwiping = horizontalSwipe - ? dx > dy && dx > UNCERTAINTY_THRESHOLD - : dy > dx && dy > UNCERTAINTY_THRESHOLD; - - if ( - definitelySwiping === true || - (horizontalSwipe ? dy > UNCERTAINTY_THRESHOLD : dx > UNCERTAINTY_THRESHOLD) - ) { - swipeInstance.current.isSwiping = definitelySwiping; - if (!definitelySwiping) { - handleBodyTouchEnd(event); - return; - } + const definitelySwiping = horizontalSwipe + ? dx > dy && dx > UNCERTAINTY_THRESHOLD + : dy > dx && dy > UNCERTAINTY_THRESHOLD; + + if ( + definitelySwiping === true || + (horizontalSwipe ? dy > UNCERTAINTY_THRESHOLD : dx > UNCERTAINTY_THRESHOLD) + ) { + swipeInstance.current.isSwiping = definitelySwiping; + if (!definitelySwiping) { + handleBodyTouchEnd(event); + return; + } - // Shift the starting point. - swipeInstance.current.startX = currentX; - swipeInstance.current.startY = currentY; + // Shift the starting point. + swipeInstance.current.startX = currentX; + swipeInstance.current.startY = currentY; - // Compensate for the part of the drawer displayed on touch start. - if (!disableDiscovery && !openRef.current) { - if (horizontalSwipe) { - swipeInstance.current.startX -= swipeAreaWidth; - } else { - swipeInstance.current.startY -= swipeAreaWidth; - } + // Compensate for the part of the drawer displayed on touch start. + if (!disableDiscovery && !open) { + if (horizontalSwipe) { + swipeInstance.current.startX -= swipeAreaWidth; + } else { + swipeInstance.current.startY -= swipeAreaWidth; } } } + } - if (!swipeInstance.current.isSwiping) { - return; - } - const startLocation = horizontalSwipe - ? swipeInstance.current.startX - : swipeInstance.current.startY; - const maxTranslate = getMaxTranslate(horizontalSwipe, paperRef.current); - - const translate = getTranslate( - horizontalSwipe ? currentX : currentY, - startLocation, - openRef.current, - maxTranslate, - ); + if (!swipeInstance.current.isSwiping) { + return; + } - if (swipeInstance.current.lastTranslate === null) { - swipeInstance.current.lastTranslate = translate; - swipeInstance.current.lastTime = performance.now() + 1; + const maxTranslate = getMaxTranslate(horizontalSwipe, paperRef.current); + let startLocation = horizontalSwipe + ? swipeInstance.current.startX + : swipeInstance.current.startY; + if (open && !swipeInstance.current.paperHit) { + startLocation = Math.min(startLocation, maxTranslate); + } + + const translate = getTranslate( + horizontalSwipe ? currentX : currentY, + startLocation, + open, + maxTranslate, + ); + + if (open) { + if (!swipeInstance.current.paperHit) { + const paperHit = horizontalSwipe ? currentX < maxTranslate : currentY < maxTranslate; + if (paperHit) { + swipeInstance.current.paperHit = true; + swipeInstance.current.startX = currentX; + swipeInstance.current.startY = currentY; + } else { + return; + } + } else if (translate === 0) { + swipeInstance.current.startX = currentX; + swipeInstance.current.startY = currentY; } + } - const velocity = - ((translate - swipeInstance.current.lastTranslate) / - (performance.now() - swipeInstance.current.lastTime)) * - 1e3; + if (swipeInstance.current.lastTranslate === null) { + swipeInstance.current.lastTranslate = translate; + swipeInstance.current.lastTime = performance.now() + 1; + } - // Low Pass filter. - swipeInstance.current.velocity = swipeInstance.current.velocity * 0.4 + velocity * 0.6; + const velocity = + ((translate - swipeInstance.current.lastTranslate) / + (performance.now() - swipeInstance.current.lastTime)) * + 1e3; - swipeInstance.current.lastTranslate = translate; - swipeInstance.current.lastTime = performance.now(); + // Low Pass filter. + swipeInstance.current.velocity = swipeInstance.current.velocity * 0.4 + velocity * 0.6; - // We are swiping, let's prevent the scroll event on iOS. - if (event.cancelable) { - event.preventDefault(); - } - setPosition(translate); - }, - [setPosition, handleBodyTouchEnd, anchor, disableDiscovery, swipeAreaWidth, theme], - ); + swipeInstance.current.lastTranslate = translate; + swipeInstance.current.lastTime = performance.now(); - const handleBodyTouchStart = React.useCallback( - event => { - // We are not supposed to handle this touch move. - if (nodeThatClaimedTheSwipe !== null && nodeThatClaimedTheSwipe !== swipeInstance.current) { - return; - } + // We are swiping, let's prevent the scroll event on iOS. + if (event.cancelable) { + event.preventDefault(); + } - const anchorRtl = getAnchor(theme, anchor); - const horizontalSwipe = isHorizontal(anchor); + setPosition(translate); + }); - const currentX = calculateCurrentX(anchorRtl, event.touches); - const currentY = calculateCurrentY(anchorRtl, event.touches); + const handleBodyTouchStart = useEventCallback(event => { + // We are not supposed to handle this touch move. + // Example of use case: ignore the event if there is a Slider. + if (event.defaultPrevented) { + return; + } - if (!openRef.current) { - if (disableSwipeToOpen || event.target !== swipeAreaRef.current) { - return; - } - if (horizontalSwipe) { - if (currentX > swipeAreaWidth) { - return; - } - } else if (currentY > swipeAreaWidth) { + // We can only have one node at the time claiming ownership for handling the swipe. + if (event.muiHandled) { + return; + } + + const anchorRtl = getAnchor(theme, anchor); + const horizontalSwipe = isHorizontal(anchor); + + const currentX = calculateCurrentX(anchorRtl, event.touches); + const currentY = calculateCurrentY(anchorRtl, event.touches); + + if (!open) { + if (disableSwipeToOpen || event.target !== swipeAreaRef.current) { + return; + } + if (horizontalSwipe) { + if (currentX > swipeAreaWidth) { return; } + } else if (currentY > swipeAreaWidth) { + return; } + } - nodeThatClaimedTheSwipe = swipeInstance.current; - swipeInstance.current.startX = currentX; - swipeInstance.current.startY = currentY; - - setMaybeSwiping(true); - if (!openRef.current && paperRef.current) { - // The ref may be null when a parent component updates while swiping. - setPosition( - getMaxTranslate(horizontalSwipe, paperRef.current) + - (disableDiscovery ? 20 : -swipeAreaWidth), - { - changeTransition: false, - }, - ); - } + event.muiHandled = true; + swipeInstance.current.startX = currentX; + swipeInstance.current.startY = currentY; + + setMaybeSwiping(true); + if (!open && paperRef.current) { + // The ref may be null when a parent component updates while swiping. + setPosition( + getMaxTranslate(horizontalSwipe, paperRef.current) + + (disableDiscovery ? 20 : -swipeAreaWidth), + { + changeTransition: false, + }, + ); + } - swipeInstance.current.velocity = 0; - swipeInstance.current.lastTime = null; - swipeInstance.current.lastTranslate = null; + swipeInstance.current.velocity = 0; + swipeInstance.current.lastTime = null; + swipeInstance.current.lastTranslate = null; + swipeInstance.current.paperHit = false; - touchDetected.current = true; - }, - [setPosition, anchor, disableDiscovery, disableSwipeToOpen, swipeAreaWidth, theme], - ); + touchDetected.current = true; + }); React.useEffect(() => { if (variant === 'temporary') { @@ -367,16 +372,6 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(props, ref) { return undefined; }, [variant, handleBodyTouchStart, handleBodyTouchMove, handleBodyTouchEnd]); - React.useEffect( - () => () => { - // We need to release the lock. - if (nodeThatClaimedTheSwipe === swipeInstance.current) { - nodeThatClaimedTheSwipe = null; - } - }, - [], - ); - React.useEffect(() => { if (!open) { setMaybeSwiping(false); diff --git a/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.test.js b/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.test.js index 4b9037e693a448..2333020c373e68 100644 --- a/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.test.js +++ b/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.test.js @@ -6,7 +6,7 @@ import describeConformance from '@material-ui/core/test-utils/describeConformanc import PropTypes from 'prop-types'; import consoleErrorMock from 'test/utils/consoleErrorMock'; import Drawer from '../Drawer'; -import SwipeableDrawer, { reset } from './SwipeableDrawer'; +import SwipeableDrawer from './SwipeableDrawer'; import SwipeArea from './SwipeArea'; import useForkRef from '../utils/useForkRef'; @@ -147,7 +147,6 @@ describe('', () => { }); afterEach(() => { - reset(); if (wrapper.length > 0) { wrapper.unmount(); } @@ -443,12 +442,20 @@ describe('', () => { , ); - fireSwipeAreaMouseEvent(wrapper.find(SwipeableDrawer).at(0), 'touchstart', { - touches: [{ pageX: 0, clientY: 0 }], - }); - fireSwipeAreaMouseEvent(wrapper.find(SwipeableDrawer).at(1), 'touchstart', { - touches: [{ pageX: 0, clientY: 0 }], - }); + // use the same event object for both touch start events, one would propagate to the other swipe area in the browser + const touchStartEvent = fireSwipeAreaMouseEvent( + wrapper.find(SwipeableDrawer).at(0), + 'touchstart', + { + touches: [{ pageX: 0, clientY: 0 }], + }, + ); + wrapper + .find(SwipeableDrawer) + .at(1) + .find(SwipeArea) + .getDOMNode() + .dispatchEvent(touchStartEvent); fireBodyMouseEvent('touchmove', { touches: [{ pageX: 20, clientY: 0 }] }); fireBodyMouseEvent('touchmove', { touches: [{ pageX: 180, clientY: 0 }] }); fireBodyMouseEvent('touchend', { changedTouches: [{ pageX: 180, clientY: 0 }] });