Bug 829056 - Cross-slide gesture module for Fx start tiles. r=mbrubeck
authorSam Foster <sfoster@mozilla.com>
Mon, 22 Apr 2013 22:42:09 +0100
changeset 129531 fa5e73d8da49417a2599592498e77520c1d738f7
parent 129530 a2f43cd4a75393da9038da02333a53be3d0e41f3
child 129532 58011469a3c951328dc35293ee5cebaa6b843260
push id24580
push useremorley@mozilla.com
push dateTue, 23 Apr 2013 11:09:54 +0000
treeherdermozilla-central@aa620f3fc2f7 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmbrubeck
bugs829056
milestone23.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 829056 - Cross-slide gesture module for Fx start tiles. r=mbrubeck
browser/metro/base/content/bindings/grid.xml
browser/metro/base/content/browser-scripts.js
browser/metro/modules/CrossSlide.jsm
browser/metro/modules/Makefile.in
browser/metro/theme/platform.css
--- a/browser/metro/base/content/bindings/grid.xml
+++ b/browser/metro/base/content/bindings/grid.xml
@@ -397,32 +397,51 @@
       </method>
 
       <!-- Inteface to suppress selection events -->
 
       <field name="_suppressOnSelect"/>
       <property name="suppressOnSelect"
                   onget="return this.getAttribute('suppressonselect') == 'true';"
                   onset="this.setAttribute('suppressonselect', val);"/>
+      <property name="crossSlideBoundary"
+          onget="return this.hasAttribute('crossslideboundary')? this.getAttribute('crossslideboundary') : Infinity;"/>
 
     <!-- Internal methods -->
-
+      <field name="_xslideHandler"/>
       <constructor>
         <![CDATA[
           if (this.controller && this.controller.gridBoundCallback != undefined)
             this.controller.gridBoundCallback();
-
+          // set up cross-slide gesture handling for multiple-selection grids
+          if (CrossSlide && "multiple" == this.getAttribute("seltype")) {
+            this._xslideHandler = new CrossSlide.Handler(this, {
+                  REARRANGESTART: this.crossSlideBoundary
+            });
+            this.addEventListener("touchstart", this._xslideHandler, false);
+            this.addEventListener("touchmove", this._xslideHandler, false);
+            this.addEventListener("touchend", this._xslideHandler, false);
+          }
           // XXX This event was never actually implemented (bug 223411).
           var event = document.createEvent("Events");
           event.initEvent("contentgenerated", true, true);
           this.dispatchEvent(event);
         ]]>
       </constructor>
-
-        <method name="_isIndexInBounds">
+      <destructor>
+        <![CDATA[
+          if (this._xslideHandler) {
+            this.removeEventListener("touchstart", this._xslideHandler);
+            this.removeEventListener("touchmove", this._xslideHandler);
+            this.removeEventListener("touchend", this._xslideHandler);
+            this._xslideHandler = null;
+          }
+        ]]>
+      </destructor>
+      <method name="_isIndexInBounds">
         <parameter name="anIndex"/>
         <body>
           <![CDATA[
             return anIndex >= 0 && anIndex < this.itemCount;
           ]]>
         </body>
       </method>
 
@@ -479,16 +498,66 @@
               break;
             default:
               if (this.controller && this.controller.doActionOnSelectedTiles) {
                 this.controller.doActionOnSelectedTiles(event.action, event);
               }
           }
         ]]>
       </handler>
