jenkins-bot has submitted this change and it was merged. Change subject: Bring back pinch-to-zoom in gallery. ......................................................................
Bring back pinch-to-zoom in gallery. So, it looks like the Fresco repo contains a "samples" directory that contains a "ZoomableDraweeView" that precisely suits our needs. https://github.com/facebook/fresco/tree/master/samples/zoomable Unfortunately, this is not yet part of the packaged distribution of Fresco, so we need to package it ourselves for now, and keep an eye out for updates. Bug: T126164 Change-Id: I764a81e904f889318744a985fa197d4c1373c971 --- A app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java A app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java A app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java A app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java A app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java M app/src/main/java/org/wikipedia/page/gallery/GalleryItemFragment.java M app/src/main/res/layout/fragment_gallery_item.xml 7 files changed, 1,250 insertions(+), 10 deletions(-) Approvals: Sniedzielski: Looks good to me, approved jenkins-bot: Verified Objections: Niedzielski: There's a problem with this change, please improve diff --git a/app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java b/app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java new file mode 100644 index 0000000..5ed4874 --- /dev/null +++ b/app/src/main/java/com/facebook/samples/gestures/MultiPointerGestureDetector.java @@ -0,0 +1,240 @@ +/* + * 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.gestures; + +import android.view.MotionEvent; + +/** + * Component that detects and tracks multiple pointers based on touch events. + * <p> + * Each time a pointer gets pressed or released, the current gesture (if any) will end, and a new + * one will be started (if there are still pressed pointers left). It is guaranteed that the number + * of pointers within the single gesture will remain the same during the whole gesture. + */ +public class MultiPointerGestureDetector { + + /** The listener for receiving notifications when gestures occur. */ + public interface Listener { + /** Responds to the beginning of a gesture. */ + void onGestureBegin(MultiPointerGestureDetector detector); + + /** Responds to the update of a gesture in progress. */ + void onGestureUpdate(MultiPointerGestureDetector detector); + + /** Responds to the end of a gesture. */ + 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 Listener mListener = null; + + public MultiPointerGestureDetector() { + reset(); + } + + /** Factory method that creates a new instance of MultiPointerGestureDetector */ + public static MultiPointerGestureDetector newInstance() { + return new MultiPointerGestureDetector(); + } + + /** + * Sets the listener. + * @param listener listener to set + */ + public void setListener(Listener listener) { + mListener = listener; + } + + /** + * Resets the component to the initial state. + */ + public void reset() { + mGestureInProgress = false; + mCount = 0; + for (int i = 0; i < MAX_POINTERS; i++) { + mId[i] = MotionEvent.INVALID_POINTER_ID; + } + } + + /** + * This method can be overridden in order to perform threshold check or something similar. + * @return whether or not to start a new gesture + */ + protected boolean shouldStartGesture() { + return true; + } + + private void startGesture() { + if (!mGestureInProgress) { + mGestureInProgress = true; + if (mListener != null) { + mListener.onGestureBegin(this); + } + } + } + + private void stopGesture() { + if (mGestureInProgress) { + mGestureInProgress = false; + if (mListener != null) { + mListener.onGestureEnd(this); + } + } + } + + /** + * Gets the index of the i-th pressed pointer. + * Normally, the index will be equal to i, except in the case when the pointer is released. + * @return index of the specified pointer or -1 if not found (i.e. not enough pointers are down) + */ + private int getPressedPointerIndex(MotionEvent event, int i) { + 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 (i >= index) { + i++; + } + } + return (i < count) ? i : -1; + } + + /** + * Handles the given motion event. + * @param event event to handle + * @return whether or not the event was handled + */ + public boolean onTouchEvent(final MotionEvent event) { + switch (event.getActionMasked()) { + 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); + } + } + // start a new gesture if not already started + if (!mGestureInProgress && shouldStartGesture()) { + startGesture(); + } + // notify listener + if (mGestureInProgress && mListener != null) { + 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; + 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) { + startGesture(); + } + break; + + case MotionEvent.ACTION_CANCEL: + stopGesture(); + reset(); + break; + + default: + break; + } + return true; + } + + /** Restarts the current gesture */ + public void restartGesture() { + if (!mGestureInProgress) { + return; + } + stopGesture(); + for (int i = 0; i < MAX_POINTERS; i++) { + mStartX[i] = mCurrentX[i]; + mStartY[i] = mCurrentY[i]; + } + startGesture(); + } + + /** Gets whether gesture is in progress or not */ + public boolean isGestureInProgress() { + return mGestureInProgress; + } + + /** Gets the number of pointers in the current gesture */ + public int getCount() { + return mCount; + } + + /** + * Gets the start X coordinates for the all pointers + * Mutable array is exposed for performance reasons and is not to be modified by the callers. + */ + public float[] getStartX() { + return mStartX; + } + + /** + * Gets the start Y coordinates for the all pointers + * Mutable array is exposed for performance reasons and is not to be modified by the callers. + */ + public float[] getStartY() { + return mStartY; + } + + /** + * Gets the current X coordinates for the all pointers + * Mutable array is exposed for performance reasons and is not to be modified by the callers. + */ + public float[] getCurrentX() { + return mCurrentX; + } + + /** + * Gets the current Y coordinates for the all pointers + * Mutable array is exposed for performance reasons and is not to be modified by the callers. + */ + public float[] getCurrentY() { + return mCurrentY; + } +} diff --git a/app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java b/app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java new file mode 100644 index 0000000..d5baf79 --- /dev/null +++ b/app/src/main/java/com/facebook/samples/gestures/TransformGestureDetector.java @@ -0,0 +1,166 @@ +/* + * 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.gestures; + +import android.view.MotionEvent; + +/** + * Component that detects translation, scale and rotation based on touch events. + * <p> + * This class notifies its listeners whenever a gesture begins, updates or ends. + * The instance of this detector is passed to the listeners, so it can be queried + * for pivot, translation, scale or rotation. + */ +public class TransformGestureDetector implements MultiPointerGestureDetector.Listener { + + /** The listener for receiving notifications when gestures occur. */ + public interface Listener { + /** Responds to the beginning of a gesture. */ + void onGestureBegin(TransformGestureDetector detector); + + /** Responds to the update of a gesture in progress. */ + void onGestureUpdate(TransformGestureDetector detector); + + /** Responds to the end of a gesture. */ + void onGestureEnd(TransformGestureDetector detector); + } + + private final MultiPointerGestureDetector mDetector; + + private Listener mListener = null; + + public TransformGestureDetector(MultiPointerGestureDetector multiPointerGestureDetector) { + mDetector = multiPointerGestureDetector; + mDetector.setListener(this); + } + + /** Factory method that creates a new instance of TransformGestureDetector */ + public static TransformGestureDetector newInstance() { + return new TransformGestureDetector(MultiPointerGestureDetector.newInstance()); + } + + /** + * Sets the listener. + * @param listener listener to set + */ + public void setListener(Listener listener) { + mListener = listener; + } + + /** + * Resets the component to the initial state. + */ + public void reset() { + mDetector.reset(); + } + + /** + * Handles the given motion event. + * @param event event to handle + * @return whether or not the event was handled + */ + public boolean onTouchEvent(final MotionEvent event) { + return mDetector.onTouchEvent(event); + } + + @Override + public void onGestureBegin(MultiPointerGestureDetector detector) { + if (mListener != null) { + mListener.onGestureBegin(this); + } + } + + @Override + public void onGestureUpdate(MultiPointerGestureDetector detector) { + if (mListener != null) { + mListener.onGestureUpdate(this); + } + } + + @Override + public void onGestureEnd(MultiPointerGestureDetector detector) { + if (mListener != null) { + mListener.onGestureEnd(this); + } + } + + private float calcAverage(float[] arr, int len) { + float sum = 0; + for (int i = 0; i < len; i++) { + sum += arr[i]; + } + return (len > 0) ? sum / len : 0; + } + + /** Restarts the current gesture */ + public void restartGesture() { + mDetector.restartGesture(); + } + + /** Gets whether gesture is in progress or not */ + public boolean isGestureInProgress() { + return mDetector.isGestureInProgress(); + } + + /** Gets the X coordinate of the pivot point */ + public float getPivotX() { + return calcAverage(mDetector.getStartX(), mDetector.getCount()); + } + + /** Gets the Y coordinate of the pivot point */ + public float getPivotY() { + return calcAverage(mDetector.getStartY(), mDetector.getCount()); + } + + /** Gets the X component of the translation */ + public float getTranslationX() { + return calcAverage(mDetector.getCurrentX(), mDetector.getCount()) + - calcAverage(mDetector.getStartX(), mDetector.getCount()); + } + + /** Gets the Y component of the translation */ + public float getTranslationY() { + return calcAverage(mDetector.getCurrentY(), mDetector.getCount()) + - calcAverage(mDetector.getStartY(), mDetector.getCount()); + } + + /** Gets the scale */ + public float getScale() { + if (mDetector.getCount() < 2) { + return 1; + } else { + float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0]; + float startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0]; + float currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0]; + float currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0]; + float startDist = (float) Math.hypot(startDeltaX, startDeltaY); + float currentDist = (float) Math.hypot(currentDeltaX, currentDeltaY); + return currentDist / startDist; + } + } + + /** Gets the rotation in radians */ + public float getRotation() { + if (mDetector.getCount() < 2) { + return 0; + } else { + float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0]; + float startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0]; + float currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0]; + float currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0]; + float startAngle = (float) Math.atan2(startDeltaY, startDeltaX); + float currentAngle = (float) Math.atan2(currentDeltaY, currentDeltaX); + return currentAngle - startAngle; + } + } +} diff --git a/app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java b/app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java new file mode 100644 index 0000000..877e56a --- /dev/null +++ b/app/src/main/java/com/facebook/samples/zoomable/DefaultZoomableController.java @@ -0,0 +1,487 @@ +/* + * 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.graphics.RectF; +import android.support.annotation.Nullable; +import android.view.MotionEvent; +import android.view.animation.DecelerateInterpolator; + +import com.facebook.common.internal.Preconditions; +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. + */ +public class DefaultZoomableController + implements ZoomableController, TransformGestureDetector.Listener { + + private static final int MATRIX_SIZE = 9; + private static final RectF IDENTITY_RECT = new RectF(0, 0, 1, 1); + + private TransformGestureDetector mGestureDetector; + + private Listener mListener = null; + + private boolean mIsEnabled = false; + private boolean mIsRotationEnabled = false; + private boolean mIsScaleEnabled = true; + private boolean mIsTranslationEnabled = true; + + private float mMinScaleFactor = 1.0f; + private float mMaxScaleFactor = Float.POSITIVE_INFINITY; + + private final RectF mViewBounds = new RectF(); + private final RectF mImageBounds = new RectF(); + 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 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()); + } + + public static DefaultZoomableController newInstance() { + return new DefaultZoomableController(TransformGestureDetector.newInstance()); + } + + @Override + public void setListener(Listener listener) { + mListener = listener; + } + + /** Rests the controller. */ + public void reset() { + mGestureDetector.reset(); + mPreviousTransform.reset(); + mActiveTransform.reset(); + onTransformChanged(); + } + + /** Sets whether the controller is enabled or not. */ + @Override + public void setEnabled(boolean enabled) { + mIsEnabled = enabled; + if (!enabled) { + reset(); + } + } + + /** Returns whether the controller is enabled or not. */ + @Override + public boolean isEnabled() { + return mIsEnabled; + } + + /** Sets whether the rotation gesture is enabled or not. */ + public void setRotationEnabled(boolean enabled) { + mIsRotationEnabled = enabled; + } + + /** Gets whether the rotation gesture is enabled or not. */ + public boolean isRotationEnabled() { + return mIsRotationEnabled; + } + + /** Sets whether the scale gesture is enabled or not. */ + public void setScaleEnabled(boolean enabled) { + mIsScaleEnabled = enabled; + } + + /** Gets whether the scale gesture is enabled or not. */ + public boolean isScaleEnabled() { + return mIsScaleEnabled; + } + + /** Sets whether the translation gesture is enabled or not. */ + public void setTranslationEnabled(boolean enabled) { + mIsTranslationEnabled = enabled; + } + + /** Gets whether the translations gesture is enabled or not. */ + public boolean isTranslationEnabled() { + 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); + } + + /** Gets the minimum scale factor allowed. */ + public float getMinScaleFactor() { + return mMinScaleFactor; + } + + /** + * 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. + */ + public void setMinScaleFactor(float minScaleFactor) { + mMinScaleFactor = minScaleFactor; + } + + /** Gets the maximum scale factor allowed. */ + public float getMaxScaleFactor() { + 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; + } + + /** + * Maps point from the view's to the image's relative coordinate system. + * This takes into account the zoomable transformation. + */ + public PointF mapViewToImage(PointF viewPoint) { + float[] points = mTempValues; + points[0] = viewPoint.x; + points[1] = viewPoint.y; + mActiveTransform.invert(mActiveTransformInverse); + mActiveTransformInverse.mapPoints(points, 0, points, 0, 1); + mapAbsoluteToRelative(points, points, 1); + return new PointF(points[0], points[1]); + } + + /** + * Maps point from the image's relative to the view's coordinate system. + * This takes into account the zoomable transformation. + */ + public PointF mapImageToView(PointF imagePoint) { + float[] points = mTempValues; + points[0] = imagePoint.x; + points[1] = imagePoint.y; + mapRelativeToAbsolute(points, points, 1); + mActiveTransform.mapPoints(points, 0, points, 0, 1); + return new PointF(points[0], points[1]); + } + + /** + * 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, ...]. + * + * @param destPoints destination array (may be the same as source array) + * @param srcPoints source array + * @param numPoints number of points to map + */ + private void mapAbsoluteToRelative(float[] destPoints, float[] srcPoints, int numPoints) { + for (int i = 0; i < numPoints; i++) { + destPoints[i * 2 + 0] = (srcPoints[i * 2 + 0] - mImageBounds.left) / mImageBounds.width(); + destPoints[i * 2 + 1] = (srcPoints[i * 2 + 1] - mImageBounds.top) / mImageBounds.height(); + } + } + + /** + * Maps array of 2D points from relative to the image's absolute coordinate system, + * and writes the transformed points back into the array + * Points are represented by float array of [x0, y0, x1, y1, ...]. + * + * @param destPoints destination array (may be the same as source array) + * @param srcPoints source array + * @param numPoints number of points to map + */ + private void mapRelativeToAbsolute(float[] destPoints, float[] srcPoints, int numPoints) { + for (int i = 0; i < numPoints; i++) { + destPoints[i * 2 + 0] = srcPoints[i * 2 + 0] * mImageBounds.width() + mImageBounds.left; + destPoints[i * 2 + 1] = srcPoints[i * 2 + 1] * mImageBounds.height() + mImageBounds.top; + } + } + + /** + * 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. + * + * <p>If this method is called while an animation or gesture is already in progress, + * this will currently result in undefined behavior. + */ + public void setTransform(Matrix newTransform) { + setTransform(newTransform, 0, null); + } + + /** + * 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(); + } + } + + /** Notifies controller of the received touch event. */ + @Override + public boolean onTouchEvent(MotionEvent event) { + if (mIsEnabled) { + return mGestureDetector.onTouchEvent(event); + } + return false; + } + + protected void onTransformChanged() { + mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds); + if (mListener != null && isEnabled()) { + mListener.onTransformChanged(mActiveTransform); + } + } + + /** + * Zooms to the desired scale and positions the view so that imagePoint is in the center. + * + * <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 + */ + 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()); + } + 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 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); + 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); + } + + private float limit(float value, float min, float max) { + return Math.min(Math.max(min, value), max); + } + +} diff --git a/app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java b/app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java new file mode 100644 index 0000000..23d6cd5 --- /dev/null +++ b/app/src/main/java/com/facebook/samples/zoomable/ZoomableController.java @@ -0,0 +1,97 @@ +/* + * 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.RectF; +import android.view.MotionEvent; + +/** + * Interface for implementing a controller that works with {@link ZoomableDraweeView} + * to control the zoom. + */ +public interface ZoomableController { + + /** + * Listener interface. + */ + public interface Listener { + + /** + * Notifies the view that the transform changed. + * + * @param transform the new matrix + */ + void onTransformChanged(Matrix transform); + } + + /** + * Enables the controller. The controller is enabled when the image has been loaded. + * + * @param enabled whether to enable the controller + */ + void setEnabled(boolean enabled); + + /** + * Gets whether the controller is enabled. This should return the last value passed to + * {@link #setEnabled}. + * + * @return whether the controller is enabled. + */ + boolean isEnabled(); + + /** + * Sets the listener for the controller to call back when the matrix changes. + * + * @param listener the listener + */ + void setListener(Listener listener); + + /** + * Gets the current scale factor. A convenience method for calculating the scale from the + * transform. + * + * @return the current scale factor + */ + float getScaleFactor(); + + /** + * Gets the current transform. + * + * @return the transform + */ + Matrix getTransform(); + + /** + * Sets the bounds of the image post transform prior to application of the zoomable + * transformation. + * + * @param imageBounds the bounds of the image + */ + void setImageBounds(RectF imageBounds); + + /** + * Sets the bounds of the view. + * + * @param viewBounds the bounds of the view + */ + void setViewBounds(RectF viewBounds); + + /** + * Allows the controller to handle a touch event. + * + * @param event the touch event + * @return whether the controller handled the event + */ + boolean onTouchEvent(MotionEvent event); +} diff --git a/app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java b/app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java new file mode 100644 index 0000000..ef48379 --- /dev/null +++ b/app/src/main/java/com/facebook/samples/zoomable/ZoomableDraweeView.java @@ -0,0 +1,243 @@ +/* + * 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.content.Context; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.graphics.drawable.Animatable; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +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.generic.GenericDraweeHierarchy; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.drawee.view.DraweeView; + +/** + * DraweeView that has zoomable capabilities. + * <p> + * Once the image loads, pinch-to-zoom and translation gestures are enabled. + */ +public class ZoomableDraweeView extends DraweeView<GenericDraweeHierarchy> + implements ZoomableController.Listener { + + private static final Class<?> TAG = ZoomableDraweeView.class; + + private static final float HUGE_IMAGE_SCALE_FACTOR_THRESHOLD = 1.1f; + + 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 final ControllerListener mControllerListener = new BaseControllerListener<Object>() { + @Override + public void onFinalImageSet( + String id, + @Nullable Object imageInfo, + @Nullable Animatable animatable) { + ZoomableDraweeView.this.onFinalImageSet(); + } + + @Override + public void onRelease(String id) { + ZoomableDraweeView.this.onRelease(); + } + }; + + private DraweeController mHugeImageController; + private ZoomableController mZoomableController = DefaultZoomableController.newInstance(); + + public ZoomableDraweeView(Context context) { + super(context); + init(); + } + + public ZoomableDraweeView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ZoomableDraweeView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(); + } + + private void init() { + mZoomableController.setListener(this); + } + + /** + * 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. + */ + 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) { + getHierarchy().getActualImageBounds(outBounds); + } + + public void setZoomableController(ZoomableController zoomableController) { + Preconditions.checkNotNull(zoomableController); + mZoomableController.setListener(null); + mZoomableController = zoomableController; + mZoomableController.setListener(this); + } + + @Override + public void setController(@Nullable DraweeController controller) { + setControllers(controller, null); + } + + private void setControllersInternal( + @Nullable DraweeController controller, + @Nullable DraweeController hugeImageController) { + removeControllerListener(getController()); + addControllerListener(controller); + mHugeImageController = hugeImageController; + 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) { + setControllersInternal(mHugeImageController, null); + } + } + + private void removeControllerListener(DraweeController controller) { + if (controller instanceof AbstractDraweeController) { + ((AbstractDraweeController) controller) + .removeControllerListener(mControllerListener); + } + } + + private void addControllerListener(DraweeController controller) { + if (controller instanceof AbstractDraweeController) { + ((AbstractDraweeController) controller) + .addControllerListener(mControllerListener); + } + } + + @Override + protected void onDraw(Canvas canvas) { + int saveCount = canvas.save(); + canvas.concat(mZoomableController.getTransform()); + super.onDraw(canvas); + canvas.restoreToCount(saveCount); + } + + @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(); + } + return true; + } + FLog.v(TAG, "onTouchEvent: view %x, handled by the super", this.hashCode()); + return super.onTouchEvent(event); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + FLog.v(TAG, "onLayout: view %x", this.hashCode()); + super.onLayout(changed, left, top, right, bottom); + updateZoomableControllerBounds(); + } + + private void onFinalImageSet() { + FLog.v(TAG, "onFinalImageSet: view %x", this.hashCode()); + if (!mZoomableController.isEnabled()) { + updateZoomableControllerBounds(); + mZoomableController.setEnabled(true); + } + } + + private void onRelease() { + FLog.v(TAG, "onRelease: view %x", this.hashCode()); + mZoomableController.setEnabled(false); + } + + @Override + public void onTransformChanged(Matrix transform) { + FLog.v(TAG, "onTransformChanged: view %x", this.hashCode()); + maybeSetHugeImageController(); + invalidate(); + } + + private void updateZoomableControllerBounds() { + getPlainBounds(mImageBounds); + mViewBounds.set(0, 0, getWidth(), getHeight()); + mZoomableController.setImageBounds(mImageBounds); + mZoomableController.setViewBounds(mViewBounds); + FLog.v( + TAG, + "updateZoomableControllerBounds: view %x, view bounds: %s, image bounds: %s", + this.hashCode(), + mViewBounds, + mImageBounds); + } +} diff --git a/app/src/main/java/org/wikipedia/page/gallery/GalleryItemFragment.java b/app/src/main/java/org/wikipedia/page/gallery/GalleryItemFragment.java index 852eac0..f6700d9 100644 --- a/app/src/main/java/org/wikipedia/page/gallery/GalleryItemFragment.java +++ b/app/src/main/java/org/wikipedia/page/gallery/GalleryItemFragment.java @@ -33,8 +33,12 @@ import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchy; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.view.SimpleDraweeView; import com.facebook.imagepipeline.image.ImageInfo; +import com.facebook.samples.zoomable.ZoomableDraweeView; import java.util.Map; @@ -53,7 +57,7 @@ private String mimeType; private ProgressBar progressBar; - private SimpleDraweeView imageView; + private ZoomableDraweeView imageView; private View videoContainer; private VideoView videoView; @@ -92,19 +96,22 @@ @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_gallery_item, container, false); - View containerView = rootView.findViewById(R.id.gallery_item_container); - containerView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - parentActivity.toggleControls(); - } - }); progressBar = (ProgressBar) rootView.findViewById(R.id.gallery_item_progress_bar); videoContainer = rootView.findViewById(R.id.gallery_video_container); videoView = (VideoView) rootView.findViewById(R.id.gallery_video); videoThumbnail = (SimpleDraweeView) rootView.findViewById(R.id.gallery_video_thumbnail); videoPlayButton = rootView.findViewById(R.id.gallery_video_play_button); - imageView = (SimpleDraweeView) rootView.findViewById(R.id.gallery_image); + imageView = (ZoomableDraweeView) rootView.findViewById(R.id.gallery_image); + imageView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + parentActivity.toggleControls(); + } + }); + GenericDraweeHierarchy hierarchy = new GenericDraweeHierarchyBuilder(getResources()) + .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + .build(); + imageView.setHierarchy(hierarchy); return rootView; } diff --git a/app/src/main/res/layout/fragment_gallery_item.xml b/app/src/main/res/layout/fragment_gallery_item.xml index 9969208..023e330 100644 --- a/app/src/main/res/layout/fragment_gallery_item.xml +++ b/app/src/main/res/layout/fragment_gallery_item.xml @@ -35,7 +35,7 @@ android:contentDescription="@null" /> </FrameLayout> - <com.facebook.drawee.view.SimpleDraweeView + <com.facebook.samples.zoomable.ZoomableDraweeView android:id="@+id/gallery_image" android:visibility="gone" android:layout_width="match_parent" -- To view, visit https://gerrit.wikimedia.org/r/269891 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I764a81e904f889318744a985fa197d4c1373c971 Gerrit-PatchSet: 2 Gerrit-Project: apps/android/wikipedia Gerrit-Branch: master Gerrit-Owner: Dbrant <[email protected]> Gerrit-Reviewer: BearND <[email protected]> Gerrit-Reviewer: Brion VIBBER <[email protected]> Gerrit-Reviewer: Dbrant <[email protected]> Gerrit-Reviewer: Mholloway <[email protected]> Gerrit-Reviewer: Niedzielski <[email protected]> Gerrit-Reviewer: Sniedzielski <[email protected]> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list [email protected] https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits
