Bug 706684 - Implement a simpler scale gesture listener on Android. r=Cwiiis
authorPatrick Walton <pwalton@mozilla.com>
Mon, 23 Jan 2012 19:18:24 -0800
changeset 86392 ca4d85ac6bf50317f6f9d52f314cb53bb2a1cf79
parent 86391 650f4fa9ca1244e9538edb4daabebbc805e6eee5
child 86393 4035cbbd550b233c28f5ac55e19314f25e72651c
push id805
push userakeybl@mozilla.com
push dateWed, 01 Feb 2012 18:17:35 +0000
treeherdermozilla-aurora@6fb3bf232436 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersCwiiis
bugs706684
milestone12.0a1
Bug 706684 - Implement a simpler scale gesture listener on Android. r=Cwiiis
mobile/android/base/Makefile.in
mobile/android/base/gfx/LayerController.java
mobile/android/base/gfx/LayerView.java
mobile/android/base/gfx/PointUtils.java
mobile/android/base/ui/PanZoomController.java
mobile/android/base/ui/SimpleScaleGestureDetector.java
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -120,16 +120,17 @@ FENNEC_JAVA_FILES = \
   gfx/SingleTileLayer.java \
   gfx/TextLayer.java \
   gfx/TextureReaper.java \
   gfx/TileLayer.java \
   gfx/ViewportMetrics.java \
   gfx/WidgetTileLayer.java \
   ui/Axis.java \
   ui/PanZoomController.java \
+  ui/SimpleScaleGestureDetector.java \
   ui/SubdocumentScrollHelper.java \
   GeckoNetworkManager.java \
   $(NULL)
 
 ifdef MOZ_WEBSMS_BACKEND
 FENNEC_JAVA_FILES += GeckoSmsManager.java
 endif
 
--- a/mobile/android/base/gfx/LayerController.java
+++ b/mobile/android/base/gfx/LayerController.java
@@ -38,16 +38,17 @@
 
 package org.mozilla.gecko.gfx;
 
 import org.mozilla.gecko.gfx.IntSize;
 import org.mozilla.gecko.gfx.Layer;
 import org.mozilla.gecko.gfx.LayerClient;
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.ui.PanZoomController;
+import org.mozilla.gecko.ui.SimpleScaleGestureDetector;
 import org.mozilla.gecko.GeckoApp;
 import android.content.Context;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.Point;
 import android.graphics.PointF;
 import android.graphics.Rect;
@@ -142,17 +143,19 @@ public class LayerController {
         return mViewportMetrics.getZoomFactor();
     }
 
     public Bitmap getBackgroundPattern()    { return getDrawable("background"); }
     public Bitmap getCheckerboardPattern()  { return getDrawable("checkerboard"); }
     public Bitmap getShadowPattern()        { return getDrawable("shadow"); }
 
     public GestureDetector.OnGestureListener getGestureListener()                   { return mPanZoomController; }
