Skip to content

Commit

Permalink
Add momentum scrolling when drag is released
Browse files Browse the repository at this point in the history
  • Loading branch information
wcjordan committed Jun 23, 2016
1 parent a1c968f commit bffbf89
Show file tree
Hide file tree
Showing 2 changed files with 179 additions and 18 deletions.
3 changes: 2 additions & 1 deletion src/FixedDataTable.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -629,12 +629,13 @@ var FixedDataTable = React.createClass({
return (
<div
className={joinClasses(
this.state.className,
this.state.className,
cx('fixedDataTableLayout/main'),
cx('public/fixedDataTable/main'),
)}
onWheel={this._wheelHandler.onWheel}
onTouchStart={this._touchHandler.onTouchStart}
onTouchEnd={this._touchHandler.onTouchEnd}
onTouchMove={this._touchHandler.onTouchMove}
style={{height: state.height, width: state.width}}>
<div
Expand Down
194 changes: 177 additions & 17 deletions src/vendor_upstream/dom/ReactTouchHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*
* This is utility that handles touch events and calls provided touch
* callback with correct frame rate.
* Deceleration logic based on http://ariya.ofilabs.com/2013/11/javascript-kinetic-scrolling-part-2.html
*
* @providesModule ReactTouchHandler
* @typechecks
Expand All @@ -18,6 +19,11 @@
var emptyFunction = require('emptyFunction');
var requestAnimationFramePolyfill = require('requestAnimationFramePolyfill');

var MOVE_AMPLITUDE = 1.6;
var DECELERATION_AMPLITUDE = 1.6;
var DECELERATION_FACTOR = 325;
var TRACKER_TIMEOUT = 100;

class ReactTouchHandler {
/**
* onTouchScroll is the callback that will be called with right frame rate if
Expand All @@ -31,12 +37,34 @@ class ReactTouchHandler {
/*boolean|function*/ handleScrollY,
/*?boolean|?function*/ stopPropagation
) {
this._animationFrameID = null;

this._startX = 0;
this._startY = 0;

this._didTouchMove = this._didTouchMove.bind(this);
// The animation frame id for the drag scroll
this._dragAnimationId = null;

// The interval id for tracking the drag velocity
this._trackerId = null;

// Used to track the drag scroll delta while waiting for an animation frame
this._deltaX = 0;
this._deltaY = 0;

// The last touch we processed while dragging. Used to compute the delta and velocity above
this._lastTouchX = 0;
this._lastTouchY = 0;

// Used to track a moving average of the scroll velocity while dragging
this._velocityX = 0;
this._velocityY = 0;

// An accummulated drag scroll delta used to calculate velocity
this._accumulatedDeltaX = 0;
this._accumulatedDeltaY = 0;

// Timestamp from the last interval frame we used to track velocity
this._lastFrameTimestamp = Date.now();

// Timestamp from the last animation frame we used to autoscroll after drag stop
this._autoScrollTimestamp = Date.now();

if (typeof handleScrollX !== 'function') {
handleScrollX = handleScrollX ?
Expand All @@ -50,6 +78,7 @@ class ReactTouchHandler {
emptyFunction.thatReturnsFalse;
}

// TODO (jordan) Is configuring this necessary
if (typeof stopPropagation !== 'function') {
stopPropagation = stopPropagation ?
emptyFunction.thatReturnsTrue :
Expand All @@ -60,53 +89,184 @@ class ReactTouchHandler {
this._handleScrollY = handleScrollY;
this._stopPropagation = stopPropagation;
this._onTouchScrollCallback = onTouchScroll;

this._didTouchMove = this._didTouchMove.bind(this);
this._track = this._track.bind(this);
this._autoScroll = this._autoScroll.bind(this);
this._startAutoScroll = this._startAutoScroll.bind(this);
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchEnd = this.onTouchEnd.bind(this);
this.onTouchMove = this.onTouchMove.bind(this);
}

onTouchStart(/*object*/ event) {
this._startX = event.touches[0].pageX;
this._startY = event.touches[0].pageY;
// Start tracking drag delta for scrolling
this._lastTouchX = event.touches[0].pageX;
this._lastTouchY = event.touches[0].pageY;

// Reset our velocity and intermediate data used to compute velocity
this._velocityX = 0;
this._velocityY = 0;
this._accumulatedDeltaX = 0;
this._accumulatedDeltaY = 0;
this._lastFrameTimestamp = Date.now();

// Setup interval for tracking velocity
clearInterval(this._trackerId);
this._trackerId = setInterval(this._track, TRACKER_TIMEOUT);

event.preventDefault();
if (this._stopPropagation()) {
event.stopPropagation();
}
}

onTouchEnd(/*object*/ event) {

// Stop tracking velocity
clearInterval(this._trackerId);
this._trackerId = null;

// Initialize decelerating autoscroll on drag stop
requestAnimationFrame(this._startAutoScroll);

event.preventDefault();
if (this._stopPropagation()) {
event.stopPropagation();
}
}

onTouchMove(/*object*/ event) {
var moveX = event.touches[0].pageX;
var moveY = event.touches[0].pageY;

//Mobile, scrolling is inverted
this._deltaX = this._startX - moveX;
this._deltaY = this._startY - moveY;
// Compute delta scrolled since last drag
// Mobile, scrolling is inverted
this._deltaX = MOVE_AMPLITUDE * (this._lastTouchX - moveX);
this._deltaY = MOVE_AMPLITUDE * (this._lastTouchY - moveY);

var handleScrollX = this._handleScrollX(this._deltaX, this._deltaY);
var handleScrollY = this._handleScrollY(this._deltaY, this._deltaX);
if (!handleScrollX && !handleScrollY) {
return;
}

this._startX = handleScrollX ? moveX : this._startX;
this._startY = handleScrollY ? moveY : this._startY;
// If we can handle scroll update last touch for computing delta
if (handleScrollX) {
this._lastTouchX = moveX;
} else {
this._deltaX = 0;
}
if (handleScrollY) {
this._lastTouchY = moveY;
} else {
this._deltaY = 0;
}

event.preventDefault();

var changed;
if (this._deltaX !== 0 || this._deltaY !== 0) {
// Ensure minimum delta magnitude is met to avoid jitter
var changed = false;
if (Math.abs(this._deltaX) > 2 || Math.abs(this._deltaY) > 2) {
if (this._stopPropagation()) {
event.stopPropagation();
}
changed = true;
}

if (changed === true && this._animationFrameID === null) {
this._animationFrameID = requestAnimationFramePolyfill(this._didTouchMove);
// Request animation frame to trigger scroll of computed delta
if (changed === true && this._dragAnimationId === null) {
this._dragAnimationId = requestAnimationFramePolyfill(this._didTouchMove);
}
}

/**
* Fire scroll callback based on computed drag delta.
* Also track accummulated delta so we can calculate velocity
*/
_didTouchMove() {
this._animationFrameID = null;
this._dragAnimationId = null;

this._onTouchScrollCallback(this._deltaX, this._deltaY);
this._accumulatedDeltaX += this._deltaX;
this._accumulatedDeltaY += this._deltaY;
this._deltaX = 0;
this._deltaY = 0;
}

/**
* Compute velocity based on a weighted average of drag over last 100 ms and
* previous velocity. Combining into a moving average results in a smoother scroll.
*/
_track() {
var now = Date.now();
var elapsed = now - this._lastFrameTimestamp;
var oldVelocityX = this._velocityX;
var oldVelocityY = this._velocityY;

// We compute velocity using a weighted average of the current velocity and the previous velocity
// If the previous velocity is 0, put the full weight on the last 100 ms
var weight = 0.8;
if (elapsed < TRACKER_TIMEOUT) {
weight *= (elapsed / TRACKER_TIMEOUT);
}
if (oldVelocityX === 0 && oldVelocityY === 0) {
weight = 1;
}

// Formula for computing weighted average of velocity
this._velocityX = weight * (TRACKER_TIMEOUT * this._accumulatedDeltaX / (1 + elapsed));
if (weight < 1) {
this._velocityX += (1 - weight) * oldVelocityX;
}

this._velocityY = weight * (TRACKER_TIMEOUT * this._accumulatedDeltaY / (1 + elapsed));
if (weight < 1) {
this._velocityY += (1 - weight) * oldVelocityY;
}

this._accumulatedDeltaX = 0;
this._accumulatedDeltaY = 0;
this._lastFrameTimestamp = now;
}

/**
* To kick off deceleration / momentum scrolling,
* handle any scrolling from a drag which was waiting for an animation frame
* Then update our velocity
* Finally start the momentum scrolling handler (autoScroll)
*/
_startAutoScroll() {
this._autoScrollTimestamp = Date.now();
if (this._deltaX > 0 || this.deltaY > 0) {
this._didTouchMove()
}
this._track();
this._autoScroll();
}

/**
* Compute a scroll delta with an exponential decay based on time elapsed since drag was released.
* This is called recursively on animation frames until the delta is below a threshold (5 pixels)
*/
_autoScroll() {
var elapsed = Date.now() - this._autoScrollTimestamp;
var factor = DECELERATION_AMPLITUDE * Math.exp(-elapsed / DECELERATION_FACTOR);
var deltaX = factor * this._velocityX;
var deltaY = factor * this._velocityY;

if (Math.abs(deltaX) <= 5 || !this._handleScrollX(deltaX, deltaY)) {
deltaX = 0;
}
if (Math.abs(deltaY) <= 5 || !this._handleScrollY(deltaY, deltaX)) {
deltaY = 0;
}

if (deltaX !== 0 || deltaY !== 0) {
this._onTouchScrollCallback(deltaX, deltaY);
requestAnimationFrame(this._autoScroll);
}
}
}

module.exports = ReactTouchHandler;

0 comments on commit bffbf89

Please sign in to comment.