Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(637)

Unified Diff: third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js

Issue 1333323003: SnapManager implementation using V8 Extras - {WIP} Base URL: https://chromium.googlesource.com/chromium/blink.git@master
Patch Set: Update with latest master Created 4 years, 6 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
Index: third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js
diff --git a/third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js b/third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js
new file mode 100644
index 0000000000000000000000000000000000000000..e977b13a04a1168412592a744af1e4494fd3faa1
--- /dev/null
+++ b/third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js
@@ -0,0 +1,486 @@
+// Copyright 2015 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+(function(global, binding, v8) {
+ 'use strict';
+
+ // V8 "Imports":
+ // - %CreatePrivateOwnSymbol
+ // - %_CallFunction
+ // - %AddNamedProperty
+ // - %HasOwnProperty
+ // - $promiseThen, $promiseCreate, $promiseResolve, $promiseReject
+ // - InternalPackedArray
+
+
+ const Number = global.Number;
+ const Math = global.Math;
+ const Symbol = global.Symbol;
+ const Date = global.Date;
+ const Map = global.Map;
+ // const performance = window.performance;
+ // const console = window.console;
+
+ const undefined = void 0;
+ const DONT_ENUM = 2;
+ const FLING_VELOCITY_THRESHOLD = 400; // dip/s
+
+ var snapper = Symbol('[[Snapper]]');
+
+ // A reference to the window which is initialized by SnapManager constructor
+ // This is needed because {request,cancel}AnimationFrame are not exposed in global
+ let window;
+
+ function log() {
+ console.log.apply(console, arguments);
+ var message = "";
+ for (var k in arguments) {
+ message += arguments[k];
+ }
+
+ //%DebugPrint(message);
+ }
+
+ /*
+ * Responsible for receiving updates from Blink about changes to snap containers
+ */
+ class SnapManager {
+ constructor(win) {
+ log('Initialized snap manager');
+ log(win);
+ log(win.requestAnimationFrame);
+ window = win;
+ }
+
+ updateSnapContainer(element, horizontalOffsets, verticalOffsets) {
+ log(element);
+ if (!element[snapper]) {
+ // TODO(majidvp): I have put the snap related functionality in snap controller but perhaps
+ // they can be in the elements prototype?
+ element[snapper] = new Snapper(element);
+ // Customize scrolling by override native scroll handler
+ if (element.setApplyScroll) {
+ element.setApplyScroll(element[snapper].applyScroll, "perform-before-native-scroll");
+ } else {
+ log("Element::setApplyScroll is missing. Make sure ScrollCustomization feature is enabled.");
+ }
+ }
+
+ element[snapper].setSnapOffsets(horizontalOffsets, verticalOffsets);
+ }
+ }
+
+ /* Encapsulates the logic for determining when and where to snap for each snap container */
+ class Snapper {
+ constructor(element) {
+ this.element = element;
+ // Make applyScroll bounded as it is called using function invocation pattern
+ this.applyScroll = this.applyScroll.bind(this);
+ }
+
+ setSnapOffsets(h, v) {
+ this.snapOffsets = [h, v];
+ // TODO: perhaps update inflight snaps?
+ }
+
+ // custom apply scroll handler
+ applyScroll(scrollState) {
+ log("scrollState", scrollState);
+ if (scrollState.isBeginning) {
+ // A new scroll clears all existing snap animations for this element
+ this.clearSnapState();
+ AnimationController.instance().remove(this);
+ return;
+ } else if (!(scrollState.inInertialPhase || scrollState.isEnding)) {
+ // Active user scrolling
+ return;
+ }
+ let consumedDelta = this.snap(scrollState);
+ scrollState.consumeDelta(consumedDelta[0], consumedDelta[1]);
+ }
+
+
+ // Make snapping decision separate on each dimension and creates an independent
+ // 1D snap animations for each. This allows us to fling in one direction while
+ // snap in another.
+ snap(scrollState) {
+ let consumedDelta = [0, 0];
+
+ for (let i of[0, 1]) {
+ let property = i == 0 ? 'scrollLeft' : 'scrollTop';
+ let delta = i == 0 ? scrollState.deltaX : scrollState.deltaY;
+ let velocity = i == 0 ? scrollState.velocityX : scrollState.velocityY;
+ let position = this.scrollPosition()[i];
+
+ if (this.snapOffsets[i].length == 0)
+ continue;
+
+ // consume all scroll delta while snap is in progress preventing native scroll
+ if (this.isSnapping[i]) {
+ consumedDelta[i] = delta;
+ continue;
+ }
+
+ // Wait for fling to slow down before starting the snap
+ // TODO(majidvp): Handle overscroll cases as well. One option is to clamp the velocity when overscrolling
+ if (scrollState.inInertialPhase && Math.abs(velocity) > FLING_VELOCITY_THRESHOLD)
+ continue;
+
+ if (scrollState.inInertialPhase && Math.abs(velocity) > 0 && !this.flingCurves[i])
+ this.flingCurves[i] = this.createFlingCurve(position, velocity);
+
+ let td = this.snapTargetAndDuration(position, velocity, this.snapOffsets[i], this.flingCurves[i]),
+ target = td.target,
+ duration = td.duration;
+
+ if (position == target)
+ continue;
+
+ // TODO(majidvp): Perhaps stay away from creating new animation objects to reduce
+ // V8 GC pressure
+ let animation = new Animation({
+ element: this.element,
+ property: property,
+ begin: position,
+ end: target,
+ duration: duration,
+ v0: velocity,
+ curveType: 'motion' /* linear | motion */
+ });
+
+ AnimationController.instance().add(this, property, animation);
+ this.isSnapping[i] = true;
+ log('horizontal:' + this.snapOffsets[0] + ' vertical:' + this.snapOffsets[1]);
+ }
+
+ return consumedDelta;
+ }
+
+ clearSnapState() {
+ this.flingCurves = [];
+ this.isSnapping = [false, false];
+ }
+
+ createFlingCurve(position, velocity) {
+ let now = Utils.now();
+ return new FlingCurve(position, -1 * velocity, now);
+ }
+
+ snapTargetAndDuration(position, velocity, snapOffsets, flingCurve) {
+ let target = flingCurve ? flingCurve.getFinalPosition() : position;
+
+ //let snapTarget = Utils.snapWithInterval(target, velocity, this.repeat(), size);
+ let snapTarget = Utils.snapWithOffsets(target, velocity, snapOffsets);
+ let snapDuration = this.estimateDuration(position, snapTarget, velocity, flingCurve);
+
+ log('position:' + position + ' velocity:' + velocity + ' => target:' + target + ' snapped-target: ' + snapTarget + ' @(' + snapDuration + ')');
+ return {
+ target: snapTarget,
+ duration: snapDuration
+ };
+ }
+
+ estimateDuration(current, target, velocity, flingCurve) {
+ // If there is a fling curve then use its estimated duration otherwise
+ // assume a constant snap speed to calculate duration.
+ let SPEED = 2; // 2px/ms
+ return flingCurve ? flingCurve.getDuration() * 1000 : Math.abs(target - current) / SPEED;
+ }
+
+ scrollSize() {
+ return [this.element.scrollWidth, this.element.scrollHeight];
+ }
+
+ scrollPosition() {
+ return [this.element.scrollLeft, this.element.scrollTop];
+ }
+ }
+
+ class Utils {
+ static snapWithInterval(value, velocity, interval, max) {
+ let roundingFn = velocity[i] == 0 ? Math.round : (velocity[i] > 0 ? Math.ceil : Math.floor);
+ return Utils.clamp(roundingFn(value[i] / interval) * interval, 0, max[i]);
+ }
+
+ // Find closest match in values
+ static snapWithOffsets(value, velocity, offsets) {
+ let snappedOffset = Utils.binarySearchClosest(value, offsets);
+ return snappedOffset;
+ }
+
+ // Find the value in array that is closes to v
+ static binarySearchClosest(v, array) {
+ if (array.length == 0)
+ return v;
+
+ let left = 0,
+ right = array.length;
+ // Clamp value to ensure it falls in range range
+ v = Utils.clamp(v, array[0], array[right - 1]);
+
+ while (right - left > 1) {
+ let mid = Math.ceil((right + left) / 2);
+ if (v < array[mid])
+ right = mid;
+ else
+ left = mid;
+ }
+
+ // min <= v <= max
+ let min = array[left],
+ max = array[right];
+ return max - v <= v - min ? max : min;
+ }
+
+ static clamp(num, min, max) {
+ return Math.min(Math.max(num, min), max);
+ }
+
+ static now() {
+ return Date.now() / 1000; // performance.now() / 1000
+ }
+ }
+
+ var controllerSingleton;
+ /* Animation controller is responsible to controlling all active snap animations and properly abort or resume them upon user touch. */
+ class AnimationController {
+
+ static instance() {
+ if (!controllerSingleton)
+ controllerSingleton = new AnimationController();
+
+ return controllerSingleton;
+ }
+
+ // TODO(majidvp): make this private
+ constructor() {
+ this.active = new Map();
+ }
+
+ add(snapper, property, animation) {
+ if (!this.active.has(snapper))
+ this.active.set(snapper, {});
+
+ let snapAnimations = this.active.get(snapper)
+ snapAnimations[property] = animation;
+
+ log('start animation');
+ snapper.element.addEventListener('touchstart', AnimationController.onTouchStart);
+ snapper.element.addEventListener('touchend', AnimationController.onTouchEnd);
+
+ animation.start();
+ }
+
+ remove(snapper) {
+ let snapAnimations = this.active.get(snapper);
+ if (snapAnimations)
+ for (let key in snapAnimations)
+ snapAnimations[key].stop();
+ this.active.delete(snapper);
+
+ snapper.isOnhold = false;
+ snapper.element.removeEventListener('touchstart', AnimationController.onTouchStart);
+ snapper.element.removeEventListener('touchend', AnimationController.onTouchEnd);
+ }
+
+ // aborts snap animation for snapper and puts it on hold
+ abort(snapper) {
+ let snapAnimations = this.active.get(snapper);
+ if (!snapAnimations || snapAnimations.length == 0)
+ return;
+
+ snapper.clearSnapState();
+ snapper.isOnhold = true;
+ for (let key in snapAnimations)
+ snapAnimations[key].stop();
+
+ this.active.delete(snapper);
+ }
+
+ // resume any aborted snap animation for this snapper
+ resume(snapper) {
+ if (!snapper || !snapper.isOnhold)
+ return;
+ // resuming snap animation is equivalent to snapping at the end of a scroll gesture with zero delta, and velocity
+ snapper.snap(new ScrollState(0, 0, 0, 0, 0, false, false, true));
+ }
+
+ static onTouchStart() {
+ log("touchstart: abort snap animations");
+ AnimationController.instance().abort(this[snapper]);
+ }
+
+ static onTouchEnd() {
+ log("touchend: re-snap");
+ AnimationController.instance().resume(this[snapper]);
+ }
+ }
+
+ class Animation {
+ /*
+ * Setup necessary RAF loop for snap animation to reach snap destination
+ */
+ constructor(params) {
+ for (let attr of['element', 'property', 'begin', 'end', 'duration', 'distance'])
+ this[attr] = params[attr];
+
+ this.distance = this.end - this.begin;
+ switch (params.curveType) {
+ case 'motion':
+ this.curve = SnapCurve.motion(-1 * params.v0, this.distance);
+ break;
+ case 'linear':
+ default:
+ this.curve = SnapCurve.linear(this.distance);
+ }
+
+ // bind step
+ this.step = this.step.bind(this);
+ }
+
+ start() {
+ log('Animate to scroll position ' + this.end + ' in ' + this.duration + ' ms');
+ if (this.duration <= 0) {
+ log("WARNING: duration must be positive");
+ return;
+ }
+
+ this.startTime = undefined;
+ this.rAF = window.requestAnimationFrame(this.step);
+ }
+
+ stop() {
+ window.cancelAnimationFrame(this.rAF);
+ }
+
+ step(now) {
+ log("animation step");
+ this.startTime = this.startTime || now;
+
+ let progress = Math.min(now - this.startTime, this.duration) / this.duration;
+ let delta = this.curve(progress);
+ let next = Math.floor(this.begin + delta);
+ log(this.property + '=' + next + ' (delta:' + delta + ' progress:' + progress + ' now:' + now + ')');
+
+ this.element[this.property] = next;
+
+ if (progress == 1) {
+ this.stop();
+ // TODO: callback and let AnimationController know that this is complete
+ return;
+ }
+
+ this.rAF = window.requestAnimationFrame(this.step);
+ }
+
+ }
+
+ class SnapCurve {
+ static linear(distance) {
+ return function(progress) {
+ return progress * distance;
+ };
+ }
+
+ /**
+ * Constructs a motion curve D(t) which satisfies these conditions:
+ * - D(0) = 0 and D(duration)= distance.
+ * - Velocity (dD/dt) is continuous
+ * - Velocity is v0 at start, t=0, and 0 at the end, t=1.
+ */
+ static motion(v0, distance) {
+ let a = 3 * v0 - 6 * distance,
+ b = 6 * distance - 4 * v0;
+
+ //let formula = '0.33*' + a + '*t^3+0.5*' + b + '*t^2+' + v0 + '*t' + ', y=' + distance;
+ //log('Motion Curve: ' + 'https://www.wolframalpha.com/input/?i=' + encodeURIComponent('plot ' + formula + ' for t=0 to 1'));
+ return function curve(t) {
+ // to ensure we always end up at distance at the end.
+ if (t === 1) {
+ return distance;
+ }
+
+ let t2 = t * t,
+ t3 = t * t * t;
+ return 0.33 * a * t3 + 0.5 * b * t2 + v0 * t;
+ };
+ }
+ }
+
+ // From src/content/public/common/renderer_preferences.cc
+ const p = [-5707.62, 172, 3.7];
+
+ // Times are in second
+ class FlingCurve {
+ constructor(initPosition, initVelocity, startTime) {
+
+ this.sign = initVelocity >= 0 ? 1 : -1;
+
+ initVelocity = Math.abs(initVelocity);
+ initVelocity = Math.min(initVelocity, this.velocity(0));
+ initVelocity = Math.max(initVelocity, 0);
+
+ this.localStartTime = this.timeAtVelocity(initVelocity);
+ this.localEndTime = this.timeAtVelocity(0);
+ this.endTime = startTime + this.localEndTime - this.localStartTime;
+ this.positionOffset = initPosition - this.sign * this.position(this.localStartTime);
+ this.timeOffset = this.localStartTime - startTime;
+
+ // let formula = -p[0] + "*" + p[2] + "*" + " e^(" + (-p[2]) + " * t) + " + (-p[1]) + ", y=" + initial_velocity;
+ //log('Fling Velocity Curve: ' + 'https://www.wolframalpha.com/input/?i=' + encodeURIComponent('plot ' + formula + " for t=0 to 2"));
+ //log('Duration:' + this.getDuration() * 1000 + " Distance:" + this.getDistance());
+ }
+
+ // From src/content/child/touch_fling_gesture_curve.cc.
+ // Uses local time and return local position
+ position(localTime) {
+ let t = localTime;
+ return p[0] * Math.exp(-p[2] * t) - p[1] * t - p[0];
+ }
+
+ velocity(localTime) {
+ let t = localTime;
+ return -p[0] * p[2] * Math.exp(-p[2] * t) - p[1];
+ }
+
+ timeAtVelocity(velocity) {
+ return (-Math.log((velocity + p[1]) / (-p[0] * p[2])) / p[2]);
+ }
+
+ // take global time and return global position
+ getPositionAtTime(time) {
+ time = Math.max(time, this.endTime);
+ return this.positionOffset + this.sign * this.position(time + this.timeOffset);
+ };
+
+ getFinalPosition() {
+ return this.positionOffset + this.sign * this.position(this.localEndTime);
+ };
+ getDuration() {
+ return this.localEndTime - this.localStartTime;
+ };
+ getDistance() {
+ return this.position(this.localEndTime) - this.position(this.localStartTime);
+ }
+ }
+
+ //
+ // Additions to the global
+ //
+
+ //%AddNamedProperty(global, 'SnapManager', SnapManager, DONT_ENUM);
+
+ //
+ // Exports for Blink to use
+ //
+
+ binding.CreateSnapManager = function(window) {
+ return new SnapManager(window);
+ }
+
+ binding.UpdateSnapContainer = function(snapManager, element, horizontalOffsets, verticalOffsets) {
+ snapManager.updateSnapContainer(element, horizontalOffsets, verticalOffsets);
+ }
+
+});

Powered by Google App Engine
This is Rietveld 408576698