Skip to content

Instantly share code, notes, and snippets.

@th3hunt
Last active August 19, 2020 08:14
Show Gist options
  • Save th3hunt/c59ba1a356fd5b584b488b2af7eb8d7d to your computer and use it in GitHub Desktop.
Save th3hunt/c59ba1a356fd5b584b488b2af7eb8d7d to your computer and use it in GitHub Desktop.
Pan gesture helpers
/**
* 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
*/
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