-    public ScaleGestureDetector.OnScaleGestureListener getScaleGestureListener()    { return mPanZoomController; }
+    public SimpleScaleGestureDetector.SimpleScaleGestureListener getScaleGestureListener() {
+        return mPanZoomController;
+    }
     public GestureDetector.OnDoubleTapListener getDoubleTapListener()               { return mPanZoomController; }
 
     private Bitmap getDrawable(String name) {
         Resources resources = mContext.getResources();
         int resourceID = resources.getIdentifier(name, "drawable", mContext.getPackageName());
         BitmapFactory.Options options = new BitmapFactory.Options();
         options.inScaled = false;
         return BitmapFactory.decodeResource(mContext.getResources(), resourceID, options);
--- a/mobile/android/base/gfx/LayerView.java
+++ b/mobile/android/base/gfx/LayerView.java
@@ -35,51 +35,52 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.gfx;
 
 import org.mozilla.gecko.gfx.FloatSize;
 import org.mozilla.gecko.gfx.InputConnectionHandler;
 import org.mozilla.gecko.gfx.LayerController;
+import org.mozilla.gecko.ui.SimpleScaleGestureDetector;
 import android.content.Context;
 import android.opengl.GLSurfaceView;
 import android.view.GestureDetector;
 import android.view.KeyEvent;
 import android.view.MotionEvent;
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputConnection;
-import android.view.ScaleGestureDetector;
 
 /**
  * A view rendered by the layer compositor.
  *
  * This view delegates to LayerRenderer to actually do the drawing. Its role is largely that of a
  * mediator between the LayerRenderer and the LayerController.
  */
 public class LayerView extends GLSurfaceView {
     private Context mContext;
     private LayerController mController;
     private InputConnectionHandler mInputConnectionHandler;
     private LayerRenderer mRenderer;
     private GestureDetector mGestureDetector;
-    private ScaleGestureDetector mScaleGestureDetector;
+    private SimpleScaleGestureDetector mScaleGestureDetector;
     private long mRenderTime;
     private boolean mRenderTimeReset;
 
     public LayerView(Context context, LayerController controller) {
         super(context);
 
         mContext = context;
         mController = controller;
         mRenderer = new LayerRenderer(this);
         setRenderer(mRenderer);
         setRenderMode(RENDERMODE_WHEN_DIRTY);
         mGestureDetector = new GestureDetector(context, controller.getGestureListener());
-        mScaleGestureDetector = new ScaleGestureDetector(context, controller.getScaleGestureListener());
+        mScaleGestureDetector =
+            new SimpleScaleGestureDetector(controller.getScaleGestureListener());
         mGestureDetector.setOnDoubleTapListener(controller.getDoubleTapListener());
         mInputConnectionHandler = null;
 
         setFocusable(true);
         setFocusableInTouchMode(true);
     }
 
     @Override
--- a/mobile/android/base/gfx/PointUtils.java
+++ b/mobile/android/base/gfx/PointUtils.java
@@ -34,16 +34,17 @@
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.gfx;
 
 import android.graphics.Point;
 import android.graphics.PointF;
+import android.util.FloatMath;
 
 import org.json.JSONObject;
 import org.json.JSONException;
 import org.mozilla.gecko.FloatUtils;
 
 import java.lang.Math;
 
 public final class PointUtils {
@@ -72,16 +73,21 @@ public final class PointUtils {
        return new PointF(x, y);
    }
 
    /* Computes the magnitude of the given vector. */
    public static float distance(PointF point) {
         return (float)Math.sqrt(point.x * point.x + point.y * point.y);
    }
 
+    /** Computes the scalar distance between two points. */
+    public static float distance(PointF one, PointF two) {
+        return PointF.length(one.x - two.x, one.y - two.y);
+    }
+
     public static JSONObject toJSON(PointF point) throws JSONException {
         // Ensure we put ints, not longs, because Gecko message handlers call getInt().
         int x = Math.round(point.x);
         int y = Math.round(point.y);
         JSONObject json = new JSONObject();
         json.put("x", x);
         json.put("y", y);
         return json;
--- a/mobile/android/base/ui/PanZoomController.java
+++ b/mobile/android/base/ui/PanZoomController.java
@@ -50,29 +50,28 @@ import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.GeckoEventListener;
 import android.graphics.PointF;
 import android.graphics.RectF;
 import android.util.FloatMath;
 import android.util.Log;
 import android.view.GestureDetector;
 import android.view.MotionEvent;
-import android.view.ScaleGestureDetector;
 import java.util.Timer;
 import java.util.TimerTask;
 
 /*
  * Handles the kinetic scrolling and zooming physics for a layer controller.
  *
  * Many ideas are from Joe Hewitt's Scrollability:
  *   https://github.com/joehewitt/scrollability/
  */
 public class PanZoomController
     extends GestureDetector.SimpleOnGestureListener
-    implements ScaleGestureDetector.OnScaleGestureListener, GeckoEventListener
+    implements SimpleScaleGestureDetector.SimpleScaleGestureListener, GeckoEventListener
 {
     private static final String LOGTAG = "GeckoPanZoomController";
 
     private static String MESSAGE_ZOOM_RECT = "Browser:ZoomToRect";
     private static String MESSAGE_ZOOM_PAGE = "Browser:ZoomToPageWidth";
 
     // Animation stops if the velocity is below this value when overscrolled or panning.
     private static final float STOPPED_THRESHOLD = 4.0f;
@@ -716,33 +715,33 @@ public class PanZoomController
         @Override
         protected float getPageLength() { return mController.getPageSize().height; }
     }
 
     /*
      * Zooming
      */
     @Override
-    public boolean onScaleBegin(ScaleGestureDetector detector) {
+    public boolean onScaleBegin(SimpleScaleGestureDetector detector) {
         Log.d(LOGTAG, "onScaleBegin in " + mState);
 
         if (mState == PanZoomState.ANIMATED_ZOOM)
             return false;
 
         mState = PanZoomState.PINCHING;
         mLastZoomFocus = new PointF(detector.getFocusX(), detector.getFocusY());
         GeckoApp.mAppContext.hidePluginViews();
         GeckoApp.mAppContext.mAutoCompletePopup.hide();
         cancelTouch();
 
         return true;
     }
 
     @Override
-    public boolean onScale(ScaleGestureDetector detector) {
+    public boolean onScale(SimpleScaleGestureDetector detector) {
         Log.d(LOGTAG, "onScale in state " + mState);
 
         if (mState == PanZoomState.ANIMATED_ZOOM)
             return false;
 
         float prevSpan = detector.getPreviousSpan();
         if (FloatUtils.fuzzyEquals(prevSpan, 0.0f)) {
             // let's eat this one to avoid setting the new zoom to infinity (bug 711453)
@@ -779,17 +778,17 @@ public class PanZoomController
         }
 
         mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY());
 
         return true;
     }
 
     @Override
-    public void onScaleEnd(ScaleGestureDetector detector) {
+    public void onScaleEnd(SimpleScaleGestureDetector detector) {
         Log.d(LOGTAG, "onScaleEnd in " + mState);
 
         if (mState == PanZoomState.ANIMATED_ZOOM)
             return;
 
         // switch back to the touching state
         startTouch(detector.getFocusX(), detector.getFocusY(), detector.getEventTime());
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/ui/SimpleScaleGestureDetector.java
@@ -0,0 +1,327 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Mozilla Android code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2012
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Patrick Walton <pcwalton@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * 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 ***** */
+
+package org.mozilla.gecko.ui;
+
+import org.mozilla.gecko.gfx.PointUtils;
+import org.json.JSONException;
+import android.graphics.PointF;
+import android.util.Log;
+import android.view.MotionEvent;
+import java.util.LinkedList;
+import java.util.ListIterator;
+import java.util.Stack;
+
+/**
+ * A less buggy, and smoother, replacement for the built-in Android ScaleGestureDetector.
+ *
+ * This gesture detector is more reliable than the built-in ScaleGestureDetector because:
+ *
+ *   - It doesn't assume that pointer IDs are numbered 0 and 1.
+ *
+ *   - It doesn't attempt to correct for "slop" when resting one's hand on the device. On some
+ *     devices (e.g. the Droid X) this can cause the ScaleGestureDetector to lose track of how many
+ *     pointers are down, with disastrous results (bug 706684).
+ *
+ *   - Cancelling a zoom into a pan is handled correctly.
+ *
+ *   - Starting with three or more fingers down, releasing fingers so that only two are down, and
+ *     then performing a scale gesture is handled correctly.
+ *
+ *   - It doesn't take pressure into account, which results in smoother scaling.
+ */
+public class SimpleScaleGestureDetector {
+    private static final String LOGTAG = "GeckoSimpleScaleGestureDetector";
+
+    private SimpleScaleGestureListener mListener;
+    private long mLastEventTime;
+
+    /* Information about all pointers that are down. */
+    private LinkedList<PointerInfo> mPointerInfo;
+
+    /** Creates a new gesture detector with the given listener. */
+    public SimpleScaleGestureDetector(SimpleScaleGestureListener listener) {
+        mListener = listener;
+        mPointerInfo = new LinkedList<PointerInfo>();
+    }
+
+    /** Forward touch events to this function. */
+    public void onTouchEvent(MotionEvent event) {
+        switch (event.getAction() & event.ACTION_MASK) {
+        case MotionEvent.ACTION_DOWN:
+        case MotionEvent.ACTION_POINTER_DOWN:
+            onTouchStart(event);
+            break;
+        case MotionEvent.ACTION_MOVE:
+            onTouchMove(event);
+            break;
+        case MotionEvent.ACTION_POINTER_UP:
+        case MotionEvent.ACTION_UP:
+        case MotionEvent.ACTION_CANCEL:
+            onTouchEnd(event);
+            break;
+        }
+    }
+
+    private int getPointersDown() {
+        return mPointerInfo.size();
+    }
+
+    private void onTouchStart(MotionEvent event) {
+        mLastEventTime = event.getEventTime();
+        mPointerInfo.push(PointerInfo.create(event, event.getActionIndex()));
+        if (getPointersDown() == 2) {
+            sendScaleGesture(EventType.BEGIN);
+        }
+    }
+
+    private void onTouchMove(MotionEvent event) {
+        mLastEventTime = event.getEventTime();
+        for (int i = 0; i < event.getPointerCount(); i++) {
+            PointerInfo pointerInfo = pointerInfoForEventIndex(event, i);
+            if (pointerInfo != null) {
+                pointerInfo.populate(event, i);
+            }
+        }
+
+        if (getPointersDown() == 2) {
+            sendScaleGesture(EventType.CONTINUE);
+        }
+    }
+
+    private void onTouchEnd(MotionEvent event) {
+        mLastEventTime = event.getEventTime();
+
+        int id = event.getPointerId(event.getActionIndex());
+        ListIterator<PointerInfo> iterator = mPointerInfo.listIterator();
+        while (iterator.hasNext()) {
+            PointerInfo pointerInfo = iterator.next();
+            if (pointerInfo.getId() != id) {
+                continue;
+            }
+
+            // One of the pointers we were tracking was lifted. Remove its info object from the
+            // list, recycle it to avoid GC pauses, and send an onScaleEnd() notification if this
+            // ended the gesture.
+            iterator.remove();
+            pointerInfo.recycle();
+            if (getPointersDown() == 1) {
+                sendScaleGesture(EventType.END);
+            }
+        }
+    }
+
+    /**
+     * Returns the X coordinate of the focus location (the midpoint of the two fingers). If only
+     * one finger is down, returns the location of that finger.
+     */
+    public float getFocusX() {
+        switch (getPointersDown()) {
+        case 1:
+            return mPointerInfo.getFirst().getCurrent().x;
+        case 2:
+            PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
+            return (pointerA.getCurrent().x + pointerB.getCurrent().x) / 2.0f;
+        }
+
+        Log.e(LOGTAG, "No gesture taking place in getFocusX()!");
+        return 0.0f;
+    }
+
+    /**
+     * Returns the Y coordinate of the focus location (the midpoint of the two fingers). If only
+     * one finger is down, returns the location of that finger.
+     */
+    public float getFocusY() {
+        switch (getPointersDown()) {
+        case 1:
+            return mPointerInfo.getFirst().getCurrent().y;
+        case 2:
+            PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
+            return (pointerA.getCurrent().y + pointerB.getCurrent().y) / 2.0f;
+        }
+
+        Log.e(LOGTAG, "No gesture taking place in getFocusY()!");
+        return 0.0f;
+    }
+
+    /** Returns the most recent distance between the two pointers. */
+    public float getCurrentSpan() {
+        if (getPointersDown() != 2) {
+            Log.e(LOGTAG, "No gesture taking place in getCurrentSpan()!");
+            return 0.0f;
+        }
+
+        PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
+        return PointUtils.distance(pointerA.getCurrent(), pointerB.getCurrent());
+    }
+
+    /** Returns the second most recent distance between the two pointers. */
+    public float getPreviousSpan() {
+        if (getPointersDown() != 2) {
+            Log.e(LOGTAG, "No gesture taking place in getPreviousSpan()!");
+            return 0.0f;
+        }
+
+        PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
+        PointF a = pointerA.getPrevious(), b = pointerB.getPrevious();
+        if (a == null || b == null) {
+            a = pointerA.getCurrent();
+            b = pointerB.getCurrent();
+        }
+
+        return PointUtils.distance(a, b);
+    }
+
+    /** Returns the time of the last event related to the gesture. */
+    public long getEventTime() {
+        return mLastEventTime;
+    }
+
+    /** Returns true if the scale gesture is in progress and false otherwise. */
+    public boolean isInProgress() {
+        return getPointersDown() == 2;
+    }
+
+    /* Sends the requested scale gesture notification to the listener. */
+    private void sendScaleGesture(EventType eventType) {
+        switch (eventType) {
+        case BEGIN:     mListener.onScaleBegin(this);   break;
+        case CONTINUE:  mListener.onScale(this);        break;
+        case END:       mListener.onScaleEnd(this);     break;
+        }
+    }
+
+    /*
+     * Returns the pointer info corresponding to the given pointer index, or null if the pointer
+     * isn't one that's being tracked.
+     */
+    private PointerInfo pointerInfoForEventIndex(MotionEvent event, int index) {
+        int id = event.getPointerId(index);
+        for (PointerInfo pointerInfo : mPointerInfo) {
+            if (pointerInfo.getId() == id) {
+                return pointerInfo;
+            }
+        }
+        return null;
+    }
+
+    private enum EventType {
+        BEGIN,
+        CONTINUE,
+        END,
+    }
+
+    /* Encapsulates information about one of the two fingers involved in the gesture. */
+    private static class PointerInfo {
+        /* A free list that recycles pointer info objects, to reduce GC pauses. */
+        private static Stack<PointerInfo> sPointerInfoFreeList;
+
+        private int mId;
+        private PointF mCurrent, mPrevious;
+
+        private PointerInfo() {
+            // External users should use create() instead.
+        }
+
+        /* Creates or recycles a new PointerInfo instance from an event and a pointer index. */
+        public static PointerInfo create(MotionEvent event, int index) {
+            if (sPointerInfoFreeList == null) {
+                sPointerInfoFreeList = new Stack<PointerInfo>();
+            }
+
+            PointerInfo pointerInfo;
+            if (sPointerInfoFreeList.empty()) {
+                pointerInfo = new PointerInfo();
+            } else {
+                pointerInfo = sPointerInfoFreeList.pop();
+            }
+
+            pointerInfo.populate(event, index);
+            return pointerInfo;
+        }
+
+        /*
+         * Fills in the fields of this instance from the given motion event and pointer index
+         * within that event.
+         */
+        public void populate(MotionEvent event, int index) {
+            mId = event.getPointerId(index);
+            mPrevious = mCurrent;
+            mCurrent = new PointF(event.getX(index), event.getY(index));
+        }
+
+        public void recycle() {
+            mId = -1;
+            mPrevious = mCurrent = null;
+            sPointerInfoFreeList.push(this);
+        }
+
+        public int getId() { return mId; }
+        public PointF getCurrent() { return mCurrent; }
+        public PointF getPrevious() { return mPrevious; }
+
+        @Override
+        public String toString() {
+            if (mId == -1) {
+                return "(up)";
+            }
+
+            try {
+                String prevString;
+                if (mPrevious == null) {
+                    prevString = "n/a";
+                } else {
+                    prevString = PointUtils.toJSON(mPrevious).toString();
+                }
+
+                // The current position should always be non-null.
+                String currentString = PointUtils.toJSON(mCurrent).toString();
+                return "id=" + mId + " cur=" + currentString + " prev=" + prevString;
+            } catch (JSONException e) {
+                throw new RuntimeException(e);
+            }
+        }
+    }
+
+    public static interface SimpleScaleGestureListener {
+        public boolean onScale(SimpleScaleGestureDetector detector);
+        public boolean onScaleBegin(SimpleScaleGestureDetector detector);
+        public void onScaleEnd(SimpleScaleGestureDetector detector);
+    }
+}
+