Skip to content

Commit

Permalink
[SwipeableDrawer] Add callback to customise touchstart ignore for swi…
Browse files Browse the repository at this point in the history
…peable drawer (#30759)
  • Loading branch information
tech-meppem authored Jan 23, 2023
1 parent 9c0f037 commit 0ff044d
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 31 deletions.
4 changes: 4 additions & 0 deletions docs/pages/material-ui/api/swipeable-drawer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"props": {
"onClose": { "type": { "name": "func" }, "required": true },
"onOpen": { "type": { "name": "func" }, "required": true },
"allowSwipeInChildren": {
"type": { "name": "union", "description": "bool<br>&#124;&nbsp;func" },
"default": "false"
},
"children": { "type": { "name": "node" } },
"disableBackdropTransition": { "type": { "name": "bool" } },
"disableDiscovery": { "type": { "name": "bool" } },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"componentDescription": "",
"propDescriptions": {
"allowSwipeInChildren": "Callback to determine what children of the drawer the user can use to drag open the drawer. Can be a custom function to control the behavior. Set or return true / false to allow / disallow the swipe event.",
"children": "O conteúdo do componente.",
"disableBackdropTransition": "Disable the backdrop transition. This can improve the FPS on low-end devices.",
"disableDiscovery": "If <code>true</code>, touching the screen near the edge of the drawer will not slide in the drawer a bit to promote accidental discovery of the swipe gesture.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"componentDescription": "",
"propDescriptions": {
"allowSwipeInChildren": "Callback to determine what children of the drawer the user can use to drag open the drawer. Can be a custom function to control the behavior. Set or return true / false to allow / disallow the swipe event.",
"children": "The content of the component.",
"disableBackdropTransition": "Disable the backdrop transition. This can improve the FPS on low-end devices.",
"disableDiscovery": "If <code>true</code>, touching the screen near the edge of the drawer will not slide in the drawer a bit to promote accidental discovery of the swipe gesture.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"componentDescription": "",
"propDescriptions": {
"allowSwipeInChildren": "If set to true, the swipe event will open the drawer even if the user begins the swipe on one of the drawer&#39;s children. This can be useful in scenarios where the drawer is partially visible. You can customize it further with a callback that determines which children the user can drag over to open the drawer (for example, to ignore other elements that handle touch move events, like sliders).",
"children": "The content of the component.",
"disableBackdropTransition": "Disable the backdrop transition. This can improve the FPS on low-end devices.",
"disableDiscovery": "If <code>true</code>, touching the screen near the edge of the drawer will not slide in the drawer a bit to promote accidental discovery of the swipe gesture.",
Expand Down
15 changes: 15 additions & 0 deletions packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import * as React from 'react';
import { DrawerProps } from '../Drawer';

export interface SwipeableDrawerProps extends Omit<DrawerProps, 'onClose' | 'open'> {
/**
* If set to true, the swipe event will open the drawer even if the user begins the swipe on one of the drawer's children.
* This can be useful in scenarios where the drawer is partially visible.
* You can customize it further with a callback that determines which children the user can drag over to open the drawer
* (for example, to ignore other elements that handle touch move events, like sliders).
*
* @param {TouchEvent} event The 'touchstart' event
* @param {HTMLDivElement} swipeArea The swipe area element
* @param {HTMLDivElement} paper The drawer's paper element
*
* @default false
*/
allowSwipeInChildren?:
| boolean
| ((e: TouchEvent, swipeArea: HTMLDivElement, paper: HTMLDivElement) => boolean);
/**
* Disable the backdrop transition.
* This can improve the FPS on low-end devices.
Expand Down
88 changes: 65 additions & 23 deletions packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
disableSwipeToOpen = iOS,
hideBackdrop,
hysteresis = 0.52,
allowSwipeInChildren = false,
minFlingVelocity = 450,
ModalProps: { BackdropProps, ...ModalPropsProp } = {},
onClose,
Expand Down Expand Up @@ -301,6 +302,39 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
}
});

const startMaybeSwiping = (force = false) => {
if (!maybeSwiping) {
// on Safari Mobile, if you want to be able to have the 'click' event fired on child elements, nothing in the DOM can be changed.
// this is because Safari Mobile will not fire any mouse events (still fires touch though) if the DOM changes during mousemove.
// so do this change on first touchmove instead of touchstart
if (force || !(disableDiscovery && allowSwipeInChildren)) {
flushSync(() => {
setMaybeSwiping(true);
});
}

const horizontalSwipe = isHorizontal(anchor);

if (!open && paperRef.current) {
// The ref may be null when a parent component updates while swiping.
setPosition(
getMaxTranslate(horizontalSwipe, paperRef.current) +
(disableDiscovery ? 15 : -DRAG_STARTED_SIGNAL),
{
changeTransition: false,
},
);
}

swipeInstance.current.velocity = 0;
swipeInstance.current.lastTime = null;
swipeInstance.current.lastTranslate = null;
swipeInstance.current.paperHit = false;

touchDetected.current = true;
}
};

const handleBodyTouchMove = useEventCallback((nativeEvent) => {
// the ref may be null when a parent component updates while swiping
if (!paperRef.current || !touchDetected.current) {
Expand All @@ -312,6 +346,8 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
return;
}

startMaybeSwiping(true);

const anchorRtl = getAnchor(theme, anchor);
const horizontalSwipe = isHorizontal(anchor);

Expand Down Expand Up @@ -477,7 +513,20 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
);

if (!open) {
if (disableSwipeToOpen || nativeEvent.target !== swipeAreaRef.current) {
// logic for if swipe should be ignored:
// if disableSwipeToOpen
// if target != swipeArea, and target is not a child of paper ref
// if is a child of paper ref, and `allowSwipeInChildren` does not allow it
if (
disableSwipeToOpen ||
!(
nativeEvent.target === swipeAreaRef.current ||
(paperRef.current?.contains(nativeEvent.target) &&
(typeof allowSwipeInChildren === 'function'
? allowSwipeInChildren(nativeEvent, swipeAreaRef.current, paperRef.current)
: allowSwipeInChildren))
)
) {
return;
}
if (horizontalSwipe) {
Expand All @@ -494,27 +543,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
swipeInstance.current.startX = currentX;
swipeInstance.current.startY = currentY;

flushSync(() => {
setMaybeSwiping(true);
});

if (!open && paperRef.current) {
// The ref may be null when a parent component updates while swiping.
setPosition(
getMaxTranslate(horizontalSwipe, paperRef.current) +
(disableDiscovery ? 15 : -DRAG_STARTED_SIGNAL),
{
changeTransition: false,
},
);
}

swipeInstance.current.velocity = 0;
swipeInstance.current.lastTime = null;
swipeInstance.current.lastTranslate = null;
swipeInstance.current.paperHit = false;

touchDetected.current = true;
startMaybeSwiping();
});

React.useEffect(() => {
Expand Down Expand Up @@ -574,7 +603,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
PaperProps={{
...PaperProps,
style: {
pointerEvents: variant === 'temporary' && !open ? 'none' : '',
pointerEvents: variant === 'temporary' && !open && !allowSwipeInChildren ? 'none' : '',
...PaperProps.style,
},
ref: handleRef,
Expand Down Expand Up @@ -604,6 +633,19 @@ SwipeableDrawer.propTypes /* remove-proptypes */ = {
// | These PropTypes are generated from the TypeScript type definitions |
// | To update them edit the d.ts file and run "yarn proptypes" |
// ----------------------------------------------------------------------
/**
* If set to true, the swipe event will open the drawer even if the user begins the swipe on one of the drawer's children.
* This can be useful in scenarios where the drawer is partially visible.
* You can customize it further with a callback that determines which children the user can drag over to open the drawer
* (for example, to ignore other elements that handle touch move events, like sliders).
*
* @param {TouchEvent} event The 'touchstart' event
* @param {HTMLDivElement} swipeArea The swipe area element
* @param {HTMLDivElement} paper The drawer's paper element
*
* @default false
*/
allowSwipeInChildren: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
/**
* @ignore
*/
Expand Down
189 changes: 181 additions & 8 deletions packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,17 +501,190 @@ describe('<SwipeableDrawer />', () => {
expect(document.querySelector('[class*=PrivateSwipeArea-root]')).not.to.equal(null);
});

it('should be able to attach paper ref passed through PaperProps', () => {
const ref = React.createRef();
render(
<SwipeableDrawer onOpen={() => {}} onClose={() => {}} PaperProps={{ ref }} open>
<div />
</SwipeableDrawer>,
);
expect(ref.current).not.to.equal(null);
const openTouchesForSwipingChildren = [
{ pageX: 0, clientY: windowHeight - 20 },
{ pageX: 0, clientY: windowHeight - 60 },
{ pageX: 0, clientY: windowHeight - 180 },
];

const handleHeight = 60;

describe('prop: allowSwipeInChildren', () => {
it('should allow swiping on children to open', () => {
const handleOpen = spy();
render(
<SwipeableDrawer
anchor={'bottom'}
allowSwipeInChildren
onOpen={handleOpen}
onClose={() => {}}
open={false}
swipeAreaWidth={20}
SwipeAreaProps={{
style: {
// ensure clicks will not be grabbed by swipe area to ensure testing just this functionality
pointerEvents: 'none',
},
}}
PaperProps={{ component: FakePaper }}
ModalProps={{
keepMounted: true,
sx: {
transform: `translateY(${handleHeight}px) !important`,
},
}}
>
<div data-testid="drawer" style={{ position: 'relative', pointerEvents: 'all' }}>
<div
data-testid="handle"
style={{
position: 'absolute',
height: `${handleHeight}px`,
marginTop: `-${handleHeight}px`,
}}
>
SwipeableDrawer
</div>
</div>
</SwipeableDrawer>,
);

const handle = screen.getAllByTestId('handle').slice(-1)[0];

fireEvent.touchStart(handle, {
touches: [
new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[0] }),
],
});
fireEvent.touchMove(handle, {
touches: [
new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[1] }),
],
});
fireEvent.touchMove(handle, {
touches: [
new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[2] }),
],
});
fireEvent.touchEnd(handle, {
changedTouches: [
new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[2] }),
],
});
expect(handleOpen.callCount).to.equal(1);
});

it('should not allow swiping on children to open that are excluded via a function', () => {
const handleOpen = spy();
render(
<SwipeableDrawer
anchor={'bottom'}
allowSwipeInChildren={(e) => {
const elem = e.target;
// ignore touch events from .ignore &^ from swipe area
return (
!elem.classList.contains('ignore') &&
!elem.classList.contains('PrivateSwipeArea-root')
);
}}
onOpen={handleOpen}
onClose={() => {}}
open={false}
swipeAreaWidth={20}
SwipeAreaProps={{
style: {
// ensure clicks will not be grabbed by swipe area to ensure testing just this functionality
pointerEvents: 'none',
},
}}
PaperProps={{ component: FakePaper }}
ModalProps={{
keepMounted: true,
sx: {
'& > *': {
pointerEvents: 'auto',
},
},
}}
>
<div
className="ignore"
data-testid="drawer"
style={{ position: 'relative', height: '40px', pointerEvents: 'all' }}
>
<div
data-testid="handle"
style={{ position: 'absolute', height: '40px', marginTop: '-40px' }}
>
SwipeableDrawer
</div>
</div>
</SwipeableDrawer>,
);

// should ignore the drawer touch events
const drawer = screen.getAllByTestId('drawer').slice(-1)[0];

fireEvent.touchStart(drawer, {
touches: [
new Touch({ identifier: 0, target: drawer, ...openTouchesForSwipingChildren[0] }),
],
});
fireEvent.touchMove(drawer, {
touches: [
new Touch({ identifier: 0, target: drawer, ...openTouchesForSwipingChildren[1] }),
],
});
fireEvent.touchMove(drawer, {
touches: [
new Touch({ identifier: 0, target: drawer, ...openTouchesForSwipingChildren[2] }),
],
});
fireEvent.touchEnd(drawer, {
changedTouches: [
new Touch({ identifier: 0, target: drawer, ...openTouchesForSwipingChildren[2] }),
],
});
expect(handleOpen.callCount).to.equal(0);

// should allow opening the drawer via handle
const handle = screen.getAllByTestId('handle').slice(-1)[0];

fireEvent.touchStart(handle, {
touches: [
new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[0] }),
],
});
fireEvent.touchMove(handle, {
touches: [
new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[1] }),
],
});
fireEvent.touchMove(handle, {
touches: [
new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[2] }),
],
});
fireEvent.touchEnd(handle, {
changedTouches: [
new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[2] }),
],
});
expect(handleOpen.callCount).to.equal(1);
});
});
});

it('should be able to attach paper ref passed through PaperProps', () => {
const ref = React.createRef();
render(
<SwipeableDrawer onOpen={() => {}} onClose={() => {}} PaperProps={{ ref }} open>
<div />
</SwipeableDrawer>,
);
expect(ref.current).not.to.equal(null);
});

describe('disableSwipeToOpen', () => {
it('should not support swipe to open if disableSwipeToOpen is set', () => {
const handleOpen = spy();
Expand Down

0 comments on commit 0ff044d

Please sign in to comment.