Bug 538682 - Skip frames during kinetic panning [r=mfinkle, froystig]
authorBenjamin Stover <bstover@mozilla.com>
Tue, 12 Jan 2010 11:22:48 -0800
changeset 65962 b07e2edad6402880670079eacc6fa74011b2c298
parent 65961 37fb48b1d720975e933f57d6778d14ab7263dba4
child 65963 8be524b148b8335e4388ddf0861d46ed1a66141f
push id1
push userroot
push dateTue, 26 Apr 2011 22:38:44 +0000
treeherdermozilla-beta@bfdb6e623a36 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmfinkle, froystig
bugs538682
Bug 538682 - Skip frames during kinetic panning [r=mfinkle, froystig]
mobile/app/mobile.js
mobile/chrome/content/InputHandler.js
--- a/mobile/app/mobile.js
+++ b/mobile/app/mobile.js
@@ -347,19 +347,20 @@ pref("javascript.options.mem.gc_frequenc
 
 pref("dom.max_chrome_script_run_time", 30);
 pref("dom.max_script_run_time", 20);
 
 // JS error console
 pref("browser.console.showInPanel", false);
 
 // kinetic tweakables
-pref("browser.ui.kinetic.updateInterval", 33);
-pref("browser.ui.kinetic.ema.alphaValue", 8);
-pref("browser.ui.kinetic.decelerationRate", 15);
+pref("browser.ui.kinetic.updateInterval", 30);
+pref("browser.ui.kinetic.decelerationRate", 20);
+pref("browser.ui.kinetic.speedSensitivity", 80);
+pref("browser.ui.kinetic.swipeLength", 160);
 
 // Disable default plugin
 pref("plugin.default_plugin_disabled", true);
 
 // product URLs
 // pref("app.releaseNotesURL", "http://www.mozilla.com/%LOCALE%/mobile/%VERSION%/releasenotes/");
 pref("app.releaseNotesURL", "http://www.mozilla.org/projects/%APP%/%VERSION%/releasenotes/");
 //pref("app.support.baseURL", "http://support.mozilla.com/1/mobile/%VERSION%/%OS%/%LOCALE%/");
--- a/mobile/chrome/content/InputHandler.js
+++ b/mobile/chrome/content/InputHandler.js
@@ -37,34 +37,22 @@
  * use your version of this file under the terms of the MPL, indicate your
  * decision by deleting the provisions above and replace them with the notice
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
-// how much movement input to take before mouse up for calculating kinetic speed
-const kSwipeLength = 160;
-
 // how many msecs elapse before two taps are not a double tap
 const kDoubleClickInterval = 400;
 
 // threshold in pixels for sensing a tap as opposed to a pan
 const kTapRadius = 25;
 
-// how many milliseconds between each kinetic pan update
-const kKineticUpdateInterval = 25;
-
-// How much speed is removed every update
-const kDecelerationRate = .09;
-
-// How sensitive kinetic scroll is to mouse movement
-const kSpeedSensitivity = 1.1;
-
 // Same as NS_EVENT_STATE_ACTIVE from nsIEventStateManager.h
 const kStateActive = 0x00000001;
 
 /**
  * InputHandler
  *
  * The input handler is an arbiter between the Fennec chrome window inputs and any
  * registered input modules.  It keeps an array of input module objects.  Incoming
@@ -991,116 +979,150 @@ DragData.prototype = {
 
 /**
  * KineticController - a class to take drag position data and use it
  * to do kinetic panning of a scrollable object.
  *
  * aPanBy is a function that will be called with the dx and dy
  * generated by the kinetic algorithm.  It should return true if the
  * object was panned, false if there was no movement.
+ *
+ * There are two complicated things done here.  One is calculating the
+ * initial velocity of the movement based on user input.  Two is
+ * calculating the distance to move every frame.
  */
 function KineticController(aPanBy, aEndCallback) {
   this._panBy = aPanBy;
   this._timer = null;
   this._beforeEnd = aEndCallback;
 
-  try {
-    this._updateInterval = gPrefService.getIntPref("browser.ui.kinetic.updateInterval");
-  } catch(e) {
-    this._updateInterval = kKineticUpdateInterval;
-  }
-
-  try {
-    // In preferences this value is an int.  We divide so that it can be a percent.
-    this._decelerationRate = gPrefService.getIntPref("browser.ui.kinetic.decelerationRate") / 100;
-  } catch (e) {
-    this._decelerationRate = kDecelerationRate;
-  };
+  // These are used to calculate the position of the scroll panes during kinetic panning. Think of
+  // these points as vectors that are added together and multiplied by scalars.
+  this._position = new Point(0, 0);
+  this._velocity = new Point(0, 0);
+  this._acceleration = new Point(0, 0);
+  this._time = 0;
+  this._timeStart = 0;
 
-  try {
-    // In preferences this value is an int.  We divide so that it can be a percent.
-    this._speedSensitivity = gPrefService.getIntPref("browser.ui.kinetic.speedsensitivity") / 100;
-  } catch(e) {
-    this._speedSensitivity = kSpeedSensitivity;
-  }
-
-  try {
-    this._swipeLength = gPrefService.getIntPref("browser.ui.kinetic.swipelength");
-  } catch(e) {
-    this._swipeLength = kSwipeLength;
-  }
+  // How often do we change the position of the scroll pane?  Too often and panning may jerk near
+  // the end. Too little and panning will be choppy. In milliseconds.
+  this._updateInterval = gPrefService.getIntPref("browser.ui.kinetic.updateInterval");
+  // "Friction" of the scroll pane. The lower, the less friction and the further distance traveled.
+  this._decelerationRate = gPrefService.getIntPref("browser.ui.kinetic.decelerationRate") / 10000;
+  // A multiplier for the initial velocity of the movement.
+  this._speedSensitivity = gPrefService.getIntPref("browser.ui.kinetic.speedSensitivity") / 100;
+  // Number of milliseconds that can contain a swipe. Movements earlier than this are disregarded.
+  this._swipeLength = gPrefService.getIntPref("browser.ui.kinetic.swipeLength");
 
   this._reset();
 }
 
 KineticController.prototype = {
   _reset: function _reset() {
     if (this._timer != null) {
       this._timer.cancel();
       this._timer = null;
     }
 
     this.momentumBuffer = [];
-    this._speedX = 0;
-    this._speedY = 0;
+    this._velocity.set(0, 0);
   },
 
   isActive: function isActive() {
     return (this._timer != null);
   },
 
   _startTimer: function _startTimer() {
+    // Use closed form of a parabola to calculate each position for panning.
+    // x(t) = v0*t + .5*t^2*a
+    // where: v0 is initial velocity
+    //        a is acceleration
+    //        t is time elapsed
+    //
+    // x(t)
+    //  ^
+    //  |                |
+    //  | 
+    //  |                |  
+    //  |           ....^^^^....
+    //  |      ...^^     |      ^^...
+    //  |  ...^                      ^...
+    //  |..              |               ..
+    //   -----------------------------------> t
+    //  t0             tf=-v0/a
+    //
+    // Using this formula, distance moved is independent of the time between each frame, unlike time
+    // step approaches. Once the time is up, set the position to x(tf) and stop the timer.
+
+    let lastx = this._position;  // track last position vector because pan takes differences
+    let v0 = this._velocity;  // initial velocity
+    let a = this._acceleration;  // acceleration
+
+    // Temporary "bins" so that we don't create new objects during pan.
+    let aBin = new Point(0, 0);
+    let v0Bin = new Point(0, 0);
+
     let callback = {
       _self: this,
       notify: function kineticTimerCallback(timer) {
         let self = this._self;
 
         if (!self.isActive())  // someone called end() on us between timer intervals
           return;
 
-        //dump("             speeds: " + self._speedX + " " + self._speedY + "\n");
+        // To make animation end fast enough but to keep smoothness, average the ideal
+        // time frame (smooth animation) with the actual time lapse (end fast enough).
+        // Animation will never take longer than 2 times the ideal length of time.
+        let realt = Date.now() - self._initialTime;
+        self._time += self._updateInterval;
+        let t = (self._time + realt) / 2;
+
+        // Calculate new position using x(t) formula.
+        let x = v0Bin.set(v0).scale(t).add(aBin.set(a).scale(0.5 * t * t));
+        let dx = x.x - lastx.x;
+        let dy = x.y - lastx.y;
+        lastx.set(x);
 
-        if (self._speedX == 0 && self._speedY == 0) {
-          self.end();
-          return;
+        // Test to see if movement is finished for each component. As seen in graph, we want the
+        // final position to be at tf.
+        if (t >= -v0.x / a.x) {
+          // Plug in t=-v0/a into x(t) to get final position.
+          dx = -v0.x * v0.x / 2 / a.x - lastx.x;
+          // Reset components. Next frame: a's component will be 0 and t >= NaN will be false.
+          lastx.x = 0;
+          v0.x = 0;
+          a.x = 0;
         }
-        let dx = Math.round(self._speedX * self._updateInterval);
-        let dy = Math.round(self._speedY * self._updateInterval);
+        // Symmetric to above case.
+        if (t >= -v0.y / a.y) {
+          dy = -v0.y * v0.y / 2 / a.y - lastx.y;
+          lastx.y = 0;
+          v0.y = 0;
+          a.y = 0;
+        }
 
         let panned = false;
-        try { panned = self._panBy(-dx, -dy); } catch (e) {}
-        if (!panned) {
-          self.end();
-          return;
-        }
-
-        if (self._speedX < 0) {
-          self._speedX = Math.min(self._speedX + self._decelerationRate, 0);
-        } else if (self._speedX > 0) {
-          self._speedX = Math.max(self._speedX - self._decelerationRate, 0);
-        }
-        if (self._speedY < 0) {
-          self._speedY = Math.min(self._speedY + self._decelerationRate, 0);
-        } else if (self._speedY > 0) {
-          self._speedY = Math.max(self._speedY - self._decelerationRate, 0);
-        }
-
-        if (self._speedX == 0 && self._speedY == 0)
+        try { panned = self._panBy(Math.round(-dx), Math.round(-dy)); } catch (e) {}
+        if (!panned)
           self.end();
       }
     };
 
     this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
     //initialize our timer with updateInterval
     this._timer.initWithCallback(callback,
                                  this._updateInterval,
                                  this._timer.TYPE_REPEATING_SLACK);
   },
 
   start: function start() {
+    function sign(x) {
+      return x ? ((x > 0) ? 1 : -1) : 0;
+    }
+
     let mb = this.momentumBuffer;
     let mblen = this.momentumBuffer.length;
 
     let lastTime = mb[mblen - 1].t;
     let distanceX = 0;
     let distanceY = 0;
     let swipeLength = this._swipeLength;
 
@@ -1110,42 +1132,47 @@ KineticController.prototype = {
       me = mb[i];
       if (lastTime - me.t < swipeLength) {
         distanceX += me.dx;
         distanceY += me.dy;
       }
     }
 
     // Only allow kinetic scrolling to speed up if kinetic scrolling is active.
-    this._speedX = (distanceX < 0 ? Math.min : Math.max)((distanceX / swipeLength) * this._speedSensitivity, this._speedX);
-    this._speedY = (distanceY < 0 ? Math.min : Math.max)((distanceY / swipeLength) * this._speedSensitivity, this._speedY);
+    this._velocity.x = (distanceX < 0 ? Math.min : Math.max)((distanceX / swipeLength) * this._speedSensitivity, this._velocity.x);
+    this._velocity.y = (distanceY < 0 ? Math.min : Math.max)((distanceY / swipeLength) * this._speedSensitivity, this._velocity.y);
+
+    // Set acceleration vector to opposite signs of velocity
+    this._acceleration.set(this._velocity.clone().map(sign).scale(-this._decelerationRate));
+
+    this._position.set(0, 0);
+    this._initialTime = Date.now();
+    this._time = 0;
     this.momentumBuffer = [];
-    if (!this.isActive()) {
+
+    if (!this.isActive())
       this._startTimer();
-    }
 
     return true;
   },
 
   end: function end() {
     if (this._beforeEnd)
       this._beforeEnd();
     this._reset();
   },
 
   addData: function addData(dx, dy) {
     let mbLength = this.momentumBuffer.length;
     let now = Date.now();
 
     if (this.isActive()) {
       // Stop active movement when dragging in other direction.
-      if (dx * this._speedX < 0)
-        this._speedX = 0;
-      if (dy * this._speedY < 0)
-        this._speedY = 0;
+      if (dx * this._velocity.x < 0 || dy * this._velocity.y < 0)
+        this.end();
     }
  
     this.momentumBuffer.push({'t': now, 'dx' : dx, 'dy' : dy});
   }
 };
 
 /**
  * Input module for basic scrollwheel input.  Currently just zooms the browser