Niedzielski has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/311152

Change subject: Upgrade to Fresco v0.13.0
......................................................................

Upgrade to Fresco v0.13.0

Also replace demo code copies per release notes[0,1,2]. From 6efeb95:

  [3]: MultiPointerGestureDetector, TransformGestureDetector
  [4]: DefaultZoomableController, ZoomableController, ZoomableDraweeView

The javax.annotation.Nullable import was changed to the Support library
and Checkstyle config updated to ignore these files.

[0] https://github.com/facebook/fresco/releases/tag/v0.11.0
[1] https://github.com/facebook/fresco/releases/tag/v0.12.0
[2] https://github.com/facebook/fresco/releases/tag/v0.13.0
[3] 
https://github.com/facebook/fresco/tree/master/samples/gestures/src/main/java/com/facebook/samples/gestures
[4] 
https://github.com/facebook/fresco/tree/master/samples/zoomable/src/main/java/com/facebook/samples/zoomable

Change-Id: I64be6993cb1ec4acb6509daf3a1d8a7373b28431
---
M app/build.gradle
M 
app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java
M app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java
A 
app/src/main/java/com/facebook/samples/zoomable/AbstractAnimatedZoomableController.java
A 
app/src/main/java/com/facebook/samples/zoomable/AnimatedZoomableController.java
M app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java
A app/src/main/java/com/facebook/samples/zoomable/GestureListenerWrapper.java
M app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java
M app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java
M app/src/main/java/org/wikipedia/views/FaceAndColorDetectImageView.java
M gradle/src/checkstyle.gradle
11 files changed, 1,130 insertions(+), 415 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/apps/android/wikipedia 
refs/changes/52/311152/1

diff --git a/app/build.gradle b/app/build.gradle
index b7445c1..e2ac521 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -170,7 +170,7 @@
     String supportVersion = '24.1.1'
     String espressoVersion = '2.2.2'
     String butterKnifeVersion = '8.4.0'
-    String frescoVersion = '0.10.0'
+    String frescoVersion = '0.13.0'
     String testingSupportVersion = '0.5'
     String mockitoCore = 'org.mockito:mockito-core:1.9.5'
 
diff --git 
a/app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java
 
b/app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java
index 5ed4874..97594ee 100644
--- 
a/app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java
+++ 
b/app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java
@@ -25,25 +25,26 @@
 
   /** The listener for receiving notifications when gestures occur. */
   public interface Listener {
-    /** Responds to the beginning of a gesture. */
-    void onGestureBegin(MultiPointerGestureDetector detector);
+    /** A callback called right before the gesture is about to start. */
+    public void onGestureBegin(MultiPointerGestureDetector detector);
 
-    /** Responds to the update of a gesture in progress. */
-    void onGestureUpdate(MultiPointerGestureDetector detector);
+    /** A callback called each time the gesture gets updated. */
+    public void onGestureUpdate(MultiPointerGestureDetector detector);
 
-    /** Responds to the end of a gesture. */
-    void onGestureEnd(MultiPointerGestureDetector detector);
+    /** A callback called right after the gesture has finished. */
+    public void onGestureEnd(MultiPointerGestureDetector detector);
   }
 
   private static final int MAX_POINTERS = 2;
 
   private boolean mGestureInProgress;
-  private int mCount;
-  private final int[] mId = new int[MAX_POINTERS];
-  private final float[] mStartX = new float[MAX_POINTERS];
-  private final float[] mStartY = new float[MAX_POINTERS];
-  private final float[] mCurrentX = new float[MAX_POINTERS];
-  private final float[] mCurrentY = new float[MAX_POINTERS];
+  private int mPointerCount;
+  private int mNewPointerCount;
+  private final int mId[] = new int[MAX_POINTERS];
+  private final float mStartX[] = new float[MAX_POINTERS];
+  private final float mStartY[] = new float[MAX_POINTERS];
+  private final float mCurrentX[] = new float[MAX_POINTERS];
+  private final float mCurrentY[] = new float[MAX_POINTERS];
 
   private Listener mListener = null;
 
@@ -69,7 +70,7 @@
    */
   public void reset() {
     mGestureInProgress = false;
-    mCount = 0;
+    mPointerCount = 0;
     for (int i = 0; i < MAX_POINTERS; i++) {
       mId[i] = MotionEvent.INVALID_POINTER_ID;
     }
@@ -83,15 +84,21 @@
     return true;
   }
 
+  /**
+   * Starts a new gesture and calls the listener just before starting it.
+   */
   private void startGesture() {
     if (!mGestureInProgress) {
-      mGestureInProgress = true;
       if (mListener != null) {
         mListener.onGestureBegin(this);
       }
+      mGestureInProgress = true;
     }
   }
 
