impetus
Version:
Add momentum to anything. It's like iScroll, except not for scrolling. Supports mouse and touch events.
490 lines (412 loc) • 15.4 kB
JavaScript
const stopThresholdDefault = 0.3;
const bounceDeceleration = 0.04;
const bounceAcceleration = 0.11;
// fixes weird safari 10 bug where preventDefault is prevented
// @see https://github.com/metafizzy/flickity/issues/457#issuecomment-254501356
window.addEventListener('touchmove', function() {});
export default class Impetus {
constructor({
source: sourceEl = document,
update: updateCallback,
multiplier = 1,
friction = 0.92,
initialValues,
boundX,
boundY,
bounce = true
}) {
var boundXmin, boundXmax, boundYmin, boundYmax, pointerLastX, pointerLastY, pointerCurrentX, pointerCurrentY, pointerId, decVelX, decVelY;
var targetX = 0;
var targetY = 0;
var stopThreshold = stopThresholdDefault * multiplier;
var ticking = false;
var pointerActive = false;
var paused = false;
var decelerating = false;
var trackingPoints = [];
/**
* Initialize instance
*/
(function init() {
sourceEl = (typeof sourceEl === 'string') ? document.querySelector(sourceEl) : sourceEl;
if (!sourceEl) {
throw new Error('IMPETUS: source not found.');
}
if (!updateCallback) {
throw new Error('IMPETUS: update function not defined.');
}
if (initialValues) {
if (initialValues[0]) {
targetX = initialValues[0];
}
if (initialValues[1]) {
targetY = initialValues[1];
}
callUpdateCallback();
}
// Initialize bound values
if (boundX) {
boundXmin = boundX[0];
boundXmax = boundX[1];
}
if (boundY) {
boundYmin = boundY[0];
boundYmax = boundY[1];
}
sourceEl.addEventListener('touchstart', onDown);
sourceEl.addEventListener('mousedown', onDown);
})();
/**
* In edge cases where you may need to
* reinstanciate Impetus on the same sourceEl
* this will remove the previous event listeners
*/
this.destroy = function() {
sourceEl.removeEventListener('touchstart', onDown);
sourceEl.removeEventListener('mousedown', onDown);
cleanUpRuntimeEvents();
// however it won't "destroy" a reference
// to instance if you'd like to do that
// it returns null as a convinience.
// ex: `instance = instance.destroy();`
return null;
};
/**
* Disable movement processing
* @public
*/
this.pause = function() {
cleanUpRuntimeEvents();
pointerActive = false;
paused = true;
};
/**
* Enable movement processing
* @public
*/
this.resume = function() {
paused = false;
};
/**
* Update the current x and y values
* @public
* @param {Number} x
* @param {Number} y
*/
this.setValues = function(x, y) {
if (typeof x === 'number') {
targetX = x;
}
if (typeof y === 'number') {
targetY = y;
}
};
/**
* Update the multiplier value
* @public
* @param {Number} val
*/
this.setMultiplier = function(val) {
multiplier = val;
stopThreshold = stopThresholdDefault * multiplier;
};
/**
* Update boundX value
* @public
* @param {Number[]} boundX
*/
this.setBoundX = function(boundX) {
boundXmin = boundX[0];
boundXmax = boundX[1];
};
/**
* Update boundY value
* @public
* @param {Number[]} boundY
*/
this.setBoundY = function(boundY) {
boundYmin = boundY[0];
boundYmax = boundY[1];
};
/**
* Removes all events set by this instance during runtime
*/
function cleanUpRuntimeEvents() {
// Remove all touch events added during 'onDown' as well.
document.removeEventListener('touchmove', onMove, getPassiveSupported() ? { passive: false } : false);
document.removeEventListener('touchend', onUp);
document.removeEventListener('touchcancel', stopTracking);
document.removeEventListener('mousemove', onMove, getPassiveSupported() ? { passive: false } : false);
document.removeEventListener('mouseup', onUp);
}
/**
* Add all required runtime events
*/
function addRuntimeEvents() {
cleanUpRuntimeEvents();
// @see https://developers.google.com/web/updates/2017/01/scrolling-intervention
document.addEventListener('touchmove', onMove, getPassiveSupported() ? { passive: false } : false);
document.addEventListener('touchend', onUp);
document.addEventListener('touchcancel', stopTracking);
document.addEventListener('mousemove', onMove, getPassiveSupported() ? { passive: false } : false);
document.addEventListener('mouseup', onUp);
}
/**
* Executes the update function
*/
function callUpdateCallback() {
updateCallback.call(sourceEl, targetX, targetY);
}
/**
* Creates a custom normalized event object from touch and mouse events
* @param {Event} ev
* @returns {Object} with x, y, and id properties
*/
function normalizeEvent(ev) {
if (ev.type === 'touchmove' || ev.type === 'touchstart' || ev.type === 'touchend') {
var touch = ev.targetTouches[0] || ev.changedTouches[0];
return {
x: touch.clientX,
y: touch.clientY,
id: touch.identifier
};
} else { // mouse events
return {
x: ev.clientX,
y: ev.clientY,
id: null
};
}
}
/**
* Initializes movement tracking
* @param {Object} ev Normalized event
*/
function onDown(ev) {
var event = normalizeEvent(ev);
if (!pointerActive && !paused) {
pointerActive = true;
decelerating = false;
pointerId = event.id;
pointerLastX = pointerCurrentX = event.x;
pointerLastY = pointerCurrentY = event.y;
trackingPoints = [];
addTrackingPoint(pointerLastX, pointerLastY);
addRuntimeEvents();
}
}
/**
* Handles move events
* @param {Object} ev Normalized event
*/
function onMove(ev) {
ev.preventDefault();
var event = normalizeEvent(ev);
if (pointerActive && event.id === pointerId) {
pointerCurrentX = event.x;
pointerCurrentY = event.y;
addTrackingPoint(pointerLastX, pointerLastY);
requestTick();
}
}
/**
* Handles up/end events
* @param {Object} ev Normalized event
*/
function onUp(ev) {
var event = normalizeEvent(ev);
if (pointerActive && event.id === pointerId) {
stopTracking();
}
}
/**
* Stops movement tracking, starts animation
*/
function stopTracking() {
pointerActive = false;
addTrackingPoint(pointerLastX, pointerLastY);
startDecelAnim();
cleanUpRuntimeEvents();
}
/**
* Records movement for the last 100ms
* @param {number} x
* @param {number} y [description]
*/
function addTrackingPoint(x, y) {
var time = Date.now();
while (trackingPoints.length > 0) {
if (time - trackingPoints[0].time <= 100) {
break;
}
trackingPoints.shift();
}
trackingPoints.push({x, y, time});
}
/**
* Calculate new values, call update function
*/
function updateAndRender() {
var pointerChangeX = pointerCurrentX - pointerLastX;
var pointerChangeY = pointerCurrentY - pointerLastY;
targetX += pointerChangeX * multiplier;
targetY += pointerChangeY * multiplier;
if (bounce) {
let diff = checkBounds();
if (diff.x !== 0) {
targetX -= pointerChangeX * dragOutOfBoundsMultiplier(diff.x) * multiplier;
}
if (diff.y !== 0) {
targetY -= pointerChangeY * dragOutOfBoundsMultiplier(diff.y) * multiplier;
}
} else {
checkBounds(true);
}
callUpdateCallback();
pointerLastX = pointerCurrentX;
pointerLastY = pointerCurrentY;
ticking = false;
}
/**
* Returns a value from around 0.5 to 1, based on distance
* @param {Number} val
*/
function dragOutOfBoundsMultiplier(val) {
return 0.000005 * Math.pow(val, 2) + 0.0001 * val + 0.55;
}
/**
* prevents animating faster than current framerate
*/
function requestTick() {
if (!ticking) {
requestAnimFrame(updateAndRender);
}
ticking = true;
}
/**
* Determine position relative to bounds
* @param {Boolean} restrict Whether to restrict target to bounds
*/
function checkBounds(restrict) {
var xDiff = 0;
var yDiff = 0;
if (boundXmin !== undefined && targetX < boundXmin) {
xDiff = boundXmin - targetX;
} else if (boundXmax !== undefined && targetX > boundXmax) {
xDiff = boundXmax - targetX;
}
if (boundYmin !== undefined && targetY < boundYmin) {
yDiff = boundYmin - targetY;
} else if (boundYmax !== undefined && targetY > boundYmax) {
yDiff = boundYmax - targetY;
}
if (restrict) {
if (xDiff !== 0) {
targetX = (xDiff > 0) ? boundXmin : boundXmax;
}
if (yDiff !== 0) {
targetY = (yDiff > 0) ? boundYmin : boundYmax;
}
}
return {
x: xDiff,
y: yDiff,
inBounds: xDiff === 0 && yDiff === 0
};
}
/**
* Initialize animation of values coming to a stop
*/
function startDecelAnim() {
var firstPoint = trackingPoints[0];
var lastPoint = trackingPoints[trackingPoints.length - 1];
var xOffset = lastPoint.x - firstPoint.x;
var yOffset = lastPoint.y - firstPoint.y;
var timeOffset = lastPoint.time - firstPoint.time;
var D = (timeOffset / 15) / multiplier;
decVelX = (xOffset / D) || 0; // prevent NaN
decVelY = (yOffset / D) || 0;
var diff = checkBounds();
if ((Math.abs(decVelX) > 1 || Math.abs(decVelY) > 1) || !diff.inBounds){
decelerating = true;
requestAnimFrame(stepDecelAnim);
}
}
/**
* Animates values slowing down
*/
function stepDecelAnim() {
if (!decelerating) {
return;
}
decVelX *= friction;
decVelY *= friction;
targetX += decVelX;
targetY += decVelY;
var diff = checkBounds();
if ((Math.abs(decVelX) > stopThreshold || Math.abs(decVelY) > stopThreshold) || !diff.inBounds) {
if (bounce) {
let reboundAdjust = 2.5;
if (diff.x !== 0) {
if (diff.x * decVelX <= 0) {
decVelX += diff.x * bounceDeceleration;
} else {
let adjust = (diff.x > 0) ? reboundAdjust : -reboundAdjust;
decVelX = (diff.x + adjust) * bounceAcceleration;
}
}
if (diff.y !== 0) {
if (diff.y * decVelY <= 0) {
decVelY += diff.y * bounceDeceleration;
} else {
let adjust = (diff.y > 0) ? reboundAdjust : -reboundAdjust;
decVelY = (diff.y + adjust) * bounceAcceleration;
}
}
} else {
if (diff.x !== 0) {
if (diff.x > 0) {
targetX = boundXmin;
} else {
targetX = boundXmax;
}
decVelX = 0;
}
if (diff.y !== 0) {
if (diff.y > 0) {
targetY = boundYmin;
} else {
targetY = boundYmax;
}
decVelY = 0;
}
}
callUpdateCallback();
requestAnimFrame(stepDecelAnim);
} else {
decelerating = false;
}
}
}
}
/**
* @see http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/
*/
const requestAnimFrame = (function(){
return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) {
window.setTimeout(callback, 1000 / 60);
};
})();
function getPassiveSupported() {
let passiveSupported = false;
try {
var options = Object.defineProperty({}, "passive", {
get: function() {
passiveSupported = true;
}
});
window.addEventListener("test", null, options);
} catch(err) {}
getPassiveSupported = () => passiveSupported;
return passiveSupported;
}