react-swipeable
Version:
React Swipe event handler component & hook
257 lines (222 loc) • 7.69 kB
JavaScript
/* global document */
import React from 'react'
import PropTypes from 'prop-types'
const defaultProps = {
preventDefaultTouchmoveEvent: false,
delta: 10,
rotationAngle: 0,
trackMouse: false,
trackTouch: true
}
const initialState = {
xy: [0, 0],
swiping: false,
eventData: undefined,
start: undefined
}
export const LEFT = 'Left'
export const RIGHT = 'Right'
export const UP = 'Up'
export const DOWN = 'Down'
const touchStart = 'touchstart'
const touchMove = 'touchmove'
const touchEnd = 'touchend'
const mouseMove = 'mousemove'
const mouseUp = 'mouseup'
function getDirection(absX, absY, deltaX, deltaY) {
if (absX > absY) {
if (deltaX > 0) {
return LEFT
}
return RIGHT
} else if (deltaY > 0) {
return UP
}
return DOWN
}
function rotateXYByAngle(pos, angle) {
if (angle === 0) return pos
const angleInRadians = (Math.PI / 180) * angle
const x = pos[0] * Math.cos(angleInRadians) + pos[1] * Math.sin(angleInRadians)
const y = pos[1] * Math.cos(angleInRadians) - pos[0] * Math.sin(angleInRadians)
return [x, y]
}
function getHandlers(set, handlerProps) {
const onStart = event => {
// if more than a single touch don't track, for now...
if (event.touches && event.touches.length > 1) return
set((state, props) => {
// setup mouse listeners on document to track swipe since swipe can leave container
if (props.trackMouse) {
document.addEventListener(mouseMove, onMove)
document.addEventListener(mouseUp, onUp)
}
const { clientX, clientY } = event.touches ? event.touches[0] : event
const xy = rotateXYByAngle([clientX, clientY], props.rotationAngle)
return {
...state,
...initialState,
eventData: { initial: [...xy] },
xy,
start: event.timeStamp || 0
}
})
}
const onMove = event => {
set((state, props) => {
if (!state.xy[0] || !state.xy[1] || (event.touches && event.touches.length > 1)) {
return state
}
const { clientX, clientY } = event.touches ? event.touches[0] : event
const [x, y] = rotateXYByAngle([clientX, clientY], props.rotationAngle)
const deltaX = state.xy[0] - x
const deltaY = state.xy[1] - y
const absX = Math.abs(deltaX)
const absY = Math.abs(deltaY)
const time = (event.timeStamp || 0) - state.start
const velocity = Math.sqrt(absX * absX + absY * absY) / (time || 1)
// if swipe is under delta and we have not started to track a swipe: skip update
if (absX < props.delta && absY < props.delta && !state.swiping) return state
const dir = getDirection(absX, absY, deltaX, deltaY)
const eventData = { ...state.eventData, event, absX, absY, deltaX, deltaY, velocity, dir }
props.onSwiping && props.onSwiping(eventData)
// track if a swipe is cancelable(handler for swiping or swiped(dir) exists)
// so we can call preventDefault if needed
let cancelablePageSwipe = false
if (props.onSwiping || props.onSwiped || props[`onSwiped${dir}`]) {
cancelablePageSwipe = true
}
if (
cancelablePageSwipe &&
props.preventDefaultTouchmoveEvent &&
props.trackTouch &&
event.cancelable
)
event.preventDefault()
return { ...state, eventData, swiping: true }
})
}
const onEnd = event => {
set((state, props) => {
let eventData
if (state.swiping) {
eventData = { ...state.eventData, event }
props.onSwiped && props.onSwiped(eventData)
props[`onSwiped${eventData.dir}`] && props[`onSwiped${eventData.dir}`](eventData)
}
return { ...state, ...initialState, eventData }
})
}
const cleanUpMouse = () => {
// safe to just call removeEventListener
document.removeEventListener(mouseMove, onMove)
document.removeEventListener(mouseUp, onUp)
}
const onUp = e => {
cleanUpMouse()
onEnd(e)
}
const attachTouch = el => {
if (el && el.addEventListener) {
// attach touch event listeners and handlers
const tls = [[touchStart, onStart], [touchMove, onMove], [touchEnd, onEnd]]
tls.forEach(([e, h]) => el.addEventListener(e, h))
// return properly scoped cleanup method for removing listeners
return () => tls.forEach(([e, h]) => el.removeEventListener(e, h))
}
}
const onRef = el => {
// "inline" ref functions are called twice on render, once with null then again with DOM element
// ignore null here
if (el === null) return
set((state, props) => {
// if the same DOM el as previous just return state
if (state.el === el) return state
let addState = {}
// if new DOM el clean up old DOM and reset cleanUpTouch
if (state.el && state.el !== el && state.cleanUpTouch) {
state.cleanUpTouch()
addState.cleanUpTouch = null
}
// only attach if we want to track touch
if (props.trackTouch && el) {
addState.cleanUpTouch = attachTouch(el)
}
// store event attached DOM el for comparison, clean up, and re-attachment
return { ...state, el, ...addState }
})
}
// set ref callback to attach touch event listeners
const output = { ref: onRef }
// if track mouse attach mouse down listener
if (handlerProps.trackMouse) {
output.onMouseDown = onStart
}
return [output, attachTouch]
}
function updateTransientState(state, props, attachTouch) {
let addState = {}
// clean up touch handlers if no longer tracking touches
if (!props.trackTouch && state.cleanUpTouch) {
state.cleanUpTouch()
addState.cleanUpTouch = null
} else if (props.trackTouch && !state.cleanUpTouch) {
// attach/re-attach touch handlers
if (state.el) {
addState.cleanUpTouch = attachTouch(state.el)
}
}
return { ...state, ...addState }
}
export function useSwipeable(props) {
const { trackMouse } = props
const transientState = React.useRef({ ...initialState, type: 'hook' })
const transientProps = React.useRef()
transientProps.current = { ...defaultProps, ...props }
const [handlers, attachTouch] = React.useMemo(
() =>
getHandlers(
cb => (transientState.current = cb(transientState.current, transientProps.current)),
{ trackMouse }
),
[trackMouse]
)
transientState.current = updateTransientState(
transientState.current,
transientProps.current,
attachTouch
)
return handlers
}
export class Swipeable extends React.PureComponent {
static propTypes = {
onSwiped: PropTypes.func,
onSwiping: PropTypes.func,
onSwipedUp: PropTypes.func,
onSwipedRight: PropTypes.func,
onSwipedDown: PropTypes.func,
onSwipedLeft: PropTypes.func,
delta: PropTypes.number,
preventDefaultTouchmoveEvent: PropTypes.bool,
nodeName: PropTypes.string,
trackMouse: PropTypes.bool,
trackTouch: PropTypes.bool,
innerRef: PropTypes.func,
rotationAngle: PropTypes.number
}
static defaultProps = defaultProps
constructor(props) {
super(props)
this.transientState = { ...initialState, type: 'class' }
}
_set = cb => {
this.transientState = cb(this.transientState, this.props)
}
render() {
const { className, style, nodeName = 'div', innerRef, children, trackMouse } = this.props
const [handlers, attachTouch] = getHandlers(this._set, { trackMouse })
this.transientState = updateTransientState(this.transientState, this.props, attachTouch)
const ref = innerRef ? el => (innerRef(el), handlers.ref(el)) : handlers.ref
return React.createElement(nodeName, { ...handlers, className, style, ref }, children)
}
}