+  /**
+   * Stops the current gesture and calls the listener right after stopping it.
+   */
   private void stopGesture() {
     if (mGestureInProgress) {
       mGestureInProgress = false;
@@ -110,12 +117,51 @@
     final int count = event.getPointerCount();
     final int action = event.getActionMasked();
     final int index = event.getActionIndex();
-    if (action == MotionEvent.ACTION_UP || action == 
MotionEvent.ACTION_POINTER_UP) {
+    if (action == MotionEvent.ACTION_UP ||
+        action == MotionEvent.ACTION_POINTER_UP) {
       if (i >= index) {
         i++;
       }
     }
     return (i < count) ? i : -1;
+  }
+
+  /**
+   * Gets the number of pressed pointers (fingers down).
+   */
+  private static int getPressedPointerCount(MotionEvent event) {
+    int count = event.getPointerCount();
+    int action = event.getActionMasked();
+    if (action == MotionEvent.ACTION_UP ||
+        action == MotionEvent.ACTION_POINTER_UP) {
+      count--;
+    }
+    return count;
+  }
+
+  private void updatePointersOnTap(MotionEvent event) {
+    mPointerCount = 0;
+    for (int i = 0; i < MAX_POINTERS; i++) {
+      int index = getPressedPointerIndex(event, i);
+      if (index == -1) {
+        mId[i] = MotionEvent.INVALID_POINTER_ID;
+      } else {
+        mId[i] = event.getPointerId(index);
+        mCurrentX[i] = mStartX[i] = event.getX(index);
+        mCurrentY[i] = mStartY[i] = event.getY(index);
+        mPointerCount++;
+      }
+    }
+  }
+
+  private void updatePointersOnMove(MotionEvent event) {
+    for (int i = 0; i < MAX_POINTERS; i++) {
+      int index = event.findPointerIndex(mId[i]);
+      if (index != -1) {
+        mCurrentX[i] = event.getX(index);
+        mCurrentY[i] = event.getY(index);
+      }
+    }
   }
 
   /**
@@ -125,17 +171,11 @@
    */
   public boolean onTouchEvent(final MotionEvent event) {
     switch (event.getActionMasked()) {
-      case MotionEvent.ACTION_MOVE:
+      case MotionEvent.ACTION_MOVE: {
         // update pointers
-        for (int i = 0; i < MAX_POINTERS; i++) {
-          int index = event.findPointerIndex(mId[i]);
-          if (index != -1) {
-            mCurrentX[i] = event.getX(index);
-            mCurrentY[i] = event.getY(index);
-          }
-        }
+        updatePointersOnMove(event);
         // start a new gesture if not already started
-        if (!mGestureInProgress && shouldStartGesture()) {
+        if (!mGestureInProgress && mPointerCount > 0 && shouldStartGesture()) {
           startGesture();
         }
         // notify listener
@@ -143,47 +183,33 @@
           mListener.onGestureUpdate(this);
         }
         break;
+      }
 
       case MotionEvent.ACTION_DOWN:
       case MotionEvent.ACTION_POINTER_DOWN:
       case MotionEvent.ACTION_POINTER_UP:
-      case MotionEvent.ACTION_UP:
-        // we'll restart the current gesture (if any) whenever the number of 
pointers changes
-        // NOTE: we only restart existing gestures here, new gestures are 
started in ACTION_MOVE
-        boolean wasGestureInProgress = mGestureInProgress;
+      case MotionEvent.ACTION_UP: {
+        // restart gesture whenever the number of pointers changes
+        mNewPointerCount = getPressedPointerCount(event);
         stopGesture();
-        reset();
-        // update pointers
-        for (int i = 0; i < MAX_POINTERS; i++) {
-          int index = getPressedPointerIndex(event, i);
-          if (index == -1) {
-            break;
-          }
-          mId[i] = event.getPointerId(index);
-          mStartX[i] = event.getX(index);
-          mCurrentX[i] = mStartX[i];
-          mStartY[i] = event.getY(index);
-          mCurrentY[i] = mStartY[i];
-          mCount++;
-        }
-        // restart the gesture (if any) if there are still pointers left
-        if (wasGestureInProgress && mCount > 0) {
+        updatePointersOnTap(event);
+        if (mPointerCount > 0 && shouldStartGesture()) {
           startGesture();
         }
         break;
+      }
 
-      case MotionEvent.ACTION_CANCEL:
+      case MotionEvent.ACTION_CANCEL: {
+        mNewPointerCount = 0;
         stopGesture();
         reset();
         break;
-
-      default:
-        break;
+      }
     }
     return true;
   }
 
-  /** Restarts the current gesture */
+  /** Restarts the current gesture (if any).  */
   public void restartGesture() {
     if (!mGestureInProgress) {
       return;
@@ -196,14 +222,19 @@
     startGesture();
   }
 
-  /** Gets whether gesture is in progress or not */
+  /** Gets whether there is a gesture in progress */
   public boolean isGestureInProgress() {
     return mGestureInProgress;
   }
 
+  /** Gets the number of pointers after the current gesture */
+  public int getNewPointerCount() {
+    return mNewPointerCount;
+  }
+
   /** Gets the number of pointers in the current gesture */
-  public int getCount() {
-    return mCount;
+  public int getPointerCount() {
+    return mPointerCount;
   }
 
   /**
diff --git 
a/app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java 
b/app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java
index d5baf79..7cebf58 100644
--- 
a/app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java
+++ 
b/app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java
@@ -25,14 +25,14 @@
 
   /** The listener for receiving notifications when gestures occur. */
   public interface Listener {
-    /** Responds to the beginning of a gesture. */
-    void onGestureBegin(TransformGestureDetector detector);
+    /** A callback called right before the gesture is about to start. */
+    public void onGestureBegin(TransformGestureDetector detector);
 
-    /** Responds to the update of a gesture in progress. */
-    void onGestureUpdate(TransformGestureDetector detector);
+    /** A callback called each time the gesture gets updated. */
+    public void onGestureUpdate(TransformGestureDetector detector);
 
-    /** Responds to the end of a gesture. */
-    void onGestureEnd(TransformGestureDetector detector);
+    /** A callback called right after the gesture has finished. */
+    public void onGestureEnd(TransformGestureDetector detector);
   }
 
   private final MultiPointerGestureDetector mDetector;
@@ -102,41 +102,51 @@
     return (len > 0) ? sum / len : 0;
   }
 
-  /** Restarts the current gesture */
+  /** Restarts the current gesture (if any).  */
   public void restartGesture() {
     mDetector.restartGesture();
   }
 
-  /** Gets whether gesture is in progress or not */
+  /** Gets whether there is a gesture in progress */
   public boolean isGestureInProgress() {
     return mDetector.isGestureInProgress();
   }
 
+  /** Gets the number of pointers after the current gesture */
+  public int getNewPointerCount() {
+    return mDetector.getNewPointerCount();
+  }
+
+  /** Gets the number of pointers in the current gesture */
+  public int getPointerCount() {
+    return mDetector.getPointerCount();
+  }
+
   /** Gets the X coordinate of the pivot point */
   public float getPivotX() {
-    return calcAverage(mDetector.getStartX(), mDetector.getCount());
+    return calcAverage(mDetector.getStartX(), mDetector.getPointerCount());
   }
 
   /** Gets the Y coordinate of the pivot point */
   public float getPivotY() {
-    return calcAverage(mDetector.getStartY(), mDetector.getCount());
+    return calcAverage(mDetector.getStartY(), mDetector.getPointerCount());
   }
 
   /** Gets the X component of the translation */
   public float getTranslationX() {
-    return calcAverage(mDetector.getCurrentX(), mDetector.getCount())
-            - calcAverage(mDetector.getStartX(), mDetector.getCount());
+    return calcAverage(mDetector.getCurrentX(), mDetector.getPointerCount()) -
+        calcAverage(mDetector.getStartX(), mDetector.getPointerCount());
   }
 
   /** Gets the Y component of the translation */
   public float getTranslationY() {
-    return calcAverage(mDetector.getCurrentY(), mDetector.getCount())
-            - calcAverage(mDetector.getStartY(), mDetector.getCount());
+    return calcAverage(mDetector.getCurrentY(), mDetector.getPointerCount()) -
+        calcAverage(mDetector.getStartY(), mDetector.getPointerCount());
   }
 
   /** Gets the scale */
   public float getScale() {
-    if (mDetector.getCount() < 2) {
+    if (mDetector.getPointerCount() < 2) {
       return 1;
     } else {
       float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0];
@@ -151,7 +161,7 @@
 
   /** Gets the rotation in radians */
   public float getRotation() {
-    if (mDetector.getCount() < 2) {
+    if (mDetector.getPointerCount() < 2) {
       return 0;
     } else {
       float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0];
diff --git 
a/app/src/main/java/com/facebook/samples/zoomable/AbstractAnimatedZoomableController.java
 
b/app/src/main/java/com/facebook/samples/zoomable/AbstractAnimatedZoomableController.java
new file mode 100644
index 0000000..94e7d1e
--- /dev/null
+++ 
b/app/src/main/java/com/facebook/samples/zoomable/AbstractAnimatedZoomableController.java
@@ -0,0 +1,187 @@
+/*
+ * This file provided by Facebook is for non-commercial testing and evaluation
+ * purposes only.  Facebook reserves all rights not expressly granted.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.facebook.samples.zoomable;
+
+import android.graphics.Matrix;
+import android.graphics.PointF;
+import android.support.annotation.Nullable;
+
+import com.facebook.common.logging.FLog;
+import com.facebook.samples.gestures.TransformGestureDetector;
+
+/**
+ * Abstract class for ZoomableController that adds animation capabilities to
+ * DefaultZoomableController.
+ */
+public abstract class AbstractAnimatedZoomableController extends 
DefaultZoomableController {
+
+  private boolean mIsAnimating;
+  private final float[] mStartValues = new float[9];
+  private final float[] mStopValues = new float[9];
+  private final float[] mCurrentValues = new float[9];
+  private final Matrix mNewTransform = new Matrix();
+  private final Matrix mWorkingTransform = new Matrix();
+
+
+  public AbstractAnimatedZoomableController(TransformGestureDetector 
transformGestureDetector) {
+    super(transformGestureDetector);
+  }
+
+  @Override
+  public void reset() {
+    FLog.v(getLogTag(), "reset");
+    stopAnimation();
+    mWorkingTransform.reset();
+    mNewTransform.reset();
+    super.reset();
+  }
+
+  /**
+   * Returns true if the zoomable transform is identity matrix, and the 
controller is idle.
+   */
+  @Override
+  public boolean isIdentity() {
+    return !isAnimating() && super.isIdentity();
+  }
+
+  /**
+   * Zooms to the desired scale and positions the image so that the given 
image point corresponds
+   * to the given view point.
+   *
+   * <p>If this method is called while an animation or gesture is already in 
progress,
+   * the current animation or gesture will be stopped first.
+   *
+   * @param scale desired scale, will be limited to {min, max} scale factor
+   * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 
<= x, y <= 1)
+   * @param viewPoint 2D point in view's absolute coordinate system
+   */
+  @Override
+  public void zoomToPoint(
+      float scale,
+      PointF imagePoint,
+      PointF viewPoint) {
+    zoomToPoint(scale, imagePoint, viewPoint, LIMIT_ALL, 0, null);
+  }
+
+  /**
+   * Zooms to the desired scale and positions the image so that the given 
image point corresponds
+   * to the given view point.
+   *
+   * <p>If this method is called while an animation or gesture is already in 
progress,
+   * the current animation or gesture will be stopped first.
+   *
+   * @param scale desired scale, will be limited to {min, max} scale factor
+   * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 
<= x, y <= 1)
+   * @param viewPoint 2D point in view's absolute coordinate system
+   * @param limitFlags whether to limit translation and/or scale.
+   * @param durationMs length of animation of the zoom, or 0 if no animation 
desired
+   * @param onAnimationComplete code to run when the animation completes. 
Ignored if durationMs=0
+   */
+  public void zoomToPoint(
+      float scale,
+      PointF imagePoint,
+      PointF viewPoint,
+      @LimitFlag int limitFlags,
+      long durationMs,
+      @Nullable Runnable onAnimationComplete) {
+    FLog.v(getLogTag(), "zoomToPoint: duration %d ms", durationMs);
+    calculateZoomToPointTransform(
+        mNewTransform,
+        scale,
+        imagePoint,
+        viewPoint,
+        limitFlags);
+    setTransform(mNewTransform, durationMs, onAnimationComplete);
+  }
+
+  /**
+   * Sets a new zoomable transformation and animates to it if desired.
+   *
+   * <p>If this method is called while an animation or gesture is already in 
progress,
+   * the current animation or gesture will be stopped first.
+   *
+   * @param newTransform new transform to make active
+   * @param durationMs duration of the animation, or 0 to not animate
+   * @param onAnimationComplete code to run when the animation completes. 
Ignored if durationMs=0
+   */
+  public void setTransform(
+      Matrix newTransform,
+      long durationMs,
+      @Nullable Runnable onAnimationComplete) {
+    FLog.v(getLogTag(), "setTransform: duration %d ms", durationMs);
+    if (durationMs <= 0) {
+      setTransformImmediate(newTransform);
+    } else {
+      setTransformAnimated(newTransform, durationMs, onAnimationComplete);
+    }
+  }
+
+  private void setTransformImmediate(final Matrix newTransform) {
+    FLog.v(getLogTag(), "setTransformImmediate");
+    stopAnimation();
+    mWorkingTransform.set(newTransform);
+    super.setTransform(newTransform);
+    getDetector().restartGesture();
+  }
+
+  protected boolean isAnimating() {
+    return mIsAnimating;
+  }
+
+  protected void setAnimating(boolean isAnimating) {
+    mIsAnimating = isAnimating;
+  }
+
+  protected float[] getStartValues() {
+    return mStartValues;
+  }
+
+  protected float[] getStopValues() {
+    return mStopValues;
+  }
+
+  protected Matrix getWorkingTransform() {
+    return mWorkingTransform;
+  }
+
+  @Override
+  public void onGestureBegin(TransformGestureDetector detector) {
+    FLog.v(getLogTag(), "onGestureBegin");
+    stopAnimation();
+    super.onGestureBegin(detector);
+  }
+
+  @Override
+  public void onGestureUpdate(TransformGestureDetector detector) {
+    FLog.v(getLogTag(), "onGestureUpdate %s", isAnimating() ? "(ignored)" : 
"");
+    if (isAnimating()) {
+      return;
+    }
+    super.onGestureUpdate(detector);
+  }
+
+  protected void calculateInterpolation(Matrix outMatrix, float fraction) {
+    for (int i = 0; i < 9; i++) {
+      mCurrentValues[i] = (1 - fraction) * mStartValues[i] + fraction * 
mStopValues[i];
+    }
+    outMatrix.setValues(mCurrentValues);
+  }
+
+  public abstract void setTransformAnimated(
+      final Matrix newTransform,
+      long durationMs,
+      @Nullable final Runnable onAnimationComplete);
+
+  protected abstract void stopAnimation();
+
+  protected abstract Class<?> getLogTag();
+}
diff --git 
a/app/src/main/java/com/facebook/samples/zoomable/AnimatedZoomableController.java
 
b/app/src/main/java/com/facebook/samples/zoomable/AnimatedZoomableController.java
new file mode 100644
index 0000000..0fa21df
--- /dev/null
+++ 
b/app/src/main/java/com/facebook/samples/zoomable/AnimatedZoomableController.java
@@ -0,0 +1,107 @@
+  /*
+ * This file provided by Facebook is for non-commercial testing and evaluation
+ * purposes only.  Facebook reserves all rights not expressly granted.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.facebook.samples.zoomable;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.graphics.Matrix;
+import android.support.annotation.Nullable;
+import android.view.animation.DecelerateInterpolator;
+
+import com.facebook.common.internal.Preconditions;
+import com.facebook.common.logging.FLog;
+import com.facebook.samples.gestures.TransformGestureDetector;
+
+/**
+ * ZoomableController that adds animation capabilities to 
DefaultZoomableController using standard
+ * Android animation classes
+ */
+public class AnimatedZoomableController extends 
AbstractAnimatedZoomableController {
+
+  private static final Class<?> TAG = AnimatedZoomableController.class;
+
+  private final ValueAnimator mValueAnimator;
+
+  public static AnimatedZoomableController newInstance() {
+    return new 
AnimatedZoomableController(TransformGestureDetector.newInstance());
+  }
+
+  @SuppressLint("NewApi")
+  public AnimatedZoomableController(TransformGestureDetector 
transformGestureDetector) {
+    super(transformGestureDetector);
+    mValueAnimator = ValueAnimator.ofFloat(0, 1);
+    mValueAnimator.setInterpolator(new DecelerateInterpolator());
+  }
+
+  @SuppressLint("NewApi")
+  @Override
+  public void setTransformAnimated(
+      final Matrix newTransform,
+      long durationMs,
+      @Nullable final Runnable onAnimationComplete) {
+    FLog.v(getLogTag(), "setTransformAnimated: duration %d ms", durationMs);
+    stopAnimation();
+    Preconditions.checkArgument(durationMs > 0);
+    Preconditions.checkState(!isAnimating());
+    setAnimating(true);
+    mValueAnimator.setDuration(durationMs);
+    getTransform().getValues(getStartValues());
+    newTransform.getValues(getStopValues());
+    mValueAnimator.addUpdateListener(new 
ValueAnimator.AnimatorUpdateListener() {
+      @Override
+      public void onAnimationUpdate(ValueAnimator valueAnimator) {
+        calculateInterpolation(getWorkingTransform(), (float) 
valueAnimator.getAnimatedValue());
+        AnimatedZoomableController.super.setTransform(getWorkingTransform());
+      }
+    });
+    mValueAnimator.addListener(new AnimatorListenerAdapter() {
+      @Override
+      public void onAnimationCancel(Animator animation) {
+        FLog.v(getLogTag(), "setTransformAnimated: animation cancelled");
+        onAnimationStopped();
+      }
+      @Override
+      public void onAnimationEnd(Animator animation) {
+        FLog.v(getLogTag(), "setTransformAnimated: animation finished");
+        onAnimationStopped();
+      }
+      private void onAnimationStopped() {
+        if (onAnimationComplete != null) {
+          onAnimationComplete.run();
+        }
+        setAnimating(false);
+        getDetector().restartGesture();
+      }
+    });
+    mValueAnimator.start();
+  }
+
+  @SuppressLint("NewApi")
+  @Override
+  public void stopAnimation() {
+    if (!isAnimating()) {
+      return;
+    }
+    FLog.v(getLogTag(), "stopAnimation");
+    mValueAnimator.cancel();
+    mValueAnimator.removeAllUpdateListeners();
+    mValueAnimator.removeAllListeners();
+  }
+
+  @Override
+  protected Class<?> getLogTag() {
+    return TAG;
+  }
+
+}
diff --git 
a/app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java
 
b/app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java
index 877e56a..7c6f2f5 100644
--- 
a/app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java
+++ 
b/app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java
@@ -12,19 +12,17 @@
 
 package com.facebook.samples.zoomable;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
 import android.graphics.Matrix;
 import android.graphics.PointF;
 import android.graphics.RectF;
-import android.support.annotation.Nullable;
+import android.support.annotation.IntDef;
 import android.view.MotionEvent;
-import android.view.animation.DecelerateInterpolator;
 
-import com.facebook.common.internal.Preconditions;
+import com.facebook.common.logging.FLog;
 import com.facebook.samples.gestures.TransformGestureDetector;
-
-import com.nineoldandroids.animation.Animator;
-import com.nineoldandroids.animation.AnimatorListenerAdapter;
-import com.nineoldandroids.animation.ValueAnimator;
 
 /**
  * Zoomable controller that calculates transformation based on touch events.
@@ -32,7 +30,26 @@
 public class DefaultZoomableController
     implements ZoomableController, TransformGestureDetector.Listener {
 
-  private static final int MATRIX_SIZE = 9;
+  @IntDef(flag=true, value={
+      LIMIT_NONE,
+      LIMIT_TRANSLATION_X,
+      LIMIT_TRANSLATION_Y,
+      LIMIT_SCALE,
+      LIMIT_ALL
+  })
+  @Retention(RetentionPolicy.SOURCE)
+  public @interface LimitFlag {}
+
+  public static final int LIMIT_NONE = 0;
+  public static final int LIMIT_TRANSLATION_X = 1;
+  public static final int LIMIT_TRANSLATION_Y = 2;
+  public static final int LIMIT_SCALE = 4;
+  public static final int LIMIT_ALL = LIMIT_TRANSLATION_X | 
LIMIT_TRANSLATION_Y | LIMIT_SCALE;
+
+  private static final float EPS = 1e-3f;
+
+  private static final Class<?> TAG = DefaultZoomableController.class;
+
   private static final RectF IDENTITY_RECT = new RectF(0, 0, 1, 1);
 
   private TransformGestureDetector mGestureDetector;
@@ -45,45 +62,44 @@
   private boolean mIsTranslationEnabled = true;
 
   private float mMinScaleFactor = 1.0f;
-  private float mMaxScaleFactor = Float.POSITIVE_INFINITY;
+  private float mMaxScaleFactor = 2.0f;
 
+  // View bounds, in view-absolute coordinates
   private final RectF mViewBounds = new RectF();
+  // Non-transformed image bounds, in view-absolute coordinates
   private final RectF mImageBounds = new RectF();
+  // Transformed image bounds, in view-absolute coordinates
   private final RectF mTransformedImageBounds = new RectF();
+
   private final Matrix mPreviousTransform = new Matrix();
   private final Matrix mActiveTransform = new Matrix();
   private final Matrix mActiveTransformInverse = new Matrix();
-  private final float[] mTempValues = new float[MATRIX_SIZE];
+  private final float[] mTempValues = new float[9];
   private final RectF mTempRect = new RectF();
-
-  private final ValueAnimator mValueAnimator;
-  private final float[] mAnimationStartValues = new float[MATRIX_SIZE];
-  private final float[] mAnimationDestValues = new float[MATRIX_SIZE];
-  private final float[] mAnimationCurrValues = new float[MATRIX_SIZE];
-  private final Matrix mNewTransform = new Matrix();
-
-  public DefaultZoomableController(TransformGestureDetector gestureDetector) {
-    mGestureDetector = gestureDetector;
-    mGestureDetector.setListener(this);
-    mValueAnimator = ValueAnimator.ofFloat(0, 1);
-    mValueAnimator.setInterpolator(new DecelerateInterpolator());
-  }
+  private boolean mWasTransformCorrected;
 
   public static DefaultZoomableController newInstance() {
     return new 
DefaultZoomableController(TransformGestureDetector.newInstance());
   }
 
-  @Override
-  public void setListener(Listener listener) {
-    mListener = listener;
+  public DefaultZoomableController(TransformGestureDetector gestureDetector) {
+    mGestureDetector = gestureDetector;
+    mGestureDetector.setListener(this);
   }
 
   /** Rests the controller. */
   public void reset() {
+    FLog.v(TAG, "reset");
     mGestureDetector.reset();
     mPreviousTransform.reset();
     mActiveTransform.reset();
     onTransformChanged();
+  }
+
+  /** Sets the zoomable listener. */
+  @Override
+  public void setListener(Listener listener) {
+    mListener = listener;
   }
 
   /** Sets whether the controller is enabled or not. */
@@ -95,7 +111,7 @@
     }
   }
 
-  /** Returns whether the controller is enabled or not. */
+  /** Gets whether the controller is enabled or not. */
   @Override
   public boolean isEnabled() {
     return mIsEnabled;
@@ -131,33 +147,12 @@
     return  mIsTranslationEnabled;
   }
 
-  /** Gets the image bounds before zoomable transformation is applied. */
-  public RectF getImageBounds() {
-    return mImageBounds;
-  }
-
-  protected RectF getTransformedImageBounds() {
-    return mTransformedImageBounds;
-  }
-
-  /** Sets the image bounds before zoomable transformation is applied. */
-  @Override
-  public void setImageBounds(RectF imageBounds) {
-    if (!imageBounds.equals(mImageBounds)) {
-      mImageBounds.set(imageBounds);
-      onTransformChanged();
-    }
-  }
-
-  /** Gets the view bounds. */
-  public RectF getViewBounds() {
-    return mViewBounds;
-  }
-
-  /** Sets the view bounds. */
-  @Override
-  public void setViewBounds(RectF viewBounds) {
-    mViewBounds.set(viewBounds);
+  /**
+   * Sets the minimum scale factor allowed.
+   * <p> Hierarchy's scaling (if any) is not taken into account.
+   */
+  public void setMinScaleFactor(float minScaleFactor) {
+    mMinScaleFactor = minScaleFactor;
   }
 
   /** Gets the minimum scale factor allowed. */
@@ -166,13 +161,11 @@
   }
 
   /**
-   * Sets the minimum scale factor allowed.
-   * <p>
-   * Note that the hierarchy performs scaling as well, which
-   * is not accounted here, so the actual scale factor may differ.
+   * Sets the maximum scale factor allowed.
+   * <p> Hierarchy's scaling (if any) is not taken into account.
    */
-  public void setMinScaleFactor(float minScaleFactor) {
-    mMinScaleFactor = minScaleFactor;
+  public void setMaxScaleFactor(float maxScaleFactor) {
+    mMaxScaleFactor = maxScaleFactor;
   }
 
   /** Gets the maximum scale factor allowed. */
@@ -180,18 +173,82 @@
     return mMaxScaleFactor;
   }
 
-  /**
-   * Sets the maximum scale factor allowed.
-   * <p>
-   * Note that the hierarchy performs scaling as well, which
-   * is not accounted here, so the actual scale factor may differ.
-   */
-  public void setMaxScaleFactor(float maxScaleFactor) {
-    mMaxScaleFactor = maxScaleFactor;
+  /** Gets the current scale factor. */
+  @Override
+  public float getScaleFactor() {
+    return getMatrixScaleFactor(mActiveTransform);
+  }
+
+  /** Sets the image bounds, in view-absolute coordinates. */
+  @Override
+  public void setImageBounds(RectF imageBounds) {
+    if (!imageBounds.equals(mImageBounds)) {
+      mImageBounds.set(imageBounds);
+      onTransformChanged();
+    }
+  }
+
+  /** Gets the non-transformed image bounds, in view-absolute coordinates. */
+  public RectF getImageBounds() {
+    return mImageBounds;
+  }
+
+  /** Gets the transformed image bounds, in view-absolute coordinates */
+  private RectF getTransformedImageBounds() {
+    return mTransformedImageBounds;
+  }
+
+  /** Sets the view bounds. */
+  @Override
+  public void setViewBounds(RectF viewBounds) {
+    mViewBounds.set(viewBounds);
+  }
+
+  /** Gets the view bounds. */
+  public RectF getViewBounds() {
+    return mViewBounds;
   }
 
   /**
-   * Maps point from the view's to the image's relative coordinate system.
+   * Returns true if the zoomable transform is identity matrix.
+   */
+  @Override
+  public boolean isIdentity() {
+    return isMatrixIdentity(mActiveTransform, 1e-3f);
+  }
+
+  /**
+   * Returns true if the transform was corrected during the last update.
+   *
+   * We should rename this method to `wasTransformedWithoutCorrection` and 
just return the
+   * internal flag directly. However, this requires interface change and 
negation of meaning.
+   */
+  @Override
+  public boolean wasTransformCorrected() {
+    return mWasTransformCorrected;
+  }
+
+  /**
+   * Gets the matrix that transforms image-absolute coordinates to 
view-absolute coordinates.
+   * The zoomable transformation is taken into account.
+   *
+   * Internal matrix is exposed for performance reasons and is not to be 
modified by the callers.
+   */
+  @Override
+  public Matrix getTransform() {
+    return mActiveTransform;
+  }
+
+  /**
+   * Gets the matrix that transforms image-relative coordinates to 
view-absolute coordinates.
+   * The zoomable transformation is taken into account.
+   */
+  public void getImageRelativeToViewAbsoluteTransform(Matrix outMatrix) {
+    outMatrix.setRectToRect(IDENTITY_RECT, mTransformedImageBounds, 
Matrix.ScaleToFit.FILL);
+  }
+
+  /**
+   * Maps point from view-absolute to image-relative coordinates.
    * This takes into account the zoomable transformation.
    */
   public PointF mapViewToImage(PointF viewPoint) {
@@ -205,7 +262,7 @@
   }
 
   /**
-   * Maps point from the image's relative to the view's coordinate system.
+   * Maps point from image-relative to view-absolute coordinates.
    * This takes into account the zoomable transformation.
    */
   public PointF mapImageToView(PointF imagePoint) {
@@ -218,9 +275,9 @@
   }
 
   /**
-   * Maps array of 2D points from absolute to the image's relative coordinate 
system,
-   * and writes the transformed points back into the array.
-   * Points are represented by float array of [x0, y0, x1, y1, ...].
+   * Maps array of 2D points from view-absolute to image-relative coordinates.
+   * This does NOT take into account the zoomable transformation.
+   * Points are represented by a float array of [x0, y0, x1, y1, ...].
    *
    * @param destPoints destination array (may be the same as source array)
    * @param srcPoints source array
@@ -234,8 +291,8 @@
   }
 
   /**
-   * Maps array of 2D points from relative to the image's absolute coordinate 
system,
-   * and writes the transformed points back into the array
+   * Maps array of 2D points from image-relative to view-absolute coordinates.
+   * This does NOT take into account the zoomable transformation.
    * Points are represented by float array of [x0, y0, x1, y1, ...].
    *
    * @param destPoints destination array (may be the same as source array)
@@ -250,113 +307,133 @@
   }
 
   /**
-   * Gets the zoomable transformation
-   * Internal matrix is exposed for performance reasons and is not to be 
modified by the callers.
-   */
-  @Override
-  public Matrix getTransform() {
-    return mActiveTransform;
-  }
-
-  /**
-   * Returns the matrix that fully transforms the image from image-relative 
coordinates
-   * to scaled view-absolute coordinates.
-   */
-  public void getImageRelativeToViewAbsoluteTransform(Matrix outMatrix) {
-    mActiveTransform.mapRect(mTempRect, mImageBounds);
-    outMatrix.setRectToRect(IDENTITY_RECT, mTempRect, Matrix.ScaleToFit.FILL);
-  }
-
-  // TODO(balazsbalazs) resolve issues with interrupting an existing 
animation/gesture with
-  // a new animation or transform
-
-  /**
-   * Sets a new zoom transformation.
+   * Zooms to the desired scale and positions the image so that the given 
image point corresponds
+   * to the given view point.
    *
-   * <p>If this method is called while an animation or gesture is already in 
progress,
-   * this will currently result in undefined behavior.
+   * @param scale desired scale, will be limited to {min, max} scale factor
+   * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 
<= x, y <= 1)
+   * @param viewPoint 2D point in view's absolute coordinate system
    */
+  public void zoomToPoint(float scale, PointF imagePoint, PointF viewPoint) {
+    FLog.v(TAG, "zoomToPoint");
+    calculateZoomToPointTransform(mActiveTransform, scale, imagePoint, 
viewPoint, LIMIT_ALL);
+    onTransformChanged();
+  }
+
+  /**
+   * Calculates the zoom transformation that would zoom to the desired scale 
and position the image
+   * so that the given image point corresponds to the given view point.
+   *
+   * @param outTransform the matrix to store the result to
+   * @param scale desired scale, will be limited to {min, max} scale factor
+   * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 
<= x, y <= 1)
+   * @param viewPoint 2D point in view's absolute coordinate system
+   * @param limitFlags whether to limit translation and/or scale.
+   * @return whether or not the transform has been corrected due to limitation
+   */
+  protected boolean calculateZoomToPointTransform(
+      Matrix outTransform,
+      float scale,
+      PointF imagePoint,
+      PointF viewPoint,
+      @LimitFlag int limitFlags) {
+    float[] viewAbsolute = mTempValues;
+    viewAbsolute[0] = imagePoint.x;
+    viewAbsolute[1] = imagePoint.y;
+    mapRelativeToAbsolute(viewAbsolute, viewAbsolute, 1);
+    float distanceX = viewPoint.x - viewAbsolute[0];
+    float distanceY = viewPoint.y - viewAbsolute[1];
+    boolean transformCorrected = false;
+    outTransform.setScale(scale, scale, viewAbsolute[0], viewAbsolute[1]);
+    transformCorrected |= limitScale(outTransform, viewAbsolute[0], 
viewAbsolute[1], limitFlags);
+    outTransform.postTranslate(distanceX, distanceY);
+    transformCorrected |= limitTranslation(outTransform, limitFlags);
+    return transformCorrected;
+  }
+
+  /** Sets a new zoom transformation. */
   public void setTransform(Matrix newTransform) {
-    setTransform(newTransform, 0, null);
+    FLog.v(TAG, "setTransform");
+    mActiveTransform.set(newTransform);
+    onTransformChanged();
   }
 
-  /**
-   * Sets a new zoomable transformation and animates to it if desired.
-   *
-   * <p>If this method is called while an animation or gesture is already in 
progress,
-   * this will currently result in undefined behavior.
-   *
-   * @param newTransform new transform to make active
-   * @param durationMs duration of the animation, or 0 to not animate
-   * @param onAnimationComplete code to run when the animation completes. 
Ignored if durationMs=0
-   */
-  public void setTransform(
-      Matrix newTransform,
-      long durationMs,
-      @Nullable Runnable onAnimationComplete) {
-    if (mGestureDetector.isGestureInProgress()) {
-      mGestureDetector.reset();
-    }
-    cancelAnimation();
-    if (durationMs <= 0) {
-      mActiveTransform.set(newTransform);
-      onTransformChanged();
-    } else {
-      setTransformAnimated(newTransform, durationMs, onAnimationComplete);
-    }
-  }
-
-  /** Do not call this method directly; call it only from setTransform. */
-  private void setTransformAnimated(
-      final Matrix newTransform,
-      long durationMs,
-      @Nullable final Runnable onAnimationComplete) {
-    Preconditions.checkArgument(durationMs > 0);
-    Preconditions.checkState(!mValueAnimator.isRunning());
-    mValueAnimator.setDuration(durationMs);
-    mActiveTransform.getValues(mAnimationStartValues);
-    newTransform.getValues(mAnimationDestValues);
-    mValueAnimator.addUpdateListener(new 
ValueAnimator.AnimatorUpdateListener() {
-      @Override
-      public void onAnimationUpdate(ValueAnimator valueAnimator) {
-        float fraction = (float) valueAnimator.getAnimatedValue();
-        for (int i = 0; i < mAnimationCurrValues.length; i++) {
-          mAnimationCurrValues[i] = (1 - fraction) * mAnimationStartValues[i]
-                  + fraction * mAnimationDestValues[i];
-        }
-        mActiveTransform.setValues(mAnimationCurrValues);
-        onTransformChanged();
-      }
-    });
-    if (onAnimationComplete != null) {
-      mValueAnimator.addListener(new AnimatorListenerAdapter() {
-        @Override
-        public void onAnimationEnd(Animator animation) {
-          onAnimationComplete.run();
-        }
-      });
-    }
-    mValueAnimator.start();
-  }
-
-  private void cancelAnimation() {
-    mValueAnimator.removeAllUpdateListeners();
-    mValueAnimator.removeAllListeners();
-    if (mValueAnimator.isRunning()) {
-      mValueAnimator.cancel();
-    }
+  /** Gets the gesture detector. */
+  protected TransformGestureDetector getDetector() {
+    return mGestureDetector;
   }
 
   /** Notifies controller of the received touch event.  */
   @Override
   public boolean onTouchEvent(MotionEvent event) {
+    FLog.v(TAG, "onTouchEvent: action: ", event.getAction());
     if (mIsEnabled) {
       return mGestureDetector.onTouchEvent(event);
     }
     return false;
   }
 
-  protected void onTransformChanged() {
+  /* TransformGestureDetector.Listener methods  */
+
+  @Override
+  public void onGestureBegin(TransformGestureDetector detector) {
+    FLog.v(TAG, "onGestureBegin");
+    mPreviousTransform.set(mActiveTransform);
+    // We only received a touch down event so far, and so we don't know yet in 
which direction a
+    // future move event will follow. Therefore, if we can't scroll in all 
directions, we have to
+    // assume the worst case where the user tries to scroll out of edge, which 
would cause
+    // transformation to be corrected.
+    mWasTransformCorrected = !canScrollInAllDirection();
+  }
+
+  @Override
+  public void onGestureUpdate(TransformGestureDetector detector) {
+    FLog.v(TAG, "onGestureUpdate");
+    boolean transformCorrected = calculateGestureTransform(mActiveTransform, 
LIMIT_ALL);
+    onTransformChanged();
+    if (transformCorrected) {
+      mGestureDetector.restartGesture();
+    }
+    // A transformation happened, but was it without correction?
+    mWasTransformCorrected = transformCorrected;
+  }
+
+  @Override
+  public void onGestureEnd(TransformGestureDetector detector) {
+    FLog.v(TAG, "onGestureEnd");
+  }
+
+  /**
+   * Calculates the zoom transformation based on the current gesture.
+   *
+   * @param outTransform the matrix to store the result to
+   * @param limitTypes whether to limit translation and/or scale.
+   * @return whether or not the transform has been corrected due to limitation
+   */
+  protected boolean calculateGestureTransform(
+      Matrix outTransform,
+      @LimitFlag int limitTypes) {
+    TransformGestureDetector detector = mGestureDetector;
+    boolean transformCorrected = false;
+    outTransform.set(mPreviousTransform);
+    if (mIsRotationEnabled) {
+      float angle = detector.getRotation() * (float) (180 / Math.PI);
+      outTransform.postRotate(angle, detector.getPivotX(), 
detector.getPivotY());
+    }
+    if (mIsScaleEnabled) {
+      float scale = detector.getScale();
+      outTransform.postScale(scale, scale, detector.getPivotX(), 
detector.getPivotY());
+    }
+    transformCorrected |=
+        limitScale(outTransform, detector.getPivotX(), detector.getPivotY(), 
limitTypes);
+    if (mIsTranslationEnabled) {
+      outTransform.postTranslate(detector.getTranslationX(), 
detector.getTranslationY());
+    }
+    transformCorrected |= limitTranslation(outTransform, limitTypes);
+    return transformCorrected;
+  }
+
+  private void onTransformChanged() {
     mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds);
     if (mListener != null && isEnabled()) {
       mListener.onTransformChanged(mActiveTransform);
@@ -364,124 +441,180 @@
   }
 
   /**
-   * Zooms to the desired scale and positions the view so that imagePoint is 
in the center.
+   * Keeps the scaling factor within the specified limits.
    *
-   * <p>If this method is called while an animation or gesture is already in 
progress,
-   * this will currently result in undefined behavior.
-   *
-   * @param scale desired scale, will be limited to {min, max} scale factor
-   * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 
<= x, y <= 1)
-   * @param viewPoint 2D point in view's absolute coordinate system
-   * @param limitTransX  Whether to adjust the transform to prevent black bars 
from appearing on
-   *                     the left or right.
-   * @param limitTransY Whether to adjust the transform to prevent black bars 
from appearing on
-   *                    the top or bottom.
-   * @param durationMs length of animation of the zoom, or 0 if no animation 
desired
-   * @param onAnimationComplete code to execute when the animation is complete.
-   *                            Ignored if durationMs=0
+   * @param pivotX x coordinate of the pivot point
+   * @param pivotY y coordinate of the pivot point
+   * @param limitTypes whether to limit scale.
+   * @return whether limiting has been applied or not
    */
-  public void zoomToImagePoint(
-      float scale,
-      PointF imagePoint,
-      PointF viewPoint,
-      boolean limitTransX,
-      boolean limitTransY,
-      long durationMs,
-      @Nullable Runnable onAnimationComplete) {
-    scale = limit(scale, mMinScaleFactor, mMaxScaleFactor);
-    float[] viewAbsolute = mTempValues;
-    viewAbsolute[0] = imagePoint.x;
-    viewAbsolute[1] = imagePoint.y;
-    mapRelativeToAbsolute(viewAbsolute, viewAbsolute, 1);
-    float distanceX = viewPoint.x - viewAbsolute[0];
-    float distanceY = viewPoint.y - viewAbsolute[1];
-    mNewTransform.setScale(scale, scale, viewAbsolute[0], viewAbsolute[1]);
-    mNewTransform.postTranslate(distanceX, distanceY);
-    limitTranslation(mNewTransform, limitTransX, limitTransY);
-
-    setTransform(mNewTransform, durationMs, onAnimationComplete);
-  }
-
-  /* TransformGestureDetector.Listener methods  */
-
-  @Override
-  public void onGestureBegin(TransformGestureDetector detector) {
-    mPreviousTransform.set(mActiveTransform);
-    // TODO(balazsbalazs): animation should be cancelled here
-  }
-
-  @Override
-  public void onGestureUpdate(TransformGestureDetector detector) {
-    mActiveTransform.set(mPreviousTransform);
-    if (mIsRotationEnabled) {
-      final int oneEighty = 180;
-      float angle = detector.getRotation() * (float) (oneEighty / Math.PI);
-      mActiveTransform.postRotate(angle, detector.getPivotX(), 
detector.getPivotY());
+  private boolean limitScale(
+      Matrix transform,
+      float pivotX,
+      float pivotY,
+      @LimitFlag int limitTypes) {
+    if (!shouldLimit(limitTypes, LIMIT_SCALE)) {
+      return false;
     }
-    if (mIsScaleEnabled) {
-      float scale = detector.getScale();
-      mActiveTransform.postScale(scale, scale, detector.getPivotX(), 
detector.getPivotY());
-    }
-    limitScale(detector.getPivotX(), detector.getPivotY());
-    if (mIsTranslationEnabled) {
-      mActiveTransform.postTranslate(detector.getTranslationX(), 
detector.getTranslationY());
-    }
-    if (limitTranslation(mActiveTransform, true, true)) {
-      mGestureDetector.restartGesture();
-    }
-    onTransformChanged();
-  }
-
-  @Override
-  public void onGestureEnd(TransformGestureDetector detector) {
-    mPreviousTransform.set(mActiveTransform);
-  }
-
-  /** Gets the current scale factor. */
-  @Override
-  public float getScaleFactor() {
-    mActiveTransform.getValues(mTempValues);
-    return mTempValues[Matrix.MSCALE_X];
-  }
-
-  private void limitScale(float pivotX, float pivotY) {
-    float currentScale = getScaleFactor();
+    float currentScale = getMatrixScaleFactor(transform);
     float targetScale = limit(currentScale, mMinScaleFactor, mMaxScaleFactor);
     if (targetScale != currentScale) {
       float scale = targetScale / currentScale;
-      mActiveTransform.postScale(scale, scale, pivotX, pivotY);
-    }
-  }
-
-  /**
-   * Keeps the view inside the image if possible, if not (i.e. image is 
smaller than view)
-   * centers the image.
-   * @param limitX whether to apply the limit on the x-axis
-   * @param limitY whether to apply the limit on the y-axis
-   * @return whether adjustments were needed or not
-   */
-  private boolean limitTranslation(Matrix newTransform, boolean limitX, 
boolean limitY) {
-    RectF bounds = mTransformedImageBounds;
-    bounds.set(mImageBounds);
-    newTransform.mapRect(bounds);
-    float offsetLeft = limitX
-            ? getOffset(bounds.left, bounds.width(), mViewBounds.width()) : 
bounds.left;
-    float offsetTop = limitY
-            ? getOffset(bounds.top, bounds.height(), mViewBounds.height()) : 
bounds.top;
-    if (offsetLeft != bounds.left || offsetTop != bounds.top) {
-      newTransform.postTranslate(offsetLeft - bounds.left, offsetTop - 
bounds.top);
+      transform.postScale(scale, scale, pivotX, pivotY);
       return true;
     }
     return false;
   }
 
-  private float getOffset(float offset, float imageDimension, float 
viewDimension) {
-    float diff = viewDimension - imageDimension;
-    return (diff > 0) ? diff / 2 : limit(offset, diff, 0);
+  /**
+   * Limits the translation so that there are no empty spaces on the sides if 
possible.
+   *
+   * <p> The image is attempted to be centered within the view bounds if the 
transformed image is
+   * smaller. There will be no empty spaces within the view bounds if the 
transformed image is
+   * bigger. This applies to each dimension (horizontal and vertical) 
independently.
+   *
+   * @param limitTypes whether to limit translation along the specific axis.
+   * @return whether limiting has been applied or not
+   */
+  private boolean limitTranslation(Matrix transform, @LimitFlag int 
limitTypes) {
+    if (!shouldLimit(limitTypes, LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y)) {
+      return false;
+    }
+    RectF b = mTempRect;
+    b.set(mImageBounds);
+    transform.mapRect(b);
+    float offsetLeft = shouldLimit(limitTypes, LIMIT_TRANSLATION_X) ?
+        getOffset(b.left, b.right, mViewBounds.left, mViewBounds.right, 
mImageBounds.centerX()) : 0;
+    float offsetTop = shouldLimit(limitTypes, LIMIT_TRANSLATION_Y) ?
+        getOffset(b.top, b.bottom, mViewBounds.top, mViewBounds.bottom, 
mImageBounds.centerY()) : 0;
+    if (offsetLeft != 0 || offsetTop != 0) {
+      transform.postTranslate(offsetLeft, offsetTop);
+      return true;
+    }
+    return false;
   }
 
+  /**
+   * Checks whether the specified limit flag is present in the limits provided.
+   *
+   * <p> If the flag contains multiple flags together using a bitwise OR, this 
only checks that at
+   * least one of the flags is included.
+   *
+   * @param limits the limits to apply
+   * @param flag the limit flag(s) to check for
+   * @return true if the flag (or one of the flags) is included in the limits
+   */
+  private static boolean shouldLimit(@LimitFlag int limits, @LimitFlag int 
flag) {
+    return (limits & flag) != LIMIT_NONE;
+  }
+
+  /**
+   * Returns the offset necessary to make sure that:
+   * - the image is centered within the limit if the image is smaller than the 
limit
+   * - there is no empty space on left/right if the image is bigger than the 
limit
+   */
+  private float getOffset(
+      float imageStart,
+      float imageEnd,
+      float limitStart,
+      float limitEnd,
+      float limitCenter) {
+    float imageWidth = imageEnd - imageStart, limitWidth = limitEnd - 
limitStart;
+    float limitInnerWidth = Math.min(limitCenter - limitStart, limitEnd - 
limitCenter) * 2;
+    // center if smaller than limitInnerWidth
+    if (imageWidth < limitInnerWidth) {
+      return limitCenter - (imageEnd + imageStart) / 2;
+    }
+    // to the edge if in between and limitCenter is not (limitLeft + 
limitRight) / 2
+    if (imageWidth < limitWidth) {
+      if (limitCenter < (limitStart + limitEnd) / 2) {
+        return limitStart - imageStart;
+      } else {
+        return limitEnd - imageEnd;
+      }
+    }
+    // to the edge if larger than limitWidth and empty space visible
+    if (imageStart > limitStart) {
+      return limitStart - imageStart;
+    }
+    if (imageEnd < limitEnd) {
+      return limitEnd - imageEnd;
+    }
+    return 0;
+  }
+
+  /**
+   * Limits the value to the given min and max range.
+   */
   private float limit(float value, float min, float max) {
     return Math.min(Math.max(min, value), max);
   }
 
+  /**
+   * Gets the scale factor for the given matrix.
+   * This method assumes the equal scaling factor for X and Y axis.
+   */
+  private float getMatrixScaleFactor(Matrix transform) {
+    transform.getValues(mTempValues);
+    return mTempValues[Matrix.MSCALE_X];
+  }
+
+  /**
+   * Same as {@code Matrix.isIdentity()}, but with tolerance {@code eps}.
+   */
+  private boolean isMatrixIdentity(Matrix transform, float eps) {
+    // Checks whether the given matrix is close enough to the identity matrix:
+    //   1 0 0
+    //   0 1 0
+    //   0 0 1
+    // Or equivalently to the zero matrix, after subtracting 1.0f from the 
diagonal elements:
+    //   0 0 0
+    //   0 0 0
+    //   0 0 0
+    transform.getValues(mTempValues);
+    mTempValues[0] -= 1.0f; // m00
+    mTempValues[4] -= 1.0f; // m11
+    mTempValues[8] -= 1.0f; // m22
+    for (int i = 0; i < 9; i++) {
+      if (Math.abs(mTempValues[i]) > eps) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Returns whether the scroll can happen in all directions. I.e. the image 
is not on any edge.
+   */
+  private boolean canScrollInAllDirection() {
+    return mTransformedImageBounds.left < mViewBounds.left - EPS &&
+        mTransformedImageBounds.top < mViewBounds.top - EPS &&
+        mTransformedImageBounds.right > mViewBounds.right + EPS &&
+        mTransformedImageBounds.bottom > mViewBounds.bottom + EPS;
+  }
+
+  @Override
+  public int computeHorizontalScrollRange() {
+    return (int)mTransformedImageBounds.width();
+  }
+  @Override
+  public int computeHorizontalScrollOffset() {
+    return (int)(mViewBounds.left - mTransformedImageBounds.left);
+  }
+  @Override
+  public int computeHorizontalScrollExtent() {
+    return (int)mViewBounds.width();
+  }
+  @Override
+  public int computeVerticalScrollRange() {
+    return (int)mTransformedImageBounds.height();
+  }
+  @Override
+  public int computeVerticalScrollOffset() {
+    return (int)(mViewBounds.top - mTransformedImageBounds.top);
+  }
+  @Override
+  public int computeVerticalScrollExtent() {
+    return (int)mViewBounds.height();
+  }
 }
diff --git 
a/app/src/main/java/com/facebook/samples/zoomable/GestureListenerWrapper.java 
b/app/src/main/java/com/facebook/samples/zoomable/GestureListenerWrapper.java
new file mode 100644
index 0000000..8e27fc0
--- /dev/null
+++ 
b/app/src/main/java/com/facebook/samples/zoomable/GestureListenerWrapper.java
@@ -0,0 +1,76 @@
+/*
+ * This file provided by Facebook is for non-commercial testing and evaluation
+ * purposes only.  Facebook reserves all rights not expressly granted.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.facebook.samples.zoomable;
+
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+
+/**
+ * Wrapper for SimpleOnGestureListener as GestureDetector does not allow 
changing its listener.
+ */
+public class GestureListenerWrapper extends 
GestureDetector.SimpleOnGestureListener {
+
+  private GestureDetector.SimpleOnGestureListener mDelegate;
+
+  public GestureListenerWrapper() {
+    mDelegate = new GestureDetector.SimpleOnGestureListener();
+  }
+
+  public void setListener(GestureDetector.SimpleOnGestureListener listener) {
+    mDelegate = listener;
+  }
+
+  @Override
+  public void onLongPress(MotionEvent e) {
+    mDelegate.onLongPress(e);
+  }
+
+  @Override
+  public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, 
float distanceY) {
+    return mDelegate.onScroll(e1, e2, distanceX, distanceY);
+  }
+
+  @Override
+  public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, 
float velocityY) {
+    return mDelegate.onFling(e1, e2, velocityX, velocityY);
+  }
+
+  @Override
+  public void onShowPress(MotionEvent e) {
+    mDelegate.onShowPress(e);
+  }
+
+  @Override
+  public boolean onDown(MotionEvent e) {
+    return mDelegate.onDown(e);
+  }
+
+  @Override
+  public boolean onDoubleTap(MotionEvent e) {
+    return mDelegate.onDoubleTap(e);
+  }
+
+  @Override
+  public boolean onDoubleTapEvent(MotionEvent e) {
+    return mDelegate.onDoubleTapEvent(e);
+  }
+
+  @Override
+  public boolean onSingleTapConfirmed(MotionEvent e) {
+    return mDelegate.onSingleTapConfirmed(e);
+  }
+
+  @Override
+  public boolean onSingleTapUp(MotionEvent e) {
+    return mDelegate.onSingleTapUp(e);
+  }
+}
diff --git 
a/app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java 
b/app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java
index 23d6cd5..52c69f5 100644
--- a/app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java
+++ b/app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java
@@ -25,7 +25,7 @@
   /**
    * Listener interface.
    */
-  public interface Listener {
+  interface Listener {
 
     /**
      * Notifies the view that the transform changed.
@@ -66,6 +66,29 @@
   float getScaleFactor();
 
   /**
+   * Returns true if the zoomable transform is identity matrix, and the 
controller is idle.
+   */
+  boolean isIdentity();
+
+  /**
+   * Returns true if the transform was corrected during the last update.
+   *
+   * This mainly happens when a gesture would cause the image to get out of 
limits and the
+   * transform gets corrected in order to prevent that.
+   */
+  boolean wasTransformCorrected();
+
+  /**
+   * See {@link android.support.v4.view.ScrollingView}.
+   */
+  int computeHorizontalScrollRange();
+  int computeHorizontalScrollOffset();
+  int computeHorizontalScrollExtent();
+  int computeVerticalScrollRange();
+  int computeVerticalScrollOffset();
+  int computeVerticalScrollExtent();
+
+  /**
    * Gets the current transform.
    *
    * @return the transform
diff --git 
a/app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java 
b/app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java
index ef48379..a41e65c 100644
--- a/app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java
+++ b/app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java
@@ -13,21 +13,26 @@
 package com.facebook.samples.zoomable;
 
 import android.content.Context;
+import android.content.res.Resources;
 import android.graphics.Canvas;
 import android.graphics.Matrix;
 import android.graphics.RectF;
 import android.graphics.drawable.Animatable;
 import android.support.annotation.Nullable;
+import android.support.v4.view.ScrollingView;
 import android.util.AttributeSet;
+import android.view.GestureDetector;
 import android.view.MotionEvent;
-import android.view.ViewConfiguration;
 
 import com.facebook.common.internal.Preconditions;
 import com.facebook.common.logging.FLog;
 import com.facebook.drawee.controller.AbstractDraweeController;
 import com.facebook.drawee.controller.BaseControllerListener;
 import com.facebook.drawee.controller.ControllerListener;
+import com.facebook.drawee.drawable.ScalingUtils;
 import com.facebook.drawee.generic.GenericDraweeHierarchy;
+import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder;
+import com.facebook.drawee.generic.GenericDraweeHierarchyInflater;
 import com.facebook.drawee.interfaces.DraweeController;
 import com.facebook.drawee.view.DraweeView;
 
@@ -37,17 +42,21 @@
  * Once the image loads, pinch-to-zoom and translation gestures are enabled.
  */
 public class ZoomableDraweeView extends DraweeView<GenericDraweeHierarchy>
-    implements ZoomableController.Listener {
+    implements ScrollingView {
 
   private static final Class<?> TAG = ZoomableDraweeView.class;
 
   private static final float HUGE_IMAGE_SCALE_FACTOR_THRESHOLD = 1.1f;
+  private static final boolean DEFAULT_ALLOW_TOUCH_INTERCEPTION_WHILE_ZOOMED = 
true;
 
   private final RectF mImageBounds = new RectF();
   private final RectF mViewBounds = new RectF();
-  private int touchSlop = 
ViewConfiguration.get(getContext()).getScaledTouchSlop();
-  private int startX;
-  private int startY;
+
+  private DraweeController mHugeImageController;
+  private ZoomableController mZoomableController;
+  private GestureDetector mTapGestureDetector;
+  private boolean mAllowTouchInterceptionWhileZoomed =
+      DEFAULT_ALLOW_TOUCH_INTERCEPTION_WHILE_ZOOMED;
 
   private final ControllerListener mControllerListener = new 
BaseControllerListener<Object>() {
     @Override
@@ -64,65 +73,165 @@
     }
   };
 
-  private DraweeController mHugeImageController;
-  private ZoomableController mZoomableController = 
DefaultZoomableController.newInstance();
+  private final ZoomableController.Listener mZoomableListener = new 
ZoomableController.Listener() {
+    @Override
+    public void onTransformChanged(Matrix transform) {
+      ZoomableDraweeView.this.onTransformChanged(transform);
+    }
+  };
+
+  private final GestureListenerWrapper mTapListenerWrapper = new 
GestureListenerWrapper();
+
+  public ZoomableDraweeView(Context context, GenericDraweeHierarchy hierarchy) 
{
+    super(context);
+    setHierarchy(hierarchy);
+    init();
+  }
 
   public ZoomableDraweeView(Context context) {
     super(context);
+    inflateHierarchy(context, null);
     init();
   }
 
   public ZoomableDraweeView(Context context, AttributeSet attrs) {
     super(context, attrs);
+    inflateHierarchy(context, attrs);
     init();
   }
 
   public ZoomableDraweeView(Context context, AttributeSet attrs, int defStyle) 
{
     super(context, attrs, defStyle);
+    inflateHierarchy(context, attrs);
     init();
   }
 
+  protected void inflateHierarchy(Context context, @Nullable AttributeSet 
attrs) {
+    Resources resources = context.getResources();
+    GenericDraweeHierarchyBuilder builder = new 
GenericDraweeHierarchyBuilder(resources)
+        .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER);
+    GenericDraweeHierarchyInflater.updateBuilder(builder, context, attrs);
+    setAspectRatio(builder.getDesiredAspectRatio());
+    setHierarchy(builder.build());
+  }
+
   private void init() {
-    mZoomableController.setListener(this);
+    mZoomableController = createZoomableController();
+    mZoomableController.setListener(mZoomableListener);
+    mTapGestureDetector = new GestureDetector(getContext(), 
mTapListenerWrapper);
   }
 
   /**
-   * Returns the matrix that matches the zoom selected by user gestures,
-   * but does not include the base scaling of the image itself. Transforms
-   * from view-absolute to view-absolute coordinates.
+   * Gets the original image bounds, in view-absolute coordinates.
+   *
+   * <p> The original image bounds are those reported by the hierarchy. The 
hierarchy itself may
+   * apply scaling on its own (e.g. due to scale type) so the reported bounds 
are not necessarily
+   * the same as the actual bitmap dimensions. In other words, the original 
image bounds correspond
+   * to the image bounds within this view when no zoomable transformation is 
applied, but including
+   * the potential scaling of the hierarchy.
+   * Having the actual bitmap dimensions abstracted away from this view 
greatly simplifies
+   * implementation because the actual bitmap may change (e.g. when a high-res 
image arrives and
+   * replaces the previously set low-res image). With proper hierarchy scaling 
(e.g. FIT_CENTER),
+   * this underlying change will not affect this view nor the zoomable 
transformation in any way.
    */
-  public void getTransformMatrix(Matrix outMatrix) {
-    outMatrix.set(mZoomableController.getTransform());
-  }
-
-  /**
-   * Gets the bounds of the image, in view-absolute coordinates,
-   * including the effects of user gestures.
-   */
-  public void getTransformedBounds(RectF outBounds) {
-    getPlainBounds(outBounds);
-    Matrix matrix = mZoomableController.getTransform();
-    matrix.mapRect(outBounds);
-  }
-
-  /**
-   * Gets the bounds of the image, in view-absolute coordinates,
-   * but not including the effets of user gestures.
-   */
-  public void getPlainBounds(RectF outBounds) {
+  protected void getImageBounds(RectF outBounds) {
     getHierarchy().getActualImageBounds(outBounds);
   }
 
+  /**
+   * Gets the bounds used to limit the translation, in view-absolute 
coordinates.
+   *
+   * <p> These bounds are passed to the zoomable controller in order to limit 
the translation. The
+   * image is attempted to be centered within the limit bounds if the 
transformed image is smaller.
+   * There will be no empty spaces within the limit bounds if the transformed 
image is bigger.
+   * This applies to each dimension (horizontal and vertical) independently.
+   * <p> Unless overridden by a subclass, these bounds are same as the view 
bounds.
+   */
+  protected void getLimitBounds(RectF outBounds) {
+    outBounds.set(0, 0, getWidth(), getHeight());
+  }
+
+  /**
+   * Sets a custom zoomable controller, instead of using the default one.
+   */
   public void setZoomableController(ZoomableController zoomableController) {
     Preconditions.checkNotNull(zoomableController);
     mZoomableController.setListener(null);
     mZoomableController = zoomableController;
-    mZoomableController.setListener(this);
+    mZoomableController.setListener(mZoomableListener);
   }
 
+  /**
+   * Gets the zoomable controller.
+   *
+   * <p> Zoomable controller can be used to zoom to point, or to map point 
from view to image
+   * coordinates for instance.
+   */
+  public ZoomableController getZoomableController() {
+    return mZoomableController;
+  }
+
+  /**
+   * Check whether the parent view can intercept touch events while zoomed.
+   * This can be used, for example, to swipe between images in a view pager 
while zoomed.
+   *
+   * @return true if touch events can be intercepted
+   */
+  public boolean allowsTouchInterceptionWhileZoomed() {
+    return mAllowTouchInterceptionWhileZoomed;
+  }
+
+  /**
+   * If this is set to true, parent views can intercept touch events while the 
view is zoomed.
+   * For example, this can be used to swipe between images in a view pager 
while zoomed.
+   *
+   * @param allowTouchInterceptionWhileZoomed true if the parent needs to 
intercept touches
+   */
+  public void setAllowTouchInterceptionWhileZoomed(
+      boolean allowTouchInterceptionWhileZoomed) {
+    mAllowTouchInterceptionWhileZoomed = allowTouchInterceptionWhileZoomed;
+  }
+
+  /**
+   * Sets the tap listener.
+   */
+  public void setTapListener(GestureDetector.SimpleOnGestureListener 
tapListener) {
+    mTapListenerWrapper.setListener(tapListener);
+  }
+
+  /**
+   * Sets whether long-press tap detection is enabled.
+   * Unfortunately, long-press conflicts with onDoubleTapEvent.
+   */
+  public void setIsLongpressEnabled(boolean enabled) {
+    mTapGestureDetector.setIsLongpressEnabled(enabled);
+  }
+
+  /**
+   * Sets the image controller.
+   */
   @Override
   public void setController(@Nullable DraweeController controller) {
     setControllers(controller, null);
+  }
+
+  /**
+   * Sets the controllers for the normal and huge image.
+   *
+   * <p> The huge image controller is used after the image gets scaled above a 
certain threshold.
+   *
+   * <p> IMPORTANT: in order to avoid a flicker when switching to the huge 
image, the huge image
+   * controller should have the normal-image-uri set as its low-res-uri.
+   *
+   * @param controller controller to be initially used
+   * @param hugeImageController controller to be used after the client starts 
zooming-in
+   */
+  public void setControllers(
+      @Nullable DraweeController controller,
+      @Nullable DraweeController hugeImageController) {
+    setControllersInternal(null, null);
+    mZoomableController.setEnabled(false);
+    setControllersInternal(controller, hugeImageController);
   }
 
   private void setControllersInternal(
@@ -134,26 +243,9 @@
     super.setController(controller);
   }
 
-    /**
-     * Sets the controllers for the normal and huge image.
-     *
-     * <p> IMPORTANT: in order to avoid a flicker when switching to the huge 
image, the huge image
-     * controller should have the normal-image-uri set as its low-res-uri.
-     *
-     * @param controller controller to be initially used
-     * @param hugeImageController controller to be used after the client 
starts zooming-in
-     */
-  public void setControllers(
-      @Nullable DraweeController controller,
-      @Nullable DraweeController hugeImageController) {
-    setControllersInternal(null, null);
-    mZoomableController.setEnabled(false);
-    setControllersInternal(controller, hugeImageController);
-  }
-
   private void maybeSetHugeImageController() {
-    if (mHugeImageController != null
-            && mZoomableController.getScaleFactor() > 
HUGE_IMAGE_SCALE_FACTOR_THRESHOLD) {
+    if (mHugeImageController != null &&
+        mZoomableController.getScaleFactor() > 
HUGE_IMAGE_SCALE_FACTOR_THRESHOLD) {
       setControllersInternal(mHugeImageController, null);
     }
   }
@@ -182,34 +274,82 @@
 
   @Override
   public boolean onTouchEvent(MotionEvent event) {
-    if (mZoomableController.onTouchEvent(event)) {
-      if (mZoomableController.getScaleFactor() > 1.0f) {
-        getParent().requestDisallowInterceptTouchEvent(true);
-      }
-      FLog.v(TAG, "onTouchEvent: view %x, handled by zoomable controller", 
this.hashCode());
-      if (event.getAction() == MotionEvent.ACTION_DOWN) {
-        startX = (int) event.getX();
-        startY = (int) event.getY();
-      } else if (event.getAction() == MotionEvent.ACTION_UP
-              && Math.abs((int) event.getX() - startX) < touchSlop
-              && Math.abs((int) event.getY() - startY) < touchSlop) {
-        this.performClick();
-      }
+    int a = event.getActionMasked();
+    FLog.v(getLogTag(), "onTouchEvent: %d, view %x, received", a, 
this.hashCode());
+    if (mTapGestureDetector.onTouchEvent(event)) {
+      FLog.v(
+          getLogTag(),
+          "onTouchEvent: %d, view %x, handled by tap gesture detector",
+          a,
+          this.hashCode());
       return true;
     }
-    FLog.v(TAG, "onTouchEvent: view %x, handled by the super", 
this.hashCode());
-    return super.onTouchEvent(event);
+    if (mZoomableController.onTouchEvent(event)) {
+      // Do not allow the parent to intercept touch events if:
+      // - we do not allow swiping while zoomed and the image is zoomed
+      // - we allow swiping while zoomed and the transform was corrected
+      if ((!mAllowTouchInterceptionWhileZoomed && 
!mZoomableController.isIdentity()) ||
+          (mAllowTouchInterceptionWhileZoomed && 
!mZoomableController.wasTransformCorrected())) {
+        getParent().requestDisallowInterceptTouchEvent(true);
+      }
+      FLog.v(
+          getLogTag(),
+          "onTouchEvent: %d, view %x, handled by zoomable controller",
+          a,
+          this.hashCode());
+      return true;
+    }
+    if (super.onTouchEvent(event)) {
+      FLog.v(getLogTag(), "onTouchEvent: %d, view %x, handled by the super", 
a, this.hashCode());
+      return true;
+    }
+    // None of our components reported that they handled the touch event. Upon 
returning false
+    // from this method, our parent won't send us any more events for this 
gesture. Unfortunately,
+    // some componentes may have started a delayed action, such as a 
long-press timer, and since we
+    // won't receive an ACTION_UP that would cancel that timer, a false event 
may be triggered.
+    // To prevent that we explicitly send one last cancel event when returning 
false.
+    MotionEvent cancelEvent = MotionEvent.obtain(event);
+    cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
+    mTapGestureDetector.onTouchEvent(cancelEvent);
+    mZoomableController.onTouchEvent(cancelEvent);
+    cancelEvent.recycle();
+    return false;
+  }
+
+  @Override
+  public int computeHorizontalScrollRange() {
+    return mZoomableController.computeHorizontalScrollRange();
+  }
+  @Override
+  public int computeHorizontalScrollOffset() {
+    return mZoomableController.computeHorizontalScrollOffset();
+  }
+  @Override
+  public int computeHorizontalScrollExtent() {
+    return mZoomableController.computeHorizontalScrollExtent();
+  }
+  @Override
+  public int computeVerticalScrollRange() {
+    return mZoomableController.computeVerticalScrollRange();
+  }
+  @Override
+  public int computeVerticalScrollOffset() {
+    return mZoomableController.computeVerticalScrollOffset();
+  }
+  @Override
+  public int computeVerticalScrollExtent() {
+    return mZoomableController.computeVerticalScrollExtent();
   }
 
   @Override
   protected void onLayout(boolean changed, int left, int top, int right, int 
bottom) {
-    FLog.v(TAG, "onLayout: view %x", this.hashCode());
+    FLog.v(getLogTag(), "onLayout: view %x", this.hashCode());
     super.onLayout(changed, left, top, right, bottom);
     updateZoomableControllerBounds();
   }
 
   private void onFinalImageSet() {
-    FLog.v(TAG, "onFinalImageSet: view %x", this.hashCode());
+    FLog.v(getLogTag(), "onFinalImageSet: view %x", this.hashCode());
     if (!mZoomableController.isEnabled()) {
       updateZoomableControllerBounds();
       mZoomableController.setEnabled(true);
@@ -217,27 +357,34 @@
   }
 
   private void onRelease() {
-    FLog.v(TAG, "onRelease: view %x", this.hashCode());
+    FLog.v(getLogTag(), "onRelease: view %x", this.hashCode());
     mZoomableController.setEnabled(false);
   }
 
-  @Override
-  public void onTransformChanged(Matrix transform) {
-    FLog.v(TAG, "onTransformChanged: view %x", this.hashCode());
+  protected void onTransformChanged(Matrix transform) {
+    FLog.v(getLogTag(), "onTransformChanged: view %x, transform: %s", 
this.hashCode(), transform);
     maybeSetHugeImageController();
     invalidate();
   }
 
-  private void updateZoomableControllerBounds() {
-    getPlainBounds(mImageBounds);
-    mViewBounds.set(0, 0, getWidth(), getHeight());
+  protected void updateZoomableControllerBounds() {
+    getImageBounds(mImageBounds);
+    getLimitBounds(mViewBounds);
     mZoomableController.setImageBounds(mImageBounds);
     mZoomableController.setViewBounds(mViewBounds);
     FLog.v(
-        TAG,
+        getLogTag(),
         "updateZoomableControllerBounds: view %x, view bounds: %s, image 
bounds: %s",
         this.hashCode(),
         mViewBounds,
         mImageBounds);
   }
+
+  protected Class<?> getLogTag() {
+    return TAG;
+  }
+
+  protected ZoomableController createZoomableController() {
+    return AnimatedZoomableController.newInstance();
+  }
 }
diff --git 
a/app/src/main/java/org/wikipedia/views/FaceAndColorDetectImageView.java 
b/app/src/main/java/org/wikipedia/views/FaceAndColorDetectImageView.java
index 44dce67..0e7ba67 100644
--- a/app/src/main/java/org/wikipedia/views/FaceAndColorDetectImageView.java
+++ b/app/src/main/java/org/wikipedia/views/FaceAndColorDetectImageView.java
@@ -56,7 +56,7 @@
 
     public void loadImage(@Nullable Uri uri) {
         if (!WikipediaApp.getInstance().isImageDownloadEnabled() || uri == 
null) {
-            setImageURI(null);
+            setImageURI((Uri) null);
             return;
         }
         ImageRequest request = ImageRequestBuilder.newBuilderWithSource(uri)
diff --git a/gradle/src/checkstyle.gradle b/gradle/src/checkstyle.gradle
index 16a53bd..325e80b 100644
--- a/gradle/src/checkstyle.gradle
+++ b/gradle/src/checkstyle.gradle
@@ -15,6 +15,7 @@
     source 'src/testlib/java'
     include '**/*.java'
     exclude '**/gen/**'
+    exclude '**/com/facebook/samples/**/*.java'
 
     classpath = configurations.compile
 }
\ No newline at end of file

-- 
To view, visit https://gerrit.wikimedia.org/r/311152
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I64be6993cb1ec4acb6509daf3a1d8a7373b28431
Gerrit-PatchSet: 1
Gerrit-Project: apps/android/wikipedia
Gerrit-Branch: master
Gerrit-Owner: Niedzielski <sniedziel...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to