From 642ed51c8c8293e8a658d6c43d67048c7a70b5b9 Mon Sep 17 00:00:00 2001 From: Kshitij Bhardwaj <44129798+kbhardwaj123@users.noreply.github.com> Date: Mon, 9 Mar 2020 14:07:48 -0400 Subject: [PATCH] Fixes #3414: For v2.13, Handle zoom in media details view (#3422) * MediaDetailFragment: add zoom feature * fragment_media_detail: add SimpleDrawee for Scroll picture * ZoomableActivity: activity which facilitates zoom in * activity_zoomable: xml for zoom activity * zoomControllers: controllers for handling gesture and zooming * MediaDetailFragment: fixing name of image variable * MediaDetailFragment: display as per the aspect ratio of image * add zoom activity to AndroidManifest * fix travis ci faliure * fix resizing of image --- app/src/main/AndroidManifest.xml | 3 + .../commons/media/MediaDetailFragment.java | 65 +- .../nrw/commons/media/ZoomableActivity.java | 64 ++ .../gestures/MultiPointerGestureDetector.java | 256 +++++++ .../gestures/TransformGestureDetector.java | 164 +++++ .../AbstractAnimatedZoomableController.java | 160 +++++ .../zoomable/AnimatedZoomableController.java | 96 +++ .../zoomable/DefaultZoomableController.java | 646 ++++++++++++++++++ .../zoomable/DoubleTapGestureListener.java | 77 +++ .../zoomable/GestureListenerWrapper.java | 63 ++ .../zoomable/MultiGestureListener.java | 148 ++++ .../MultiZoomableControllerListener.java | 40 ++ .../zoomable/ZoomableController.java | 121 ++++ .../zoomable/ZoomableDraweeView.java | 400 +++++++++++ app/src/main/res/layout/activity_zoomable.xml | 17 + .../main/res/layout/fragment_media_detail.xml | 7 +- 16 files changed, 2295 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.java create mode 100644 app/src/main/res/layout/activity_zoomable.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3b3b427f9..9ec7a2cc1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,9 @@ android:excludeFromRecents="true" android:finishOnTaskLaunch="true" /> + + diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java index d25d5f7a0..0b4e37fce 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java @@ -1,8 +1,10 @@ package fr.free.nrw.commons.media; import android.annotation.SuppressLint; +import android.graphics.drawable.Animatable; import android.app.AlertDialog; import android.content.Intent; +import android.content.Context; import android.net.Uri; import android.os.Bundle; import android.text.Editable; @@ -23,7 +25,10 @@ import android.widget.Toast; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.controller.ControllerListener; import com.facebook.drawee.view.SimpleDraweeView; +import com.facebook.imagepipeline.image.ImageInfo; import com.facebook.imagepipeline.request.ImageRequest; import org.apache.commons.lang3.StringUtils; @@ -39,6 +44,7 @@ import javax.inject.Inject; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; +import androidx.annotation.Nullable; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.MediaDataExtractor; import fr.free.nrw.commons.R; @@ -97,10 +103,8 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { private int initialListTop = 0; - @BindView(R.id.mediaDetailImage) + @BindView(R.id.mediaDetailImageView) SimpleDraweeView image; - @BindView(R.id.mediaDetailSpacer) - MediaDetailSpacer spacer; @BindView(R.id.mediaDetailTitle) TextView title; @BindView(R.id.mediaDetailDesc) @@ -197,36 +201,18 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { // Progressively darken the image in the background when we scroll detail pane up scrollListener = this::updateTheDarkness; view.getViewTreeObserver().addOnScrollChangedListener(scrollListener); - - // Layout layoutListener to size the spacer item relative to the available space. - // There may be a .... better way to do this. - layoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { - private int currentHeight = -1; - - @Override - public void onGlobalLayout() { - int viewHeight = view.getHeight(); - //int textHeight = title.getLineHeight(); - int paddingDp = 112; - float paddingPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingDp, getResources().getDisplayMetrics()); - int newHeight = viewHeight - Math.round(paddingPx); - - if (newHeight != currentHeight) { - currentHeight = newHeight; - ViewGroup.LayoutParams params = spacer.getLayoutParams(); - params.height = newHeight; - spacer.setLayoutParams(params); - - scrollView.scrollTo(0, initialListTop); - } - } - }; - view.getViewTreeObserver().addOnGlobalLayoutListener(layoutListener); locale = getResources().getConfiguration().locale; - return view; } + @OnClick(R.id.mediaDetailImageView) + public void launchZoomActivity(View view) { + Context ctx = view.getContext(); + ctx.startActivity( + new Intent(ctx,ZoomableActivity.class).setData(Uri.parse(media.getImageUrl())) + ); + } + @Override public void onResume() { super.onResume(); @@ -255,6 +241,26 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { compositeDisposable.add(disposable); } + private void updateAspectRatio(ImageInfo imageInfo) { + if (imageInfo != null) { + int finalHeight = (scrollView.getWidth()*imageInfo.getHeight()) / imageInfo.getWidth(); + ViewGroup.LayoutParams params = image.getLayoutParams(); + params.height = finalHeight; + image.setLayoutParams(params); + } + } + + private final ControllerListener aspectRatioListener = new BaseControllerListener() { + @Override + public void onIntermediateImageSet(String id, @Nullable ImageInfo imageInfo) { + updateAspectRatio(imageInfo); + } + @Override + public void onFinalImageSet(String id, @Nullable ImageInfo imageInfo, @Nullable Animatable animatable) { + updateAspectRatio(imageInfo); + } + }; + /** * Uses two image sources. * - low resolution thumbnail is shown initially @@ -264,6 +270,7 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { DraweeController controller = Fresco.newDraweeControllerBuilder() .setLowResImageRequest(ImageRequest.fromUri(media.getThumbUrl())) .setImageRequest(ImageRequest.fromUri(media.getImageUrl())) + .setControllerListener(aspectRatioListener) .setOldController(image.getController()) .build(); image.setController(controller); diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.java b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.java new file mode 100644 index 000000000..34e7b8f6b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.java @@ -0,0 +1,64 @@ +package fr.free.nrw.commons.media; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.media.zoomControllers.zoomable.DoubleTapGestureListener; +import fr.free.nrw.commons.media.zoomControllers.zoomable.ZoomableDraweeView; +import timber.log.Timber; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Bundle; +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.interfaces.DraweeController; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.facebook.drawee.view.SimpleDraweeView; +import com.github.chrisbanes.photoview.PhotoView; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +public class ZoomableActivity extends AppCompatActivity { + private Uri imageUri; + + @BindView(R.id.zoomable) + ZoomableDraweeView photo; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + imageUri = getIntent().getData(); + if (null == imageUri) { + throw new IllegalArgumentException("No data to display"); + } + Timber.d("URl = " + imageUri); + + setContentView(R.layout.activity_zoomable); + ButterKnife.bind(this); + init(); + } + + private void init() { + if( imageUri != null ) { + photo.setAllowTouchInterceptionWhileZoomed(true); + photo.setIsLongpressEnabled(false); + photo.setTapListener(new DoubleTapGestureListener(photo)); + DraweeController controller = Fresco.newDraweeControllerBuilder() + .setUri(imageUri) + .build(); + photo.setController(controller); + } + } + + +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.java new file mode 100644 index 000000000..e91f47311 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.java @@ -0,0 +1,256 @@ +package fr.free.nrw.commons.media.zoomControllers.gestures; + +import android.view.MotionEvent; + +/** + * Component that detects and tracks multiple pointers based on touch events. + * + * 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 { + /** A callback called right before the gesture is about to start. */ + public void onGestureBegin(MultiPointerGestureDetector detector); + + /** A callback called each time the gesture gets updated. */ + public void onGestureUpdate(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 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; + + 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; + mPointerCount = 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; + } + + /** Starts a new gesture and calls the listener just before starting it. */ + private void startGesture() { + if (!mGestureInProgress) { + 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; + 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; + } + + /** 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); + } + } + } + + /** + * 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 + updatePointersOnMove(event); + // start a new gesture if not already started + if (!mGestureInProgress && mPointerCount > 0 && 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: + { + // restart gesture whenever the number of pointers changes + mNewPointerCount = getPressedPointerCount(event); + stopGesture(); + updatePointersOnTap(event); + if (mPointerCount > 0 && shouldStartGesture()) { + startGesture(); + } + break; + } + + case MotionEvent.ACTION_CANCEL: + { + mNewPointerCount = 0; + stopGesture(); + reset(); + break; + } + } + return true; + } + + /** Restarts the current gesture (if any). */ + 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 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 getPointerCount() { + return mPointerCount; + } + + /** + * 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/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.java new file mode 100644 index 000000000..3a6baeba2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.java @@ -0,0 +1,164 @@ +package fr.free.nrw.commons.media.zoomControllers.gestures; + +import android.view.MotionEvent; + +/** + * Component that detects translation, scale and rotation based on touch events. + * + * 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 { + /** A callback called right before the gesture is about to start. */ + public void onGestureBegin(TransformGestureDetector detector); + + /** A callback called each time the gesture gets updated. */ + public void onGestureUpdate(TransformGestureDetector detector); + + /** A callback called right after the gesture has finished. */ + public 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 (if any). */ + public void restartGesture() { + mDetector.restartGesture(); + } + + /** 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.getPointerCount()); + } + + /** Gets the Y coordinate of the pivot point */ + public float getPivotY() { + return calcAverage(mDetector.getStartY(), mDetector.getPointerCount()); + } + + /** Gets the X component of the translation */ + public float getTranslationX() { + 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.getPointerCount()) + - calcAverage(mDetector.getStartY(), mDetector.getPointerCount()); + } + + /** Gets the scale */ + public float getScale() { + if (mDetector.getPointerCount() < 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.getPointerCount() < 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; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.java new file mode 100644 index 000000000..700991b9e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.java @@ -0,0 +1,160 @@ +package fr.free.nrw.commons.media.zoomControllers.zoomable; + +import android.graphics.Matrix; +import android.graphics.PointF; +import com.facebook.common.logging.FLog; +import androidx.annotation.Nullable; +import fr.free.nrw.commons.media.zoomControllers.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. + * + *

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 mListeners = new ArrayList<>(); + + /** + * Adds a listener to the multi gesture listener. + * + *

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 mListeners = new ArrayList<>(); + + @Override + public synchronized void onTransformBegin(Matrix transform) { + for (ZoomableController.Listener listener : mListeners) { + listener.onTransformBegin(transform); + } + } + + @Override + public synchronized void onTransformChanged(Matrix transform) { + for (ZoomableController.Listener listener : mListeners) { + listener.onTransformChanged(transform); + } + } + + @Override + public synchronized void onTransformEnd(Matrix transform) { + for (ZoomableController.Listener listener : mListeners) { + listener.onTransformEnd(transform); + } + } + + public synchronized void addListener(ZoomableController.Listener listener) { + mListeners.add(listener); + } + + public synchronized void removeListener(ZoomableController.Listener listener) { + mListeners.remove(listener); + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.java b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.java new file mode 100644 index 000000000..7cf6c5d44 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.java @@ -0,0 +1,121 @@ +package fr.free.nrw.commons.media.zoomControllers.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. */ + interface Listener { + + /** + * Notifies the view that the transform began. + * + * @param transform the current transform matrix + */ + void onTransformBegin(Matrix transform); + + /** + * Notifies the view that the transform changed. + * + * @param transform the new matrix + */ + void onTransformChanged(Matrix transform); + + /** + * Notifies the view that the transform ended. + * + * @param transform the current transform matrix + */ + void onTransformEnd(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(); + + /** 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 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 + implements ScrollingView { + + 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 DraweeController mHugeImageController; + private ZoomableController mZoomableController; + private GestureDetector mTapGestureDetector; + private boolean mAllowTouchInterceptionWhileZoomed = true; + + private boolean mIsDialtoneEnabled = false; + private boolean mZoomingEnabled = true; + + private final ControllerListener mControllerListener = + new BaseControllerListener() { + @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 final ZoomableController.Listener mZoomableListener = + new ZoomableController.Listener() { + @Override + public void onTransformBegin(Matrix transform) {} + + @Override + public void onTransformChanged(Matrix transform) { + ZoomableDraweeView.this.onTransformChanged(transform); + } + + @Override + public void onTransformEnd(Matrix 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 = createZoomableController(); + mZoomableController.setListener(mZoomableListener); + mTapGestureDetector = new GestureDetector(getContext(), mTapListenerWrapper); + } + + public void setIsDialtoneEnabled(boolean isDialtoneEnabled) { + mIsDialtoneEnabled = isDialtoneEnabled; + } + + /** + * Gets the original image bounds, in view-absolute coordinates. + * + *

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. + */ + protected void getImageBounds(RectF outBounds) { + getHierarchy().getActualImageBounds(outBounds); + } + + /** + * Gets the bounds used to limit the translation, in view-absolute coordinates. + * + *

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. + * + *

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(mZoomableListener); + } + + /** + * Gets the zoomable controller. + * + *

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); + } + + public void setZoomingEnabled(boolean zoomingEnabled) { + mZoomingEnabled = zoomingEnabled; + mZoomableController.setEnabled(false); + } + + /** Sets the image controller. */ + @Override + public void setController(@Nullable DraweeController controller) { + setControllers(controller, null); + } + + /** + * Sets the controllers for the normal and huge image. + * + *

The huge image controller is used after the image gets scaled above a certain threshold. + * + *

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( + @Nullable DraweeController controller, @Nullable DraweeController hugeImageController) { + removeControllerListener(getController()); + addControllerListener(controller); + mHugeImageController = hugeImageController; + super.setController(controller); + } + + 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()); + try { + super.onDraw(canvas); + } catch (Exception e) { + DraweeController controller = getController(); + if (controller != null && controller instanceof AbstractDraweeController) { + Object callerContext = ((AbstractDraweeController) controller).getCallerContext(); + if (callerContext != null) { + throw new RuntimeException( + String.format("Exception in onDraw, callerContext=%s", callerContext.toString()), e); + } + } + throw e; + } + canvas.restoreToCount(saveCount); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + int a = event.getActionMasked(); + FLog.v(getLogTag(), "onTouchEvent: %d, view %x, received", a, this.hashCode()); + if (!mIsDialtoneEnabled && mTapGestureDetector.onTouchEvent(event)) { + FLog.v( + getLogTag(), + "onTouchEvent: %d, view %x, handled by tap gesture detector", + a, + this.hashCode()); + return true; + } + + if (!mIsDialtoneEnabled && mZoomableController.onTouchEvent(event)) { + FLog.v( + getLogTag(), + "onTouchEvent: %d, view %x, handled by zoomable controller", + a, + this.hashCode()); + if (!mAllowTouchInterceptionWhileZoomed && !mZoomableController.isIdentity()) { + getParent().requestDisallowInterceptTouchEvent(true); + } + 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 components 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(getLogTag(), "onLayout: view %x", this.hashCode()); + super.onLayout(changed, left, top, right, bottom); + updateZoomableControllerBounds(); + } + + private void onFinalImageSet() { + FLog.v(getLogTag(), "onFinalImageSet: view %x", this.hashCode()); + if (!mZoomableController.isEnabled() && mZoomingEnabled) { + mZoomableController.setEnabled(true); + updateZoomableControllerBounds(); + } + } + + private void onRelease() { + FLog.v(getLogTag(), "onRelease: view %x", this.hashCode()); + mZoomableController.setEnabled(false); + } + + protected void onTransformChanged(Matrix transform) { + FLog.v(getLogTag(), "onTransformChanged: view %x, transform: %s", this.hashCode(), transform); + maybeSetHugeImageController(); + invalidate(); + } + + protected void updateZoomableControllerBounds() { + getImageBounds(mImageBounds); + getLimitBounds(mViewBounds); + mZoomableController.setImageBounds(mImageBounds); + mZoomableController.setViewBounds(mViewBounds); + FLog.v( + 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(); + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_zoomable.xml b/app/src/main/res/layout/activity_zoomable.xml new file mode 100644 index 000000000..5f1e73945 --- /dev/null +++ b/app/src/main/res/layout/activity_zoomable.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_media_detail.xml b/app/src/main/res/layout/fragment_media_detail.xml index 5c325aaaf..1cdcccaa5 100644 --- a/app/src/main/res/layout/fragment_media_detail.xml +++ b/app/src/main/res/layout/fragment_media_detail.xml @@ -40,10 +40,11 @@ - + android:layout_height="@dimen/dimen_250" + app:actualImageScaleType="none" />