@activelylearn/material-ui
Version:
Material-UI's workspace package
436 lines (381 loc) • 12.4 kB
JavaScript
/* eslint-disable consistent-this */
// @inheritedComponent Drawer
import React from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import Drawer, { getAnchor, isHorizontal } from '../Drawer/Drawer';
import { duration } from '../styles/transitions';
import withTheme from '../styles/withTheme';
import { getTransitionProps } from '../transitions/utils';
import SwipeArea from './SwipeArea';
const Fragment = React.Fragment || 'div';
// This value is closed to what browsers are using internally to
// 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;
}
class SwipeableDrawer extends React.Component {
static getDerivedStateFromProps() {
// Reset the maybeSwiping state everytime we receive new properties.
return {
maybeSwiping: false,
};
}
state = {};
componentDidMount() {
if (this.props.variant === 'temporary') {
this.listenTouchStart();
}
}
componentDidUpdate(prevProps) {
const variant = this.props.variant;
const prevVariant = prevProps.variant;
if (variant !== prevVariant) {
if (variant === 'temporary') {
this.listenTouchStart();
} else if (prevVariant === 'temporary') {
this.removeTouchStart();
}
}
}
componentWillUnmount() {
this.removeTouchStart();
this.removeBodyTouchListeners();
// We need to release the lock.
if (nodeThatClaimedTheSwipe === this) {
nodeThatClaimedTheSwipe = null;
}
}
getMaxTranslate() {
return isHorizontal(this.props) ? this.paper.clientWidth : this.paper.clientHeight;
}
getTranslate(current) {
const start = isHorizontal(this.props) ? this.startX : this.startY;
return Math.min(
Math.max(this.props.open ? start - current : this.getMaxTranslate() + start - current, 0),
this.getMaxTranslate(),
);
}
setPosition(translate, options = {}) {
const { mode = null, changeTransition = true } = options;
const anchor = getAnchor(this.props);
const rtlTranslateMultiplier = ['right', 'bottom'].indexOf(anchor) !== -1 ? 1 : -1;
const transform = isHorizontal(this.props)
? `translate(${rtlTranslateMultiplier * translate}px, 0)`
: `translate(0, ${rtlTranslateMultiplier * translate}px)`;
const drawerStyle = this.paper.style;
drawerStyle.webkitTransform = transform;
drawerStyle.transform = transform;
let transition = '';
if (mode) {
transition = this.props.theme.transitions.create(
'all',
getTransitionProps(
{
timeout: this.props.transitionDuration,
},
{
mode,
},
),
);
}
if (changeTransition) {
drawerStyle.webkitTransition = transition;
drawerStyle.transition = transition;
}
if (!this.props.disableBackdropTransition) {
const backdropStyle = this.backdrop.style;
backdropStyle.opacity = 1 - translate / this.getMaxTranslate();
if (changeTransition) {
backdropStyle.webkitTransition = transition;
backdropStyle.transition = transition;
}
}
}
handleBodyTouchStart = event => {
// We are not supposed to hanlde this touch move.
if (nodeThatClaimedTheSwipe !== null && nodeThatClaimedTheSwipe !== this) {
return;
}
const { disableDiscovery, disableSwipeToOpen, open, swipeAreaWidth } = this.props;
const anchor = getAnchor(this.props);
const currentX =
anchor === 'right'
? document.body.offsetWidth - event.touches[0].pageX
: event.touches[0].pageX;
const currentY =
anchor === 'bottom'
? window.innerHeight - event.touches[0].clientY
: event.touches[0].clientY;
if (!open) {
if (disableSwipeToOpen) {
return;
}
if (isHorizontal(this.props)) {
if (currentX > swipeAreaWidth) {
return;
}
} else if (currentY > swipeAreaWidth) {
return;
}
}
nodeThatClaimedTheSwipe = this;
this.startX = currentX;
this.startY = currentY;
this.setState({ maybeSwiping: true });
if (!open && this.paper) {
// the ref may be null when a parent component updates while swiping
this.setPosition(this.getMaxTranslate() + (disableDiscovery ? 20 : -swipeAreaWidth), {
changeTransition: false,
});
}
document.body.addEventListener('touchmove', this.handleBodyTouchMove, { passive: false });
document.body.addEventListener('touchend', this.handleBodyTouchEnd);
// https://plus.google.com/+PaulIrish/posts/KTwfn1Y2238
document.body.addEventListener('touchcancel', this.handleBodyTouchEnd);
};
handleBodyTouchMove = event => {
// the ref may be null when a parent component updates while swiping
if (!this.paper) return;
const anchor = getAnchor(this.props);
const horizontalSwipe = isHorizontal(this.props);
const currentX =
anchor === 'right'
? document.body.offsetWidth - event.touches[0].pageX
: event.touches[0].pageX;
const currentY =
anchor === 'bottom'
? window.innerHeight - event.touches[0].clientY
: event.touches[0].clientY;
// We don't know yet.
if (this.isSwiping == null) {
const dx = Math.abs(currentX - this.startX);
const dy = Math.abs(currentY - this.startY);
// We are likely to be swiping, let's prevent the scroll event on iOS.
if (dx > dy) {
event.preventDefault();
}
const isSwiping = horizontalSwipe
? dx > dy && dx > UNCERTAINTY_THRESHOLD
: dy > dx && dy > UNCERTAINTY_THRESHOLD;
if (
isSwiping === true ||
(horizontalSwipe ? dy > UNCERTAINTY_THRESHOLD : dx > UNCERTAINTY_THRESHOLD)
) {
this.isSwiping = isSwiping;
if (!isSwiping) {
this.handleBodyTouchEnd(event);
return;
}
// Shift the starting point.
this.startX = currentX;
this.startY = currentY;
// Compensate for the part of the drawer displayed on touch start.
if (!this.props.disableDiscovery && !this.props.open) {
if (horizontalSwipe) {
this.startX -= this.props.swipeAreaWidth;
} else {
this.startY -= this.props.swipeAreaWidth;
}
}
}
}
if (!this.isSwiping) {
return;
}
// We are swiping, let's prevent the scroll event on iOS.
event.preventDefault();
this.setPosition(this.getTranslate(horizontalSwipe ? currentX : currentY));
};
handleBodyTouchEnd = event => {
nodeThatClaimedTheSwipe = null;
this.removeBodyTouchListeners();
this.setState({ maybeSwiping: false });
// The swipe wasn't started.
if (!this.isSwiping) {
this.isSwiping = null;
return;
}
const anchor = getAnchor(this.props);
let current;
if (isHorizontal(this.props)) {
current =
anchor === 'right'
? document.body.offsetWidth - event.changedTouches[0].pageX
: event.changedTouches[0].pageX;
} else {
current =
anchor === 'bottom'
? window.innerHeight - event.changedTouches[0].clientY
: event.changedTouches[0].clientY;
}
const translateRatio = this.getTranslate(current) / this.getMaxTranslate();
// We have to open or close after setting swiping to null,
// because only then CSS transition is enabled.
if (translateRatio > 0.5) {
if (this.isSwiping && !this.props.open) {
// Reset the position, the swipe was aborted.
this.setPosition(this.getMaxTranslate(), {
mode: 'enter',
});
} else {
this.props.onClose();
}
} else if (this.isSwiping && !this.props.open) {
this.props.onOpen();
} else {
// Reset the position, the swipe was aborted.
this.setPosition(0, {
mode: 'exit',
});
}
this.isSwiping = null;
};
backdrop = null;
paper = null;
isSwiping = null;
startX = null;
startY = null;
listenTouchStart() {
document.body.addEventListener('touchstart', this.handleBodyTouchStart);
}
removeTouchStart() {
document.body.removeEventListener('touchstart', this.handleBodyTouchStart);
}
removeBodyTouchListeners() {
document.body.removeEventListener('touchmove', this.handleBodyTouchMove, { passive: false });
document.body.removeEventListener('touchend', this.handleBodyTouchEnd);
document.body.removeEventListener('touchcancel', this.handleBodyTouchEnd);
}
handleBackdropRef = node => {
this.backdrop = node ? ReactDOM.findDOMNode(node) : null;
};
handlePaperRef = node => {
this.paper = node ? ReactDOM.findDOMNode(node) : null;
};
render() {
const {
disableBackdropTransition,
disableDiscovery,
disableSwipeToOpen,
ModalProps: { BackdropProps, ...ModalPropsProp } = {},
onOpen,
open,
PaperProps,
swipeAreaWidth,
variant,
...other
} = this.props;
const { maybeSwiping } = this.state;
return (
<Fragment>
<Drawer
open={variant === 'temporary' && maybeSwiping ? true : open}
variant={variant}
ModalProps={{
BackdropProps: {
...BackdropProps,
ref: this.handleBackdropRef,
},
...ModalPropsProp,
}}
PaperProps={{
...PaperProps,
style: { pointerEvents: variant === 'temporary' && !open ? 'none' : '' },
ref: this.handlePaperRef,
}}
{...other}
/>
{!disableDiscovery &&
!disableSwipeToOpen &&
variant === 'temporary' && (
<SwipeArea anchor={other.anchor} swipeAreaWidth={swipeAreaWidth} />
)}
</Fragment>
);
}
}
SwipeableDrawer.propTypes = {
/**
* @ignore
*/
anchor: PropTypes.oneOf(['left', 'top', 'right', 'bottom']),
/**
* Disable the backdrop transition.
* This can improve the FPS on low-end devices.
*/
disableBackdropTransition: PropTypes.bool,
/**
* If `true`, 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.
*/
disableDiscovery: PropTypes.bool,
/**
* If `true`, swipe to open is disabled. This is useful in browsers where swiping triggers
* navigation actions. Swipe to open is disabled on iOS browsers by default.
*/
disableSwipeToOpen: PropTypes.bool,
/**
* @ignore
*/
ModalProps: PropTypes.object,
/**
* Callback fired when the component requests to be closed.
*
* @param {object} event The event source of the callback
*/
onClose: PropTypes.func.isRequired,
/**
* Callback fired when the component requests to be opened.
*
* @param {object} event The event source of the callback
*/
onOpen: PropTypes.func.isRequired,
/**
* If `true`, the drawer is open.
*/
open: PropTypes.bool.isRequired,
/**
* @ignore
*/
PaperProps: PropTypes.object,
/**
* The width of the left most (or right most) area in pixels where the
* drawer can be swiped open from.
*/
swipeAreaWidth: PropTypes.number,
/**
* @ignore
*/
theme: PropTypes.object.isRequired,
/**
* The duration for the transition, in milliseconds.
* You may specify a single timeout for all transitions, or individually with an object.
*/
transitionDuration: PropTypes.oneOfType([
PropTypes.number,
PropTypes.shape({ enter: PropTypes.number, exit: PropTypes.number }),
]),
/**
* @ignore
*/
variant: PropTypes.oneOf(['permanent', 'persistent', 'temporary']),
};
SwipeableDrawer.defaultProps = {
anchor: 'left',
disableBackdropTransition: false,
disableDiscovery: false,
disableSwipeToOpen:
typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent),
swipeAreaWidth: 20,
transitionDuration: { enter: duration.enteringScreen, exit: duration.leavingScreen },
variant: 'temporary', // Mobile first.
};
export default withTheme()(SwipeableDrawer);