UNPKG

react-swipeable-views

Version:
1,028 lines (892 loc) 26.6 kB
import * as React from 'react'; import PropTypes from 'prop-types'; import warning from 'warning'; import { constant, checkIndexBounds, computeIndex, getDisplaySameSlide, } from 'react-swipeable-views-core'; function addEventListener(node, event, handler, options) { node.addEventListener(event, handler, options); return { remove() { node.removeEventListener(event, handler, options); }, }; } const styles = { container: { direction: 'ltr', display: 'flex', willChange: 'transform', }, slide: { width: '100%', WebkitFlexShrink: 0, flexShrink: 0, overflow: 'auto', }, }; const axisProperties = { root: { x: { overflowX: 'hidden', }, 'x-reverse': { overflowX: 'hidden', }, y: { overflowY: 'hidden', }, 'y-reverse': { overflowY: 'hidden', }, }, flexDirection: { x: 'row', 'x-reverse': 'row-reverse', y: 'column', 'y-reverse': 'column-reverse', }, transform: { x: translate => `translate(${-translate}%, 0)`, 'x-reverse': translate => `translate(${translate}%, 0)`, y: translate => `translate(0, ${-translate}%)`, 'y-reverse': translate => `translate(0, ${translate}%)`, }, length: { x: 'width', 'x-reverse': 'width', y: 'height', 'y-reverse': 'height', }, rotationMatrix: { x: { x: [1, 0], y: [0, 1], }, 'x-reverse': { x: [-1, 0], y: [0, 1], }, y: { x: [0, 1], y: [1, 0], }, 'y-reverse': { x: [0, -1], y: [1, 0], }, }, scrollPosition: { x: 'scrollLeft', 'x-reverse': 'scrollLeft', y: 'scrollTop', 'y-reverse': 'scrollTop', }, scrollLength: { x: 'scrollWidth', 'x-reverse': 'scrollWidth', y: 'scrollHeight', 'y-reverse': 'scrollHeight', }, clientLength: { x: 'clientWidth', 'x-reverse': 'clientWidth', y: 'clientHeight', 'y-reverse': 'clientHeight', }, }; function createTransition(property, options) { const { duration, easeFunction, delay } = options; return `${property} ${duration} ${easeFunction} ${delay}`; } // We are using a 2x2 rotation matrix. function applyRotationMatrix(touch, axis) { const rotationMatrix = axisProperties.rotationMatrix[axis]; return { pageX: rotationMatrix.x[0] * touch.pageX + rotationMatrix.x[1] * touch.pageY, pageY: rotationMatrix.y[0] * touch.pageX + rotationMatrix.y[1] * touch.pageY, }; } function adaptMouse(event) { event.touches = [{ pageX: event.pageX, pageY: event.pageY }]; return event; } export function getDomTreeShapes(element, rootNode) { let domTreeShapes = []; while (element && element !== rootNode && element !== document.body) { // We reach a Swipeable View, no need to look higher in the dom tree. if (element.hasAttribute('data-swipeable')) { break; } const style = window.getComputedStyle(element); if ( // Ignore the scroll children if the element is absolute positioned. style.getPropertyValue('position') === 'absolute' || // Ignore the scroll children if the element has an overflowX hidden style.getPropertyValue('overflow-x') === 'hidden' ) { domTreeShapes = []; } else if ( (element.clientWidth > 0 && element.scrollWidth > element.clientWidth) || (element.clientHeight > 0 && element.scrollHeight > element.clientHeight) ) { // Ignore the nodes that have no width. // Keep elements with a scroll domTreeShapes.push({ element, scrollWidth: element.scrollWidth, scrollHeight: element.scrollHeight, clientWidth: element.clientWidth, clientHeight: element.clientHeight, scrollLeft: element.scrollLeft, scrollTop: element.scrollTop, }); } element = element.parentNode; } return domTreeShapes; } // 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 nodeWhoClaimedTheScroll = null; export function findNativeHandler(params) { const { domTreeShapes, pageX, startX, axis } = params; return domTreeShapes.some(shape => { // Determine if we are going backward or forward. let goingForward = pageX >= startX; if (axis === 'x' || axis === 'y') { goingForward = !goingForward; } // scrollTop is not always be an integer. // https://github.com/jquery/api.jquery.com/issues/608 const scrollPosition = Math.round(shape[axisProperties.scrollPosition[axis]]); const areNotAtStart = scrollPosition > 0; const areNotAtEnd = scrollPosition + shape[axisProperties.clientLength[axis]] < shape[axisProperties.scrollLength[axis]]; if ((goingForward && areNotAtEnd) || (!goingForward && areNotAtStart)) { nodeWhoClaimedTheScroll = shape.element; return true; } return false; }); } export const SwipeableViewsContext = React.createContext(); if (process.env.NODE_ENV !== 'production') { SwipeableViewsContext.displayName = 'SwipeableViewsContext'; } class SwipeableViews extends React.Component { rootNode = null; containerNode = null; ignoreNextScrollEvents = false; viewLength = 0; startX = 0; lastX = 0; vx = 0; startY = 0; isSwiping = undefined; started = false; startIndex = 0; transitionListener = null; touchMoveListener = null; activeSlide = null; indexCurrent = null; firstRenderTimeout = null; constructor(props) { super(props); if (process.env.NODE_ENV !== 'production') { checkIndexBounds(props); } this.state = { indexLatest: props.index, // Set to true as soon as the component is swiping. // It's the state counter part of this.isSwiping. isDragging: false, // Help with SSR logic and lazy loading logic. renderOnlyActive: !props.disableLazyLoading, heightLatest: 0, // Let the render method that we are going to display the same slide than previously. displaySameSlide: true, }; this.setIndexCurrent(props.index); } componentDidMount() { // Subscribe to transition end events. this.transitionListener = addEventListener(this.containerNode, 'transitionend', event => { if (event.target !== this.containerNode) { return; } this.handleTransitionEnd(); }); // Block the thread to handle that event. this.touchMoveListener = addEventListener( this.rootNode, 'touchmove', event => { // Handling touch events is disabled. if (this.props.disabled) { return; } this.handleSwipeMove(event); }, { passive: false, }, ); if (!this.props.disableLazyLoading) { this.firstRenderTimeout = setTimeout(() => { this.setState({ renderOnlyActive: false, }); }, 0); } // Send all functions in an object if action param is set. if (this.props.action) { this.props.action({ updateHeight: this.updateHeight, }); } } // eslint-disable-next-line camelcase,react/sort-comp UNSAFE_componentWillReceiveProps(nextProps) { const { index } = nextProps; if (typeof index === 'number' && index !== this.props.index) { if (process.env.NODE_ENV !== 'production') { checkIndexBounds(nextProps); } this.setIndexCurrent(index); this.setState({ // If true, we are going to change the children. We shoudn't animate it. displaySameSlide: getDisplaySameSlide(this.props, nextProps), indexLatest: index, }); } } componentWillUnmount() { this.transitionListener.remove(); this.touchMoveListener.remove(); clearTimeout(this.firstRenderTimeout); } getSwipeableViewsContext() { return { slideUpdateHeight: () => { this.updateHeight(); }, }; } setIndexCurrent(indexCurrent) { if (!this.props.animateTransitions && this.indexCurrent !== indexCurrent) { this.handleTransitionEnd(); } this.indexCurrent = indexCurrent; if (this.containerNode) { const { axis } = this.props; const transform = axisProperties.transform[axis](indexCurrent * 100); this.containerNode.style.WebkitTransform = transform; this.containerNode.style.transform = transform; } } setRootNode = node => { this.rootNode = node; }; setContainerNode = node => { this.containerNode = node; }; setActiveSlide = node => { this.activeSlide = node; this.updateHeight(); }; handleSwipeStart = event => { const { axis } = this.props; const touch = applyRotationMatrix(event.touches[0], axis); this.viewLength = this.rootNode.getBoundingClientRect()[axisProperties.length[axis]]; this.startX = touch.pageX; this.lastX = touch.pageX; this.vx = 0; this.startY = touch.pageY; this.isSwiping = undefined; this.started = true; const computedStyle = window.getComputedStyle(this.containerNode); const transform = computedStyle.getPropertyValue('-webkit-transform') || computedStyle.getPropertyValue('transform'); if (transform && transform !== 'none') { const transformValues = transform .split('(')[1] .split(')')[0] .split(','); const rootStyle = window.getComputedStyle(this.rootNode); const tranformNormalized = applyRotationMatrix( { pageX: parseInt(transformValues[4], 10), pageY: parseInt(transformValues[5], 10), }, axis, ); this.startIndex = -tranformNormalized.pageX / (this.viewLength - parseInt(rootStyle.paddingLeft, 10) - parseInt(rootStyle.paddingRight, 10)) || 0; } }; handleSwipeMove = event => { // The touch start event can be cancel. // Makes sure we set a starting point. if (!this.started) { this.handleTouchStart(event); return; } // We are not supposed to hanlde this touch move. if (nodeWhoClaimedTheScroll !== null && nodeWhoClaimedTheScroll !== this.rootNode) { return; } const { axis, children, ignoreNativeScroll, onSwitching, resistance } = this.props; const touch = applyRotationMatrix(event.touches[0], axis); // We don't know yet. if (this.isSwiping === undefined) { const dx = Math.abs(touch.pageX - this.startX); const dy = Math.abs(touch.pageY - this.startY); const isSwiping = dx > dy && dx > constant.UNCERTAINTY_THRESHOLD; // We let the parent handle the scroll. if ( !resistance && (axis === 'y' || axis === 'y-reverse') && ((this.indexCurrent === 0 && this.startX < touch.pageX) || (this.indexCurrent === React.Children.count(this.props.children) - 1 && this.startX > touch.pageX)) ) { this.isSwiping = false; return; } // We are likely to be swiping, let's prevent the scroll event. if (dx > dy) { event.preventDefault(); } if (isSwiping === true || dy > constant.UNCERTAINTY_THRESHOLD) { this.isSwiping = isSwiping; this.startX = touch.pageX; // Shift the starting point. return; // Let's wait the next touch event to move something. } } if (this.isSwiping !== true) { return; } // We are swiping, let's prevent the scroll event. event.preventDefault(); // Low Pass filter. this.vx = this.vx * 0.5 + (touch.pageX - this.lastX) * 0.5; this.lastX = touch.pageX; const { index, startX } = computeIndex({ children, resistance, pageX: touch.pageX, startIndex: this.startIndex, startX: this.startX, viewLength: this.viewLength, }); // Add support for native scroll elements. if (nodeWhoClaimedTheScroll === null && !ignoreNativeScroll) { const domTreeShapes = getDomTreeShapes(event.target, this.rootNode); const hasFoundNativeHandler = findNativeHandler({ domTreeShapes, startX: this.startX, pageX: touch.pageX, axis, }); // We abort the touch move handler. if (hasFoundNativeHandler) { return; } } // We are moving toward the edges. if (startX) { this.startX = startX; } else if (nodeWhoClaimedTheScroll === null) { nodeWhoClaimedTheScroll = this.rootNode; } this.setIndexCurrent(index); const callback = () => { if (onSwitching) { onSwitching(index, 'move'); } }; if (this.state.displaySameSlide || !this.state.isDragging) { this.setState( { displaySameSlide: false, isDragging: true, }, callback, ); } callback(); }; handleSwipeEnd = () => { nodeWhoClaimedTheScroll = null; // The touch start event can be cancel. // Makes sure that a starting point is set. if (!this.started) { return; } this.started = false; if (this.isSwiping !== true) { return; } const indexLatest = this.state.indexLatest; const indexCurrent = this.indexCurrent; const delta = indexLatest - indexCurrent; let indexNew; // Quick movement if (Math.abs(this.vx) > this.props.threshold) { if (this.vx > 0) { indexNew = Math.floor(indexCurrent); } else { indexNew = Math.ceil(indexCurrent); } } else if (Math.abs(delta) > this.props.hysteresis) { // Some hysteresis with indexLatest. indexNew = delta > 0 ? Math.floor(indexCurrent) : Math.ceil(indexCurrent); } else { indexNew = indexLatest; } const indexMax = React.Children.count(this.props.children) - 1; if (indexNew < 0) { indexNew = 0; } else if (indexNew > indexMax) { indexNew = indexMax; } this.setIndexCurrent(indexNew); this.setState( { indexLatest: indexNew, isDragging: false, }, () => { if (this.props.onSwitching) { this.props.onSwitching(indexNew, 'end'); } if (this.props.onChangeIndex && indexNew !== indexLatest) { this.props.onChangeIndex(indexNew, indexLatest, { reason: 'swipe', }); } // Manually calling handleTransitionEnd in that case as isn't otherwise. if (indexCurrent === indexLatest) { this.handleTransitionEnd(); } }, ); }; handleTouchStart = event => { if (this.props.onTouchStart) { this.props.onTouchStart(event); } this.handleSwipeStart(event); }; handleTouchEnd = event => { if (this.props.onTouchEnd) { this.props.onTouchEnd(event); } this.handleSwipeEnd(event); }; handleMouseDown = event => { if (this.props.onMouseDown) { this.props.onMouseDown(event); } event.persist(); this.handleSwipeStart(adaptMouse(event)); }; handleMouseUp = event => { if (this.props.onMouseUp) { this.props.onMouseUp(event); } this.handleSwipeEnd(adaptMouse(event)); }; handleMouseLeave = event => { if (this.props.onMouseLeave) { this.props.onMouseLeave(event); } // Filter out events if (this.started) { this.handleSwipeEnd(adaptMouse(event)); } }; handleMouseMove = event => { if (this.props.onMouseMove) { this.props.onMouseMove(event); } // Filter out events if (this.started) { this.handleSwipeMove(adaptMouse(event)); } }; handleScroll = event => { if (this.props.onScroll) { this.props.onScroll(event); } // Ignore events bubbling up. if (event.target !== this.rootNode) { return; } if (this.ignoreNextScrollEvents) { this.ignoreNextScrollEvents = false; return; } const indexLatest = this.state.indexLatest; const indexNew = Math.ceil(event.target.scrollLeft / event.target.clientWidth) + indexLatest; this.ignoreNextScrollEvents = true; // Reset the scroll position. event.target.scrollLeft = 0; if (this.props.onChangeIndex && indexNew !== indexLatest) { this.props.onChangeIndex(indexNew, indexLatest, { reason: 'focus', }); } }; updateHeight = () => { if (this.activeSlide !== null) { const child = this.activeSlide.children[0]; if ( child !== undefined && child.offsetHeight !== undefined && this.state.heightLatest !== child.offsetHeight ) { this.setState({ heightLatest: child.offsetHeight, }); } } }; handleTransitionEnd() { if (!this.props.onTransitionEnd) { return; } // Filters out when changing the children if (this.state.displaySameSlide) { return; } // The rest callback is triggered when swiping. It's just noise. // We filter it out. if (!this.state.isDragging) { this.props.onTransitionEnd(); } } render() { const { action, animateHeight, animateTransitions, axis, children, containerStyle: containerStyleProp, disabled, disableLazyLoading, enableMouseEvents, hysteresis, ignoreNativeScroll, index, onChangeIndex, onSwitching, onTransitionEnd, resistance, slideStyle: slideStyleProp, slideClassName, springConfig, style, threshold, ...other } = this.props; const { displaySameSlide, heightLatest, indexLatest, isDragging, renderOnlyActive, } = this.state; const touchEvents = !disabled ? { onTouchStart: this.handleTouchStart, onTouchEnd: this.handleTouchEnd, } : {}; const mouseEvents = !disabled && enableMouseEvents ? { onMouseDown: this.handleMouseDown, onMouseUp: this.handleMouseUp, onMouseLeave: this.handleMouseLeave, onMouseMove: this.handleMouseMove, } : {}; // There is no point to animate if we are already providing a height. warning( !animateHeight || !containerStyleProp || !containerStyleProp.height, `react-swipeable-view: You are setting animateHeight to true but you are also providing a custom height. The custom height has a higher priority than the animateHeight property. So animateHeight is most likely having no effect at all.`, ); const slideStyle = Object.assign({}, styles.slide, slideStyleProp); let transition; let WebkitTransition; if (isDragging || !animateTransitions || displaySameSlide) { transition = 'all 0s ease 0s'; WebkitTransition = 'all 0s ease 0s'; } else { transition = createTransition('transform', springConfig); WebkitTransition = createTransition('-webkit-transform', springConfig); if (heightLatest !== 0) { const additionalTranstion = `, ${createTransition('height', springConfig)}`; transition += additionalTranstion; WebkitTransition += additionalTranstion; } } const containerStyle = { height: null, WebkitFlexDirection: axisProperties.flexDirection[axis], flexDirection: axisProperties.flexDirection[axis], WebkitTransition, transition, }; // Apply the styles for SSR considerations if (!renderOnlyActive) { const transform = axisProperties.transform[axis](this.indexCurrent * 100); containerStyle.WebkitTransform = transform; containerStyle.transform = transform; } if (animateHeight) { containerStyle.height = heightLatest; } return ( <SwipeableViewsContext.Provider value={this.getSwipeableViewsContext()}> <div ref={this.setRootNode} style={Object.assign({}, axisProperties.root[axis], style)} {...other} {...touchEvents} {...mouseEvents} onScroll={this.handleScroll} > <div ref={this.setContainerNode} style={Object.assign({}, containerStyle, styles.container, containerStyleProp)} className="react-swipeable-view-container" > {React.Children.map(children, (child, indexChild) => { if (renderOnlyActive && indexChild !== indexLatest) { return null; } warning( React.isValidElement(child), `react-swipeable-view: one of the children provided is invalid: ${child}. We are expecting a valid React Element`, ); let ref; let hidden = true; if (indexChild === indexLatest) { hidden = false; if (animateHeight) { ref = this.setActiveSlide; slideStyle.overflowY = 'hidden'; } } return ( <div ref={ref} style={slideStyle} className={slideClassName} aria-hidden={hidden} data-swipeable="true" > {child} </div> ); })} </div> </div> </SwipeableViewsContext.Provider> ); } } // Added as an ads for people using the React dev tools in production. // So they know, the tool used to build the awesome UI they // are looking at/retro engineering. SwipeableViews.displayName = 'ReactSwipableView'; SwipeableViews.propTypes = { /** * This is callback property. It's called by the component on mount. * This is useful when you want to trigger an action programmatically. * It currently only supports updateHeight() action. * * @param {object} actions This object contains all posible actions * that can be triggered programmatically. */ action: PropTypes.func, /** * If `true`, the height of the container will be animated to match the current slide height. * Animating another style property has a negative impact regarding performance. */ animateHeight: PropTypes.bool, /** * If `false`, changes to the index prop will not cause an animated transition. */ animateTransitions: PropTypes.bool, /** * The axis on which the slides will slide. */ axis: PropTypes.oneOf(['x', 'x-reverse', 'y', 'y-reverse']), /** * Use this property to provide your slides. */ children: PropTypes.node.isRequired, /** * This is the inlined style that will be applied * to each slide container. */ containerStyle: PropTypes.object, /** * If `true`, it will disable touch events. * This is useful when you want to prohibit the user from changing slides. */ disabled: PropTypes.bool, /** * This is the config used to disable lazyloding, * if `true` will render all the views in first rendering. */ disableLazyLoading: PropTypes.bool, /** * If `true`, it will enable mouse events. * This will allow the user to perform the relevant swipe actions with a mouse. */ enableMouseEvents: PropTypes.bool, /** * Configure hysteresis between slides. This value determines how far * should user swipe to switch slide. */ hysteresis: PropTypes.number, /** * If `true`, it will ignore native scroll container. * It can be used to filter out false positive that blocks the swipe. */ ignoreNativeScroll: PropTypes.bool, /** * This is the index of the slide to show. * This is useful when you want to change the default slide shown. * Or when you have tabs linked to each slide. */ index: PropTypes.number, /** * This is callback prop. It's call by the * component when the shown slide change after a swipe made by the user. * This is useful when you have tabs linked to each slide. * * @param {integer} index This is the current index of the slide. * @param {integer} indexLatest This is the oldest index of the slide. * @param {object} meta Meta data containing more information about the event. */ onChangeIndex: PropTypes.func, /** * @ignore */ onMouseDown: PropTypes.func, /** * @ignore */ onMouseLeave: PropTypes.func, /** * @ignore */ onMouseMove: PropTypes.func, /** * @ignore */ onMouseUp: PropTypes.func, /** * @ignore */ onScroll: PropTypes.func, /** * This is callback prop. It's called by the * component when the slide switching. * This is useful when you want to implement something corresponding * to the current slide position. * * @param {integer} index This is the current index of the slide. * @param {string} type Can be either `move` or `end`. */ onSwitching: PropTypes.func, /** * @ignore */ onTouchEnd: PropTypes.func, /** * @ignore */ onTouchMove: PropTypes.func, /** * @ignore */ onTouchStart: PropTypes.func, /** * The callback that fires when the animation comes to a rest. * This is useful to defer CPU intensive task. */ onTransitionEnd: PropTypes.func, /** * If `true`, it will add bounds effect on the edges. */ resistance: PropTypes.bool, /** * This is the className that will be applied * on the slide component. */ slideClassName: PropTypes.string, /** * This is the inlined style that will be applied * on the slide component. */ slideStyle: PropTypes.object, /** * This is the config used to create CSS transitions. * This is useful to change the dynamic of the transition. */ springConfig: PropTypes.shape({ delay: PropTypes.string, duration: PropTypes.string, easeFunction: PropTypes.string, }), /** * This is the inlined style that will be applied * on the root component. */ style: PropTypes.object, /** * This is the threshold used for detecting a quick swipe. * If the computed speed is above this value, the index change. */ threshold: PropTypes.number, }; SwipeableViews.defaultProps = { animateHeight: false, animateTransitions: true, axis: 'x', disabled: false, disableLazyLoading: false, enableMouseEvents: false, hysteresis: 0.6, ignoreNativeScroll: false, index: 0, threshold: 5, springConfig: { duration: '0.35s', easeFunction: 'cubic-bezier(0.15, 0.3, 0.25, 1)', delay: '0s', }, resistance: false, }; export default SwipeableViews;