Last active
August 19, 2020 08:14
-
-
Save th3hunt/c59ba1a356fd5b584b488b2af7eb8d7d to your computer and use it in GitHub Desktop.
Pan gesture helpers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Pan | |
* --- | |
* | |
* A collection one-finger Pan gesture recognizers. | |
* | |
* A pan is an omnidirectional one- or two-finger gesture that expands the field of view. | |
* Drag is typically used with pan. | |
* | |
*/ | |
'use strict'; | |
function checkArgs(gesture, ...args) { | |
const [el, callback] = args; | |
if (!el) { | |
throw new Error(`${gesture} expects el to be a DOM element`); | |
} | |
if (!callback) { | |
throw new Error(`${gesture} expects a callback function`); | |
} | |
} | |
// a Pan gesture should involve only 1 touch point | |
const isPan = e => e.touches.length === 1; | |
// the distance between two points with a little bit of help from Pythagoras | |
const distance = (deltaX, deltaY) => Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)); | |
function touchListener(el, {start, move, end}) { | |
const onMove = move; | |
const onEnd = e => { | |
if (end) { | |
end(e); | |
} | |
el.removeEventListener('touchmove', onMove); | |
el.removeEventListener('touchend', onEnd); | |
}; | |
const onStart = e => { | |
if (start) { | |
start(e); | |
} | |
el.addEventListener('touchmove', move, {passive: true}); | |
el.addEventListener('touchend', onEnd); | |
}; | |
el.addEventListener('touchstart', onStart); | |
const off = () => { | |
el.removeEventListener('touchstart', onStart); | |
el.removeEventListener('touchmove', onMove); | |
el.removeEventListener('touchend', onEnd); | |
}; | |
return {off}; | |
} | |
function detectPan(el, {filter, callback}) { | |
let startX; | |
let startY; | |
let deltaX; | |
let deltaY; | |
let priorX; | |
let priorY; | |
let priorTime; | |
let velocity = 0; | |
const {onMove, onStart, onEnd} = typeof callback === 'object' ? callback : {onMove: callback}; | |
return touchListener(el, { | |
start(e) { | |
if (!isPan(e)) { | |
return; | |
} | |
const touch = e.touches[0]; | |
startX = touch.clientX; | |
startY = touch.clientY; | |
priorX = startX; | |
priorY = startY; | |
priorTime = Date.now(); | |
if (onStart) { | |
onStart(e, {startX, startY}); | |
} | |
}, | |
move(e) { | |
if (!isPan(e)) { | |
return; | |
} | |
const touch = e.touches[0]; | |
startX = startX !== void(0) ? startX : touch.clientX; | |
startY = startY !== void(0) ? startY : touch.clientY; | |
deltaX = touch.clientX - startX; | |
deltaY = touch.clientY - startY; | |
const now = Date.now(); | |
velocity = distance(touch.clientX - priorX, priorY - touch.clientY) / (now - priorTime); | |
priorX = touch.clientX; | |
priorY = touch.clientY; | |
priorTime = now; | |
if (filter(e, {deltaX, deltaY})) { | |
onMove(e, {deltaX, deltaY, velocity}); | |
} | |
}, | |
end(e) { | |
const touch = e.changedTouches[0]; | |
deltaX = touch.clientX - startX; | |
deltaY = touch.clientY - startY; | |
if (onEnd) { | |
onEnd(e, {deltaX, deltaY, velocity}); | |
} | |
} | |
}); | |
} | |
/** | |
* Recognize a pan gesture within the given element. | |
* | |
* @param {element} el - the element that defines the area to watch for the gesture | |
* @param {onPanMove|panCallbacks} callback - the callback to call upon recognizing the gesture | |
* @param {object} options - options | |
* @param {number} options.threshold - the minimal pan distance required before recognizing | |
* @returns {{off}} - the listener, has an `off` method to call to remove it from the element | |
*/ | |
export function onPan(el, callback, options = {}) { | |
checkArgs('onPan', ...arguments); | |
const threshold = options.threshold || 1; | |
const filter = (e, {deltaX, deltaY}) => { | |
return deltaY > threshold || | |
deltaX > threshold || | |
distance(deltaX, deltaY) > threshold; | |
}; | |
return detectPan(el, {filter, callback}, options); | |
} | |
/** | |
* Recognize a downwards pan gesture within the given element. | |
* | |
* @param {element} el - the element that defines the area to watch for the gesture | |
* @param {onPanMove|panCallbacks} callback(e, {deltaY}) - the callback to call upon recognizing the gesture | |
* @param {object} options - options | |
* @param {number} options.threshold - the minimal pan distance required before recognizing | |
* @returns {{off}} - the listener, has an `off` method to call to remove it from the element | |
*/ | |
export function onPanDown(el, callback, options = {}) { | |
checkArgs('onPanDown', ...arguments); | |
const threshold = options.threshold || 1; | |
const filter = (e, {deltaY}) => deltaY > threshold; | |
return detectPan(el, {filter, callback}, options); | |
} | |
/** | |
* Recognize a left pan gesture within the given element. | |
* | |
* @param {element} el - the element that defines the area to watch for the gesture | |
* @param {onPanMove|panCallbacks} callback(e, {deltaY}) - the callback to call upon recognizing the gesture | |
* @param {object} options - options | |
* @param {number} options.threshold - the minimal pan distance required before recognizing | |
* @returns {{off}} - the listener, has an `off` method to call to remove it from the element | |
*/ | |
export function onPanLeft(el, callback, options = {}) { | |
checkArgs('onPanLeft', ...arguments); | |
const threshold = options.threshold || 1; | |
const filter = (e, {deltaX}) => deltaX < 0 && Math.abs(deltaX) > threshold; | |
return detectPan(el, {filter, callback}, options); | |
} | |
/** | |
* Recognize a right pan gesture within the given element. | |
* | |
* @param {element} el - the element that defines the area to watch for the gesture | |
* @param {onPanMove|panCallbacks} callback(e, {deltaY}) - the callback to call upon recognizing the gesture | |
* @param {object} options - options | |
* @param {number} options.threshold - the minimal pan distance required before recognizing | |
* @returns {{off}} - the listener, has an `off` method to call to remove it from the element | |
*/ | |
export function onPanRight(el, callback, options = {}) { | |
checkArgs('onPanRight', ...arguments); | |
const threshold = options.threshold || 1; | |
const filter = (e, {deltaX}) => deltaX > 0 && deltaX > threshold; | |
return detectPan(el, {filter, callback}, options); | |
} | |
/** | |
* @callback onPanMove | |
* @param {TouchEvent} e - the touch event that triggered the pan | |
* @param {object} deltas - the pan deltas since the first touchmove | |
* @param {number} deltas.deltaX - the X delta | |
* @param {number} deltas.deltaY - the Y delta | |
*/ | |
/** | |
* @typedef {object} panCallbacks | |
* @property {function} onStart - callback to execute when the user touches the screen with 1 finger | |
* @property {function} onMove - callback to execute when the user moves 1 finger on the screen | |
* @property {function} onEnd - callback to execute when the user lifts the finger from the screen | |
*/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const _ = require('lodash') | |
const EventEmitter = require('events') | |
const debug = require('debug')(); | |
const support = require('../dom/support'); | |
const {onPan} = require('../dom/pan'); | |
const defaultState = { | |
activated: false, | |
busy: false, | |
distance: 0, | |
startingPositionY: 0, | |
isReadyToRefresh: false | |
}; | |
const transformProperty = support.getTransformCssProperty(); | |
function getEl(selector) { | |
return _.isObject(selector) | |
? selector | |
: document.querySelector(selector); | |
} | |
function translateY(pixels) { | |
return 'translateY(' + pixels + 'px)'; | |
} | |
function rotate(degrees) { | |
return 'rotate(' + degrees + 'deg)'; | |
} | |
/** | |
* PullToRefresh | |
* -------------- | |
* | |
* a pull to refresh component that works with specific markup/css | |
* | |
*/ | |
function PullToRefresh(options) { | |
_.bindAll(this, 'refresh', 'reset', 'pullStart', 'pullDown', 'pullEnd'); | |
this.options = _.defaults(options || {}, _.result(this, 'defaults')); | |
this.state = _.clone(defaultState); | |
this.initialize(this.options); | |
Object.defineProperty(this, 'isActivated', { | |
get: function () { | |
return this.state.activated; | |
} | |
}); | |
} | |
_.extend(PullToRefresh.prototype, EventEmitter, { | |
defaults: { | |
pullEl: '.ptr-pull', | |
hookEl: '.ptr-hook', | |
distanceToRefresh: 70, | |
resistance: 2.0, | |
onRefresh: null | |
}, | |
initialize: function () { | |
this.pullEl = getEl(this.options.pullEl); | |
this.hookEl = getEl(this.options.hookEl); | |
this.iconEl = getEl(this.options.iconEl) || this.hookEl.querySelector('.pull-icon'); | |
this.scrollableEl = getEl(this.options.scrollableEl) || this.pullEl.querySelector('.scrollable') || this.pullEl; | |
this.distanceToRefresh = this.getOption('distanceToRefresh'); | |
this.maxDistance = this.getOption('maxDistance') || this.pullEl.offsetHeight / 2; | |
this.resistance = this.getOption('resistance'); | |
if (!this.pullEl) { | |
throw new Error('pull element not found'); | |
} | |
if (!this.hookEl) { | |
throw new Error('hook element not found'); | |
} | |
if (!this.iconEl) { | |
throw new Error('pull icon element not found'); | |
} | |
if (typeof this.distanceToRefresh !== 'number' || this.distanceToRefresh <= 0) { | |
throw new Error('invalid distanceToRefresh, (' + this.distanceToRefresh + ')'); | |
} | |
this.panListener = onPan( | |
this.pullEl, | |
{ | |
onStart: this.pullStart, | |
onMove: this.pullDown, | |
onEnd: this.pullEnd | |
}, | |
{ | |
thresholdInPixels: 5 | |
} | |
); | |
this.suspendOnScroll(); | |
this.enable(); | |
this.triggerMethod('initialize'); | |
}, | |
destroy: function () { | |
this.panListener.off(); | |
this.triggerMethod('destroy'); | |
}, | |
enable: function () { | |
this.isEnabled = true; | |
}, | |
disable: function () { | |
this.isEnabled = false; | |
}, | |
pullStart: function pullStart() { | |
if (this.state.busy || !this.isEnabled || this.isSuspended) { | |
return; | |
} | |
this.maxDistance = (this.maxDistance > 0) ? this.maxDistance : this.pullEl.offsetHeight / 2; | |
this.pullEl.classList.add('ptr-active'); | |
this.state.startingPositionY = Math.max(this.scrollableEl.scrollTop, 0); | |
this.state.activated = this.state.startingPositionY === 0; | |
debug('pullStart', this.state); | |
}, | |
pullDown: function pullDown(e) { | |
if (!this.state.activated) { | |
return; | |
} | |
e.srcEvent.preventDefault(); | |
// Provide feeling of resistance | |
this.state.distance = Math.min(e.deltaY / this.resistance, this.maxDistance); | |
this.pullEl.style[transformProperty] = translateY(this.state.distance); | |
this.hookEl.style[transformProperty] = translateY(-this.state.distance / (this.resistance * 2)); | |
this.iconEl.style[transformProperty] = rotate(Math.min(this.state.distance / this.distanceToRefresh * 180.0, 180)); | |
if (this.getOption('hookStyle') === 'behind') { | |
this.hookEl.style[transformProperty] = translateY(this.state.distance - this.hookEl.offsetHeight); | |
} | |
this.state.isReadyToRefresh = this.state.distance >= this.distanceToRefresh; | |
debug('pullDown', this.state); | |
}, | |
pullEnd: function pullEnd(e) { | |
if (!this.state.activated) { | |
return; | |
} | |
this.state.busy = true; | |
this.state.activated = false; | |
e.srcEvent.preventDefault(); | |
if (this.state.isReadyToRefresh) { | |
this.refresh(); | |
} else { | |
this.reset(); | |
} | |
debug('pullEnd', this.state); | |
}, | |
refresh: function refresh() { | |
const onRefresh = this.getOption('onRefresh'), refreshPromise; | |
debug('refresh'); | |
if (typeof onRefresh !== 'function') { | |
return this.reset(); | |
} | |
this.pullEl.classList.add('ptr-refresh'); | |
this._removeTransforms(); | |
refreshPromise = onRefresh(); | |
// For UX continuity, make sure we show loading for at least one second before resetting | |
setTimeout(() => refreshPromise.then(this.reset, this.reset), 1000); | |
}, | |
reset: function reset() { | |
const onResetEnd = () => { | |
debug('resetEnd'); | |
this.pullEl.classList.remove('ptr-reset'); | |
this.pullEl.classList.remove('ptr-refresh'); | |
this.pullEl.removeEventListener(support.transitionEnd, onResetEnd); | |
this.state = _.clone(defaultState); | |
} | |
debug('reset'); | |
this.pullEl.classList.add('ptr-reset'); | |
this.pullEl.classList.remove('ptr-active'); | |
this._removeTransforms(); | |
this.pullEl.addEventListener(support.transitionEnd, onResetEnd); | |
}, | |
suspendOnScroll() { | |
let suspendTimer; | |
const onScroll = () => { | |
clearTimeout(suspendTimer); | |
this.isSuspended = true; | |
suspendTimer = _.delay(() => this.isSuspended = false, 500); | |
}; | |
this.scrollableEl.addEventListener('scroll', onScroll); | |
this.once('destroy', () => { | |
this.scrollableEl.removeEventListener('scroll', onScroll); | |
}); | |
}, | |
_removeTransforms: function () { | |
this.hookEl.style[transformProperty] = ''; | |
this.pullEl.style[transformProperty] = ''; | |
this.iconEl.style[transformProperty] = ''; | |
}, | |
getOption: function (option) { | |
return option in this.options | |
? this.options[option] | |
: this[option]; | |
}, | |
triggerMethod(baseName, ...args) { | |
const methodName = `on${_.capitalize(baseName)}`; | |
[ | |
this[methodName], | |
this.options[methodName] | |
].forEach(method => { | |
if (typeof method === 'function') { | |
method.apply(this, ...args); | |
this.emit(baseName); | |
} | |
}); | |
} | |
}); | |
module.exports = PullToRefresh; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment