Bug 697701 - Support double tap zooming. r=pcwalton
authorWes Johnston <wjohnston@mozilla.com>
Tue, 15 Nov 2011 13:41:19 -0800
changeset 81843 8f3eacc9ceb6374cf8b96910d9b61c6548d124d5
parent 81842 f5c60c4ec1ed2bc02a9701fc4e571c4fa5fe5d00
child 81844 6b1414e0a6e8d10578fb889b0fb305f0e0f42915
push idunknown
push userunknown
push dateunknown
reviewerspcwalton
bugs697701
milestone11.0a1
Bug 697701 - Support double tap zooming. r=pcwalton
embedding/android/GeckoApp.java
embedding/android/gfx/LayerController.java
embedding/android/gfx/LayerView.java
embedding/android/gfx/RectUtils.java
embedding/android/ui/PanZoomController.java
mobile/chrome/content/browser.js
--- a/embedding/android/GeckoApp.java
+++ b/embedding/android/GeckoApp.java
@@ -769,16 +769,35 @@ abstract public class GeckoApp
                     }
                 });
             } else if (event.equals("ToggleChrome:Show")) {
                 mMainHandler.post(new Runnable() {
                     public void run() {
                         mBrowserToolbar.setVisibility(View.VISIBLE);
                     }
                 });
+            } else if (event.equals("Browser:ZoomToRect")) {
+                if (mLayerController != null) {
+                    float x = (float)message.getDouble("x");
+                    float y = (float)message.getDouble("y");
+                    float width = (float)message.getDouble("w");
+                    float height = (float)message.getDouble("h");
+                    mLayerController.getPanZoomController().animatedZoomTo(x, y, width, height, 200);
+                }
+            } else if (event.equals("Browser:ZoomToPageWidth")) {
+                if (mLayerController != null) {
+                    IntSize pageSize = mLayerController.getPageSize();
+                    RectF viewableRect = mLayerController.getVisibleRect();
+                    // attempt to keep the middle of the screen in the middle of the screen
+                    float y = viewableRect.top;
+                    float dh = (viewableRect.height() * pageSize.width/viewableRect.width()) - viewableRect.height(); // increase in the height
+                    y -= dh/2;
+                    mLayerController.getPanZoomController().animatedZoomTo(0, y, pageSize.width,
+                                                    pageSize.width * viewableRect.height()/viewableRect.width(), 200);
+                }
             }
         } catch (Exception e) { 
             Log.i(LOG_NAME, "handleMessage throws " + e + " for message: " + event);
         }
     }
 
     void handleDoorHanger(JSONObject geckoObject) throws JSONException {
         final String message = geckoObject.getString("message");
@@ -1123,16 +1142,18 @@ abstract public class GeckoApp
         GeckoAppShell.registerGeckoEventListener("Menu:Remove", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Preferences:Data", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Gecko:Ready", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Toast:Show", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("PanZoom:Ack", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("PanZoom:Resize", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("ToggleChrome:Hide", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("ToggleChrome:Show", GeckoApp.mAppContext);
+        GeckoAppShell.registerGeckoEventListener("Browser:ZoomToRect", GeckoApp.mAppContext);
+        GeckoAppShell.registerGeckoEventListener("Browser:ZoomToPageWidth", GeckoApp.mAppContext);
 
         mConnectivityFilter = new IntentFilter();
         mConnectivityFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
         mConnectivityReceiver = new GeckoConnectivityReceiver();
 
         mBatteryFilter = new IntentFilter();
         mBatteryFilter.addAction(Intent.ACTION_BATTERY_CHANGED);
         mBatteryReceiver = new GeckoBatteryManager();
@@ -1323,19 +1344,20 @@ abstract public class GeckoApp
         GeckoAppShell.unregisterGeckoEventListener("Doorhanger:Add", GeckoApp.mAppContext);
         GeckoAppShell.unregisterGeckoEventListener("Menu:Add", GeckoApp.mAppContext);
         GeckoAppShell.unregisterGeckoEventListener("Menu:Remove", GeckoApp.mAppContext);
         GeckoAppShell.unregisterGeckoEventListener("Preferences:Data", GeckoApp.mAppContext);
         GeckoAppShell.unregisterGeckoEventListener("Gecko:Ready", GeckoApp.mAppContext);
         GeckoAppShell.unregisterGeckoEventListener("Toast:Show", GeckoApp.mAppContext);
         GeckoAppShell.unregisterGeckoEventListener("ToggleChrome:Hide", GeckoApp.mAppContext);
         GeckoAppShell.unregisterGeckoEventListener("ToggleChrome:Show", GeckoApp.mAppContext);
+        GeckoAppShell.unregisterGeckoEventListener("Browser:ZoomToRect", GeckoApp.mAppContext);
+        GeckoAppShell.unregisterGeckoEventListener("Browser:ZoomToPageWidth", GeckoApp.mAppContext);
 
         mFavicons.close();
-
         super.onDestroy();
     }
 
     @Override
     public void onConfigurationChanged(android.content.res.Configuration newConfig)
     {
         Log.i(LOG_NAME, "configuration changed");
         // nothing, just ignore
--- a/embedding/android/gfx/LayerController.java
+++ b/embedding/android/gfx/LayerController.java
@@ -115,18 +115,20 @@ public class LayerController {
     public Context getContext()         { return mContext; }
     public RectF getVisibleRect()       { return mVisibleRect; }
     public IntSize getScreenSize()      { return mScreenSize; }
     public IntSize getPageSize()        { return mPageSize; }
 
     public Bitmap getCheckerboardPattern()  { return getDrawable("checkerboard"); }
     public Bitmap getShadowPattern()        { return getDrawable("shadow"); }
 
+    public PanZoomController getPanZoomController()                                 { return mPanZoomController; }
     public GestureDetector.OnGestureListener getGestureListener()                   { return mPanZoomController; }
     public ScaleGestureDetector.OnScaleGestureListener 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);
     }
@@ -157,16 +159,20 @@ public class LayerController {
     public void setNeedsDisplay() {
         // TODO
     }
 
     public void scrollTo(float x, float y) {
         setVisibleRect(x, y, mVisibleRect.width(), mVisibleRect.height());
     }
 
+    public void setVisibleRect(RectF aRect) {
+        setVisibleRect(aRect.left, aRect.top, aRect.width(), aRect.height());
+    }
+
     public void setVisibleRect(float x, float y, float width, float height) {
         mVisibleRect = new RectF(x, y, x + width, y + height);
         setNeedsDisplay();
         GeckoApp.mAppContext.repositionPluginViews();
     }
 
     /**
      * Sets the zoom factor to 1, adjusting the visible rect accordingly. The Gecko layer client
@@ -216,16 +222,21 @@ public class LayerController {
         return aboutToCheckerboard();
     }
 
     private RectF getTileRect() {
         float x = mRootLayer.origin.x, y = mRootLayer.origin.y;
         return new RectF(x, y, x + TILE_WIDTH, y + TILE_HEIGHT);
     }
 
+    public RectF clampToScreenSize(RectF aRect) {
+        RectF pageRect = new RectF(0, 0, mPageSize.width, mPageSize.height);
+        return RectUtils.clamp(aRect, pageRect);
+    }
+
     // Returns true if a checkerboard is about to be visible.
     private boolean aboutToCheckerboard() {
         Rect pageRect = new Rect(0, 0, mPageSize.width, mPageSize.height);
         Rect adjustedPageRect = RectUtils.contract(pageRect, DANGER_ZONE_X, DANGER_ZONE_Y);
         RectF visiblePageRect = RectUtils.intersect(mVisibleRect, new RectF(adjustedPageRect));
         RectF adjustedTileRect = RectUtils.contract(getTileRect(), DANGER_ZONE_X, DANGER_ZONE_Y);
         return !adjustedTileRect.contains(visiblePageRect);
     }
--- a/embedding/android/gfx/LayerView.java
+++ b/embedding/android/gfx/LayerView.java
@@ -65,16 +65,17 @@ public class LayerView extends GLSurface
     public LayerView(Context context, LayerController controller) {
         super(context);
 
         mContext = context;
         mController = controller;
         mRenderer = new LayerRenderer(this);
         setRenderer(mRenderer);
         mGestureDetector = new GestureDetector(context, controller.getGestureListener());
+        mGestureDetector.setOnDoubleTapListener(controller.getDoubleTapListener());
         mScaleGestureDetector = new ScaleGestureDetector(context, controller.getScaleGestureListener());
         mInputConnectionHandler = null;
 
         setFocusable(true);
         setFocusableInTouchMode(true);
     }
 
     @Override
--- a/embedding/android/gfx/RectUtils.java
+++ b/embedding/android/gfx/RectUtils.java
@@ -83,9 +83,24 @@ public final class RectUtils {
 
     public static RectF scale(RectF rect, float scale) {
         float x = rect.left * scale;
         float y = rect.top * scale;
         return new RectF(x, y,
                          x + (rect.width() * scale),
                          y + (rect.height() * scale));
     }
+
+    public static RectF clamp(RectF rect, RectF dest) {
+        float width = Math.min(rect.width(), dest.width());
+        float height = Math.min(rect.height(), dest.height());
+        float x = Math.max(dest.left, Math.min(dest.width()-rect.width(), rect.left));
+        float y = Math.max(dest.top, Math.min(dest.height()-rect.height(), rect.top));
+        return new RectF(x, y, width, height);
+    }
+
+    public static RectF blend(RectF aRect1, RectF aRect2, float aBlendAmount) {
+        return new RectF((aRect1.left-aRect2.left)*aBlendAmount + aRect2.left,
+                         (aRect1.top-aRect2.top)*aBlendAmount + aRect2.top,
+                         (aRect1.width()-aRect2.width())*aBlendAmount + aRect2.width(),
+                         (aRect1.height()-aRect2.height())*aBlendAmount + aRect2.height());
+    }
 }
--- a/embedding/android/ui/PanZoomController.java
+++ b/embedding/android/ui/PanZoomController.java
@@ -35,36 +35,39 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.ui;
 
 import org.json.JSONObject;
 import org.mozilla.gecko.gfx.IntSize;
 import org.mozilla.gecko.gfx.LayerController;
+import org.mozilla.gecko.gfx.RectUtils;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import android.graphics.PointF;
 import android.graphics.RectF;
 import android.util.Log;
 import android.view.GestureDetector;
 import android.view.MotionEvent;
 import android.view.ScaleGestureDetector;
 import java.util.Timer;
 import java.util.TimerTask;
+import java.util.Date;
 
 /*
  * 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
+    implements ScaleGestureDetector.OnScaleGestureListener,
+               GestureDetector.OnDoubleTapListener
 {
     private static final String LOG_NAME = "PanZoomController";
 
     private LayerController mController;
 
     private static final float FRICTION = 0.97f;
     // Animation stops if the velocity is below this value.
     private static final float STOPPED_THRESHOLD = 4.0f;
@@ -593,9 +596,63 @@ public class PanZoomController
             ret.put("y", (int)Math.round(point.y));
         } catch(Exception ex) {
             Log.w(LOG_NAME, "Error building return: " + ex);
         }
 
         GeckoEvent e = new GeckoEvent("Gesture:LongPress", ret.toString());
         GeckoAppShell.sendEventToGecko(e);
     }
+
+    @Override
+    public boolean onDoubleTap(MotionEvent motionEvent) {
+        JSONObject ret = new JSONObject();
+        try {
+            PointF point = new PointF(motionEvent.getX(), motionEvent.getY());
+            point = mController.convertViewPointToLayerPoint(point);
+            ret.put("x", (int)Math.round(point.x));
+            ret.put("y", (int)Math.round(point.y));
+        } catch(Exception ex) {
+            throw new RuntimeException(ex);
+        }
+
+        GeckoEvent e = new GeckoEvent("Gesture:DoubleTap", ret.toString());
+        GeckoAppShell.sendEventToGecko(e);
+        return true;
+    }
+
+    private Timer mZoomTimer;
+    public boolean animatedZoomTo(float x, float y, float width, float height, final float duration) {
+        if (mZoomTimer != null) {
+            mZoomTimer.cancel();
+        }
+
+        IntSize screenSize = mController.getScreenSize();
+        float newHeight = width * screenSize.height / screenSize.width;
+        // if the requested rect would not fill the screen, shift it to be centered
+        if (height < newHeight) {
+            y -= (newHeight - height)/2;
+        }
+        final RectF finalRect = mController.clampToScreenSize(new RectF(x, y, width, newHeight));
+
+        mZoomTimer = new Timer();
+        final RectF startRect = mController.getVisibleRect();
+        final long startTime = new Date().getTime();
+        mZoomTimer.scheduleAtFixedRate(new TimerTask() {
+            public void run() {
+                long now = new Date().getTime();
+                float dt = (float)(now - startTime)/duration;
+                if (dt < 1) {
+                    mController.setVisibleRect(RectUtils.blend(finalRect, startRect, dt));
+                    mController.notifyLayerClientOfGeometryChange();
+                    geometryChanged();
+                } else {
+                    mController.setVisibleRect(finalRect);
+                    mController.notifyLayerClientOfGeometryChange();
+                    geometryChanged();
+                    mZoomTimer.cancel();
+                    mZoomTimer = null;
+                }
+            }
+        }, 0, 1000L/60L);
+        return true;
+    }
 }
--- a/mobile/chrome/content/browser.js
+++ b/mobile/chrome/content/browser.js
@@ -157,16 +157,17 @@ var BrowserApp = {
     Services.obs.addObserver(this, "SaveAs:PDF", false);
     Services.obs.addObserver(this, "Browser:Quit", false);
     Services.obs.addObserver(this, "Preferences:Get", false);
     Services.obs.addObserver(this, "Preferences:Set", false);
     Services.obs.addObserver(this, "ScrollTo:FocusedInput", false);
     Services.obs.addObserver(this, "Sanitize:ClearAll", false);
     Services.obs.addObserver(this, "PanZoom:PanZoom", false);
     Services.obs.addObserver(this, "FullScreen:Exit", false);
+    Services.obs.addObserver(this, "Gesture:DoubleTap", false);
 
     Services.obs.addObserver(XPInstallObserver, "addon-install-blocked", false);
     Services.obs.addObserver(XPInstallObserver, "addon-install-started", false);
 
     function showFullScreenWarning() {
       NativeWindow.toast.show(Strings.browser.GetStringFromName("alertFullScreenToast"), "short");
     }
 
@@ -609,16 +610,48 @@ var BrowserApp = {
     } else if (aTopic == "ScrollTo:FocusedInput") {
       this.scrollToFocusedInput(browser);
     } else if (aTopic == "Sanitize:ClearAll") {
       Sanitizer.sanitize();
     } else if (aTopic == "PanZoom:PanZoom") {
       this.panZoom(aData);
     } else if (aTopic == "FullScreen:Exit") {
       browser.contentDocument.mozCancelFullScreen();
+    } else if (aTopic == "Gesture:DoubleTap") {
+      this.onDoubleTap(aData);
+    }
+  },
+
+  onDoubleTap: function(aData) {
+    let data = JSON.parse(aData);
+
+    let rect = {};
+    let win = BrowserApp.selectedBrowser.contentWindow;
+    let element = ElementTouchHelper.anyElementFromPoint(win, data.x, data.y);
+
+    win = element.ownerDocument.defaultView;
+    while (element && win.getComputedStyle(element,null).display == "inline")
+      element = element.parentNode;
+    if (!element || element == this._zoomedToElement) {
+      this._zoomedToElement = null;
+      // zoom out, try to keep the center in the center of the page
+      setTimeout(function() {
+        rect.type = "Browser:ZoomToPageWidth";
+        sendMessageToJava({ gecko: rect });
+      }, 0);
+    } else if (element) {
+      const margin = 15;
+      this._zoomedToElement = element;
+      rect = ElementTouchHelper.getBoundingContentRect(element);
+      setTimeout(function() {
+        rect.type = "Browser:ZoomToRect";
+        rect.x -= 2*margin;
+        rect.w += 2*margin;
+        sendMessageToJava({ gecko: rect });
+      }, 0);
     }
   }
 }
 
 var NativeWindow = {
   init: function() {
     Services.obs.addObserver(this, "Menu:Clicked", false);
     Services.obs.addObserver(this, "Doorhanger:Reply", false);
@@ -1836,16 +1869,42 @@ const ElementTouchHelper = {
       let r = nativeRects[i];
       result.push({ left: r.left + offset.x,
                     top: r.top + offset.y,
                     width: r.width,
                     height: r.height
                   });
     }
     return result;
+  },
+  getBoundingContentRect: function(aElement) {
+    if (!aElement)
+      return {x: 0, y: 0, w: 0, h: 0};
+  
+    let document = aElement.ownerDocument;
+    while (document.defaultView.frameElement)
+      document = document.defaultView.frameElement.ownerDocument;
+  
+    let cwu = document.defaultView.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+    let scrollX = {}, scrollY = {};
+    cwu.getScrollXY(false, scrollX, scrollY);
+  
+    let r = aElement.getBoundingClientRect();
+ 
+    // step out of iframes and frames, offsetting scroll values
+    for (let frame = aElement.ownerDocument.defaultView; frame != content; frame = frame.parent) {
+      // adjust client coordinates' origin to be top left of iframe viewport
+      let rect = frame.frameElement.getBoundingClientRect();
+      let left = frame.getComputedStyle(frame.frameElement, "").borderLeftWidth;
+      let top = frame.getComputedStyle(frame.frameElement, "").borderTopWidth;
+      scrollX.value += rect.left + parseInt(left);
+      scrollY.value += rect.top + parseInt(top);
+    }
+
+    return {x: r.left + scrollX.value, y: r.top + scrollY.value, w: r.width, h: r.height};
   }
 };
 
 var ErrorPageEventHandler = {
   handleEvent: function(aEvent) {
     switch (aEvent.type) {
       case "click": {
         // Don't trust synthetic events