+      <handler event="MozCrossSliding">
+        <![CDATA[
+          // MozCrossSliding is swipe gesture across a tile
+          // The tile should follow the drag to reinforce the gesture
+          // (with inertia/speedbump behavior)
+          let state = event.crossSlidingState;
+          let thresholds = this._xslideHandler.thresholds;
+          let transformValue;
+          switch(state) {
+            case "cancelled":
+              // hopefully nothing else is transform-ing the tile
+              event.target.removeAttribute('crosssliding');
+              event.target.style.removeProperty('transform');
+              break;
+            case "dragging":
+            case "selecting":
+              event.target.setAttribute("crosssliding", true);
+              // just track the mouse in the initial phases of the drag gesture
+              transformValue = (event.direction=='x') ?
+                                      'translateX('+event.delta+'px)' :
+                                      'translateY('+event.delta+'px)';
+              event.target.style.transform = transformValue;
+              break;
+            case "selectSpeedBumping":
+            case "speedBumping":
+              event.target.setAttribute('crosssliding', true);
+              // in speed-bump phase, we add inertia to the drag
+              let offset = CrossSlide.speedbump(
+                event.delta,
+                thresholds.SPEEDBUMPSTART,
+                thresholds.SPEEDBUMPEND
+              );
+              transformValue = (event.direction=='x') ?
+                                      'translateX('+offset+'px)' :
+                                      'translateY('+offset+'px)';
+              event.target.style.transform = transformValue;
+              break;
+            // "rearranging" case not used or implemented here
+            case "completed":
+              event.target.removeAttribute('crosssliding');
+              event.target.style.removeProperty('transform');
+              break;
+          }
+        ]]>
+      </handler>
+      <handler event="MozCrossSlideSelect">
+        <![CDATA[
+          this.toggleItemSelection(event.target);
+        ]]>
+      </handler>
     </handlers>
   </binding>
   <binding id="richgrid-item">
     <content>
       <xul:vbox anonid="anon-richgrid-item" class="richgrid-item-content" xbl:inherits="customImage">
         <xul:hbox class="richgrid-icon-container" xbl:inherits="customImage">
           <xul:box class="richgrid-icon-box"><xul:image anonid="anon-richgrid-item-icon" xbl:inherits="src=iconURI"/></xul:box>
           <xul:box flex="1" />
--- a/browser/metro/base/content/browser-scripts.js
+++ b/browser/metro/base/content/browser-scripts.js
@@ -28,16 +28,18 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
                                   "resource://gre/modules/NewTabUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
                                   "resource://gre/modules/commonjs/sdk/core/promise.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "CrossSlide",
