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. + * + *
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. + * + *
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/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.java new file mode 100644 index 000000000..471ceb973 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.java @@ -0,0 +1,96 @@ +package fr.free.nrw.commons.media.zoomControllers.zoomable; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.graphics.Matrix; +import android.view.animation.DecelerateInterpolator; +import com.facebook.common.internal.Preconditions; +import com.facebook.common.logging.FLog; +import androidx.annotation.Nullable; +import fr.free.nrw.commons.media.zoomControllers.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; + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.java new file mode 100644 index 000000000..9c3538f05 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.java @@ -0,0 +1,646 @@ +package fr.free.nrw.commons.media.zoomControllers.zoomable; + +import android.graphics.Matrix; +import android.graphics.PointF; +import android.graphics.RectF; +import android.view.MotionEvent; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector; +import com.facebook.common.logging.FLog; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Zoomable controller that calculates transformation based on touch events. */ +public class DefaultZoomableController + implements ZoomableController, TransformGestureDetector.Listener { + + /** Interface for handling call backs when the image bounds are set. */ + public interface ImageBoundsListener { + void onImageBoundsSet(RectF imageBounds); + } + + @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; + + private @Nullable ImageBoundsListener mImageBoundsListener; + + private @Nullable Listener mListener = null; + + private boolean mIsEnabled = false; + private boolean mIsRotationEnabled = false; + private boolean mIsScaleEnabled = true; + private boolean mIsTranslationEnabled = true; + private boolean mIsGestureZoomEnabled = true; + + private float mMinScaleFactor = 1.0f; + 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[9]; + private final RectF mTempRect = new RectF(); + private boolean mWasTransformCorrected; + + public static DefaultZoomableController newInstance() { + return new DefaultZoomableController(TransformGestureDetector.newInstance()); + } + + 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. */ + @Override + public void setEnabled(boolean enabled) { + mIsEnabled = enabled; + if (!enabled) { + reset(); + } + } + + /** Gets 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; + } + + /** + * Sets the minimum scale factor allowed. + * + *
Hierarchy's scaling (if any) is not taken into account. + */ + public void setMinScaleFactor(float minScaleFactor) { + mMinScaleFactor = minScaleFactor; + } + + /** Gets the minimum scale factor allowed. */ + public float getMinScaleFactor() { + return mMinScaleFactor; + } + + /** + * Sets the maximum scale factor allowed. + * + *
Hierarchy's scaling (if any) is not taken into account. + */ + public void setMaxScaleFactor(float maxScaleFactor) { + mMaxScaleFactor = maxScaleFactor; + } + + /** Gets the maximum scale factor allowed. */ + public float getMaxScaleFactor() { + return mMaxScaleFactor; + } + + /** Sets whether gesture zooms are enabled or not. */ + public void setGestureZoomEnabled(boolean isGestureZoomEnabled) { + mIsGestureZoomEnabled = isGestureZoomEnabled; + } + + /** Gets whether gesture zooms are enabled or not. */ + public boolean isGestureZoomEnabled() { + return mIsGestureZoomEnabled; + } + + /** 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(); + if (mImageBoundsListener != null) { + mImageBoundsListener.onImageBoundsSet(mImageBounds); + } + } + } + + /** 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; + } + + /** Sets the image bounds listener. */ + public void setImageBoundsListener(@Nullable ImageBoundsListener imageBoundsListener) { + mImageBoundsListener = imageBoundsListener; + } + + /** Gets the image bounds listener. */ + public @Nullable ImageBoundsListener getImageBoundsListener() { + return mImageBoundsListener; + } + + /** 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) { + 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 image-relative to view-absolute coordinates. 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 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 + * @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 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) + * @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; + } + } + + /** + * Zooms to the desired scale and positions the image so that the given image point corresponds to + * the given view point. + * + * @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) { + FLog.v(TAG, "setTransform"); + mActiveTransform.set(newTransform); + onTransformChanged(); + } + + /** 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 && mIsGestureZoomEnabled) { + return mGestureDetector.onTouchEvent(event); + } + return false; + } + + /* TransformGestureDetector.Listener methods */ + + @Override + public void onGestureBegin(TransformGestureDetector detector) { + FLog.v(TAG, "onGestureBegin"); + mPreviousTransform.set(mActiveTransform); + onTransformBegin(); + // 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"); + onTransformEnd(); + } + + /** + * 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 onTransformBegin() { + if (mListener != null && isEnabled()) { + mListener.onTransformBegin(mActiveTransform); + } + } + + private void onTransformChanged() { + mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds); + if (mListener != null && isEnabled()) { + mListener.onTransformChanged(mActiveTransform); + } + } + + private void onTransformEnd() { + if (mListener != null && isEnabled()) { + mListener.onTransformEnd(mActiveTransform); + } + } + + /** + * Keeps the scaling factor within the specified limits. + * + * @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 + */ + private boolean limitScale( + Matrix transform, float pivotX, float pivotY, @LimitFlag int limitTypes) { + if (!shouldLimit(limitTypes, LIMIT_SCALE)) { + return false; + } + float currentScale = getMatrixScaleFactor(transform); + float targetScale = limit(currentScale, mMinScaleFactor, mMaxScaleFactor); + if (targetScale != currentScale) { + float scale = targetScale / currentScale; + transform.postScale(scale, scale, pivotX, pivotY); + return true; + } + return false; + } + + /** + * Limits the translation so that there are no empty spaces on the sides if possible. + * + *
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. + * + *
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();
+ }
+
+ public Listener getListener() {
+ return mListener;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.java
new file mode 100644
index 000000000..395b5c388
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.java
@@ -0,0 +1,77 @@
+package fr.free.nrw.commons.media.zoomControllers.zoomable;
+
+import android.graphics.PointF;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+
+/**
+ * Tap gesture listener for double tap to zoom / unzoom and double-tap-and-drag to zoom.
+ *
+ * @see ZoomableDraweeView#setTapListener(GestureDetector.SimpleOnGestureListener)
+ */
+public class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener {
+ private static final int DURATION_MS = 300;
+ private static final int DOUBLE_TAP_SCROLL_THRESHOLD = 20;
+
+ private final ZoomableDraweeView mDraweeView;
+ private final PointF mDoubleTapViewPoint = new PointF();
+ private final PointF mDoubleTapImagePoint = new PointF();
+ private float mDoubleTapScale = 1;
+ private boolean mDoubleTapScroll = false;
+
+ public DoubleTapGestureListener(ZoomableDraweeView zoomableDraweeView) {
+ mDraweeView = zoomableDraweeView;
+ }
+
+ @Override
+ public boolean onDoubleTapEvent(MotionEvent e) {
+ AbstractAnimatedZoomableController zc =
+ (AbstractAnimatedZoomableController) mDraweeView.getZoomableController();
+ PointF vp = new PointF(e.getX(), e.getY());
+ PointF ip = zc.mapViewToImage(vp);
+ switch (e.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ mDoubleTapViewPoint.set(vp);
+ mDoubleTapImagePoint.set(ip);
+ mDoubleTapScale = zc.getScaleFactor();
+ break;
+ case MotionEvent.ACTION_MOVE:
+ mDoubleTapScroll = mDoubleTapScroll || shouldStartDoubleTapScroll(vp);
+ if (mDoubleTapScroll) {
+ float scale = calcScale(vp);
+ zc.zoomToPoint(scale, mDoubleTapImagePoint, mDoubleTapViewPoint);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ if (mDoubleTapScroll) {
+ float scale = calcScale(vp);
+ zc.zoomToPoint(scale, mDoubleTapImagePoint, mDoubleTapViewPoint);
+ } else {
+ final float maxScale = zc.getMaxScaleFactor();
+ final float minScale = zc.getMinScaleFactor();
+ if (zc.getScaleFactor() < (maxScale + minScale) / 2) {
+ zc.zoomToPoint(
+ maxScale, ip, vp, DefaultZoomableController.LIMIT_ALL, DURATION_MS, null);
+ } else {
+ zc.zoomToPoint(
+ minScale, ip, vp, DefaultZoomableController.LIMIT_ALL, DURATION_MS, null);
+ }
+ }
+ mDoubleTapScroll = false;
+ break;
+ }
+ return true;
+ }
+
+ private boolean shouldStartDoubleTapScroll(PointF viewPoint) {
+ double dist =
+ Math.hypot(viewPoint.x - mDoubleTapViewPoint.x, viewPoint.y - mDoubleTapViewPoint.y);
+ return dist > DOUBLE_TAP_SCROLL_THRESHOLD;
+ }
+
+ private float calcScale(PointF currentViewPoint) {
+ float dy = (currentViewPoint.y - mDoubleTapViewPoint.y);
+ float t = 1 + Math.abs(dy) * 0.001f;
+ return (dy < 0) ? mDoubleTapScale / t : mDoubleTapScale * t;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.java
new file mode 100644
index 000000000..b8433de90
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.java
@@ -0,0 +1,63 @@
+package fr.free.nrw.commons.media.zoomControllers.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/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.java
new file mode 100644
index 000000000..05be602a7
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.java
@@ -0,0 +1,148 @@
+package fr.free.nrw.commons.media.zoomControllers.zoomable;
+
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Gesture listener that allows multiple child listeners to be added and notified about gesture
+ * events.
+ *
+ * NOTE: The order of the listeners is important. Listeners can consume gesture events. For
+ * example, if one of the child listeners consumes {@link #onLongPress(MotionEvent)} (the listener
+ * returned true), subsequent listeners will not be notified about the event any more since it has
+ * been consumed.
+ */
+public class MultiGestureListener extends GestureDetector.SimpleOnGestureListener {
+
+ private final List NOTE: The order of the listeners is important since gesture events can be consumed.
+ *
+ * @param listener the listener to be added
+ */
+ public synchronized void addListener(GestureDetector.SimpleOnGestureListener listener) {
+ mListeners.add(listener);
+ }
+
+ /**
+ * Removes the given listener so that it will not be notified about future events.
+ *
+ * NOTE: The order of the listeners is important since gesture events can be consumed.
+ *
+ * @param listener the listener to remove
+ */
+ public synchronized void removeListener(GestureDetector.SimpleOnGestureListener listener) {
+ mListeners.remove(listener);
+ }
+
+ @Override
+ public synchronized boolean onSingleTapUp(MotionEvent e) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ if (mListeners.get(i).onSingleTapUp(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public synchronized void onLongPress(MotionEvent e) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ mListeners.get(i).onLongPress(e);
+ }
+ }
+
+ @Override
+ public synchronized boolean onScroll(
+ MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ if (mListeners.get(i).onScroll(e1, e2, distanceX, distanceY)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public synchronized boolean onFling(
+ MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ if (mListeners.get(i).onFling(e1, e2, velocityX, velocityY)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public synchronized void onShowPress(MotionEvent e) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ mListeners.get(i).onShowPress(e);
+ }
+ }
+
+ @Override
+ public synchronized boolean onDown(MotionEvent e) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ if (mListeners.get(i).onDown(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public synchronized boolean onDoubleTap(MotionEvent e) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ if (mListeners.get(i).onDoubleTap(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public synchronized boolean onDoubleTapEvent(MotionEvent e) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ if (mListeners.get(i).onDoubleTapEvent(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public synchronized boolean onSingleTapConfirmed(MotionEvent e) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ if (mListeners.get(i).onSingleTapConfirmed(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public synchronized boolean onContextClick(MotionEvent e) {
+ final int size = mListeners.size();
+ for (int i = 0; i < size; i++) {
+ if (mListeners.get(i).onContextClick(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.java
new file mode 100644
index 000000000..33268ed29
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.java
@@ -0,0 +1,40 @@
+package fr.free.nrw.commons.media.zoomControllers.zoomable;
+
+import android.graphics.Matrix;
+import java.util.ArrayList;
+import java.util.List;
+
+
+public class MultiZoomableControllerListener implements ZoomableController.Listener {
+
+ private final List 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 androidx.core.view.ScrollingView}. */
+ int computeHorizontalScrollRange();
+
+ int computeHorizontalScrollOffset();
+
+ int computeHorizontalScrollExtent();
+
+ int computeVerticalScrollRange();
+
+ int computeVerticalScrollOffset();
+
+ int computeVerticalScrollExtent();
+
+ /**
+ * 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/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.java
new file mode 100644
index 000000000..3cb2b2f91
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.java
@@ -0,0 +1,400 @@
+package fr.free.nrw.commons.media.zoomControllers.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.util.AttributeSet;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+
+import androidx.annotation.Nullable;
+import androidx.core.view.ScrollingView;
+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;
+
+
+/**
+ * DraweeView that has zoomable capabilities.
+ *
+ * Once the image loads, pinch-to-zoom and translation gestures are enabled.
+ */
+public class ZoomableDraweeView extends DraweeView