+                                  "resource:///modules/CrossSlide.jsm");
 
 /*
  * Services
  */
 
 #ifdef XP_WIN
 XPCOMUtils.defineLazyServiceGetter(this, "MetroUtils",
                                    "@mozilla.org/windows-metroutils;1",
new file mode 100644
--- /dev/null
+++ b/browser/metro/modules/CrossSlide.jsm
@@ -0,0 +1,272 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["CrossSlide"];
+
+// needs DPI adjustment?
+let CrossSlideThresholds = {
+   SELECTIONSTART: 25,
+   SPEEDBUMPSTART: 30,
+   SPEEDBUMPEND: 50,
+   REARRANGESTART: 80
+};
+
+// see: http://msdn.microsoft.com/en-us/library/windows/apps/windows.ui.input.crossslidingstate.ASPx
+let CrossSlidingState = {
+  STARTED:  0,
+  DRAGGING: 1,
+  SELECTING: 2,
+  SELECT_SPEED_BUMPING: 3,
+  SPEED_BUMPING: 4,
+  REARRANGING: 5,
+  COMPLETED: 6
+};
+
+let CrossSlidingStateNames = [
+  'started',
+  'dragging',
+  'selecting',
+  'selectSpeedBumping',
+  'speedBumping',
+  'rearranging',
+  'completed'
+];
+
+// --------------------------------
+// module helpers
+//
+
+function isSelectable(aElement) {
+  // placeholder logic
+  return aElement.nodeName == 'richgriditem';
+}
+
+function withinCone(aLen, aHeight) {
+  // check pt falls within 45deg either side of the cross axis
+  return aLen > aHeight;
+}
+
+function getScrollAxisFromElement(aElement) {
+  let elem = aElement,
+      win = elem.ownerDocument.defaultView;
+  let scrollX, scrollY;
+  for (; elem && 1==elem.nodeType; elem = elem.parentNode) {
+    let cs = win.getComputedStyle(elem);
+    scrollX = (cs.overflowX=='scroll' || cs.overflowX=='auto');
+    scrollY = (cs.overflowX=='scroll' || cs.overflowX=='auto');
+    if (scrollX || scrollY) {
+      break;
+    }
+  }
+  return scrollX ? 'x' : 'y';
+}
+
+function pointFromTouchEvent(aEvent) {
+  let touch = aEvent.touches[0];
+  return { x: touch.clientX, y: touch.clientY };
+}
+
+// This damping function has these important properties:
+// f(0) = 0
+// f'(0) = 1
+// limit as x -> Infinity of f(x) = 1
+function damp(aX) {
+  return 2 / (1 + Math.exp(-2 * aX)) - 1;
+}
+function speedbump(aDelta, aStart, aEnd) {
+  let x = Math.abs(aDelta);
+  if (x <= aStart)
+    return aDelta;
+  let sign = aDelta / x;
+
+  let d = aEnd - aStart;
+  let damped = damp((x - aStart) / d);
+  return sign * (aStart + (damped * d));
+}
+
+
+this.CrossSlide = {
+  // -----------------------
+  // Gesture constants
+  Thresholds: CrossSlideThresholds,
+  State: CrossSlidingState,
+  StateNames: CrossSlidingStateNames,
+  // -----------------------
+  speedbump: speedbump
+};
+
+function CrossSlideHandler(aNode, aThresholds) {
+  this.node = aNode;
+  this.thresholds = Object.create(CrossSlideThresholds);
+  // apply per-instance threshold configuration
+  if (aThresholds) {
+    for(let key in aThresholds)
+      this.thresholds[key] = aThresholds[key];
+  }
+}
+
+CrossSlideHandler.prototype = {
+  node: null,
+  drag: null,
+
+  getCrossSlideState: function(aCrossAxisDistance, aScrollAxisDistance) {
+    if (aCrossAxisDistance <= 0) {
+      return CrossSlidingState.STARTED;
+    }
+    if (aCrossAxisDistance < this.thresholds.SELECTIONSTART) {
+      return CrossSlidingState.DRAGGING;
+    }
+    if (aCrossAxisDistance < this.thresholds.SPEEDBUMPSTART) {
+      return CrossSlidingState.SELECTING;
+    }
+    if (aCrossAxisDistance < this.thresholds.SPEEDBUMPEND) {
+      return CrossSlidingState.SELECT_SPEED_BUMPING;
+    }
+    if (aCrossAxisDistance < this.thresholds.REARRANGESTART) {
+      return CrossSlidingState.SPEED_BUMPING;
+    }
+    // out of bounds cross-slide
+    return -1;
+  },
+
+  handleEvent: function handleEvent(aEvent) {
+    switch (aEvent.type) {
+      case "touchstart":
+        this._onTouchStart(aEvent);
+        break;
+      case "touchmove":
+        this._onTouchMove(aEvent);
+        break;
+      case "touchend":
+        this._onTouchEnd(aEvent);
+        break;
+    }
+  },
+
+  cancel: function(){
+    this._fireProgressEvent("cancelled", aEvent);
+    this.drag = null;
+  },
+
+  _onTouchStart: function onTouchStart(aEvent){
+    if (aEvent.touches.length > 1)
+      return;
+    let touch = aEvent.touches[0];
+     // cross-slide is a single touch gesture
+     // the top target is the one we need here, touch.target not relevant
+    let target = aEvent.target;
+
+    if (!isSelectable(target))
+        return;
+
+    // we'll handle this event, dont let it bubble further
+    aEvent.stopPropagation();
+
+    let scrollAxis = getScrollAxisFromElement(target);
+
+    this.drag = {
+      scrollAxis: scrollAxis,
+      crossAxis: (scrollAxis=='x') ? 'y' : 'x',
+      origin: pointFromTouchEvent(aEvent),
+      state: -1
+    };
+  },
+
+  _onTouchMove: function(aEvent){
+    if (!this.drag) {
+      return;
+    }
+    // event is handled here, dont let it bubble further
+    aEvent.stopPropagation();
+
+    if (aEvent.touches.length!==1) {
+      // cancel if another touch point gets involved
+      return this.cancel();
+    }
+
+    let startPt = this.drag.origin;
+    let endPt = this.drag.position = pointFromTouchEvent(aEvent);
+
+    let scrollAxis = this.drag.scrollAxis,
+        crossAxis = this.drag.crossAxis;
+
+    // distance from the origin along the axis perpendicular to scrolling
+    let crossAxisDistance = Math.abs(endPt[crossAxis] - startPt[crossAxis]);
+    // distance along the scrolling axis
+    let scrollAxisDistance = Math.abs(endPt[scrollAxis] - startPt[scrollAxis]);
+
+    let currState = this.drag.state;
+    let newState = this.getCrossSlideState(crossAxisDistance, scrollAxisDistance);
+
+    if (-1 == newState) {
+      // out of bounds, cancel the event always
+      return this.cancel();
+    }
+
+    let isWithinCone = withinCone(crossAxisDistance, scrollAxisDistance);
+    if (currState < CrossSlidingState.SELECTING && !isWithinCone) {
+      // ignore, no progress to report
+      return;
+    }
+    if (currState >= CrossSlidingState.SELECTING && !isWithinCone) {
+      // we're committed to a cross-slide gesture,
+      // so going out of bounds at this point means aborting
+      return this.cancel();
+    }
+
+    if (currState > newState) {
+      // moved backwards, ignoring
+      return;
+    }
+
+    this.drag.state = newState;
+    this._fireProgressEvent( CrossSlidingStateNames[newState], aEvent );
+  },
+  _onTouchEnd: function(aEvent){
+    if (!this.drag)
+      return;
+
+    // event is handled, dont let it bubble further
+    aEvent.stopPropagation();
+
+    if (this.drag.state < CrossSlidingState.SELECTING) {
+      return this.cancel();
+    }
+
+    this._fireProgressEvent("completed", aEvent);
+    this._fireSelectEvent(aEvent);
+    this.drag = null;
+  },
+
+  /**
+   * Dispatches a custom Event on the drag node.
+   * @param aEvent The source event.
+   * @param aType The event type.
+   */
+  _fireProgressEvent: function CrossSliding_fireEvent(aState, aEvent) {
+    if (!this.drag)
+        return;
+    let event = this.node.ownerDocument.createEvent("Events");
+    let crossAxis = this.drag.crossAxis;
+    event.initEvent("MozCrossSliding", true, true);
+    event.crossSlidingState = aState;
+    event.position = this.drag.position;
+    event.direction = this.drag.crossAxis;
+    event.delta = this.drag.position[crossAxis] - this.drag.origin[crossAxis];
+    aEvent.target.dispatchEvent(event);
+  },
+
+  /**
+   * Dispatches a custom Event on the given target node.
+   * @param aEvent The source event.
+   */
+  _fireSelectEvent: function SelectTarget_fireEvent(aEvent) {
+    let event = this.node.ownerDocument.createEvent("Events");
+    event.initEvent("MozCrossSlideSelect", true, true);
+    event.position = this.drag.position;
+    aEvent.target.dispatchEvent(event);
+  }
+};
+this.CrossSlide.Handler = CrossSlideHandler;
--- a/browser/metro/modules/Makefile.in
+++ b/browser/metro/modules/Makefile.in
@@ -4,14 +4,13 @@
 
 DEPTH      = @DEPTH@
 topsrcdir  = @top_srcdir@
 srcdir     = @srcdir@
 VPATH      = @srcdir@
 
 include $(DEPTH)/config/autoconf.mk
 include $(topsrcdir)/config/config.mk
-
 EXTRA_JS_MODULES = \
   colorUtils.jsm \
+  CrossSlide.jsm \
   $(NULL)
-
 include $(topsrcdir)/config/rules.mk
--- a/browser/metro/theme/platform.css
+++ b/browser/metro/theme/platform.css
@@ -485,16 +485,20 @@ richgriditem[selected] .richgrid-item-co
     background-image: url(chrome://browser/skin/images/tile-selected-check-hdpi.png);
     background-origin: border-box;
     background-position: right 0 top 0;
     background-repeat: no-repeat;
     /* scale the image whatever the dppx */
     background-size: 35px 35px;
     border: @metro_border_xthick@ solid @selected_color@;
 }
+/* ease the return to original position when cross-sliding */
+richgriditem:not([crosssliding]) {
+  transition: transform ease-out 0.2s;
+}
 
 richgriditem .richgrid-icon-container {
   padding-bottom: 2px;
 }
 
 richgriditem .richgrid-icon-box {
   padding: 4px;
   background: #fff;