diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt index 3b72982f1e..de65e317a3 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt @@ -272,7 +272,7 @@ class ZoomableActivity : BaseActivity() { * Handles down swipe action */ private fun onDownSwiped() { - if (binding.zoomable.zoomableController?.isIdentity == false) { + if (!binding.zoomable.getZoomableController().isIdentity()) { return } @@ -342,7 +342,7 @@ class ZoomableActivity : BaseActivity() { * Handles up swipe action */ private fun onUpSwiped() { - if (binding.zoomable.zoomableController?.isIdentity == false) { + if (!binding.zoomable.getZoomableController().isIdentity()) { return } @@ -415,7 +415,7 @@ class ZoomableActivity : BaseActivity() { * Handles right swipe action */ private fun onRightSwiped(showAlreadyActionedImages: Boolean) { - if (binding.zoomable.zoomableController?.isIdentity == false) { + if (!binding.zoomable.getZoomableController().isIdentity()) { return } @@ -452,7 +452,7 @@ class ZoomableActivity : BaseActivity() { * Handles left swipe action */ private fun onLeftSwiped(showAlreadyActionedImages: Boolean) { - if (binding.zoomable.zoomableController?.isIdentity == false) { + if (!binding.zoomable.getZoomableController().isIdentity()) { return } 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 deleted file mode 100644 index e91f47311e..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.java +++ /dev/null @@ -1,256 +0,0 @@ -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/MultiPointerGestureDetector.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.kt new file mode 100644 index 0000000000..84dccfc073 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.kt @@ -0,0 +1,252 @@ +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. + */ +open class MultiPointerGestureDetector { + + /** The listener for receiving notifications when gestures occur. */ + interface Listener { + /** A callback called right before the gesture is about to start. */ + fun onGestureBegin(detector: MultiPointerGestureDetector) + + /** A callback called each time the gesture gets updated. */ + fun onGestureUpdate(detector: MultiPointerGestureDetector) + + /** A callback called right after the gesture has finished. */ + fun onGestureEnd(detector: MultiPointerGestureDetector) + } + + companion object { + private const val MAX_POINTERS = 2 + + /** Factory method that creates a new instance of MultiPointerGestureDetector */ + fun newInstance(): MultiPointerGestureDetector { + return MultiPointerGestureDetector() + } + } + + private var mGestureInProgress = false + private var mPointerCount = 0 + private var mNewPointerCount = 0 + private val mId = IntArray(MAX_POINTERS) { MotionEvent.INVALID_POINTER_ID } + private val mStartX = FloatArray(MAX_POINTERS) + private val mStartY = FloatArray(MAX_POINTERS) + private val mCurrentX = FloatArray(MAX_POINTERS) + private val mCurrentY = FloatArray(MAX_POINTERS) + + private var mListener: Listener? = null + + init { + reset() + } + + /** + * Sets the listener. + * + * @param listener listener to set + */ + fun setListener(listener: Listener?) { + mListener = listener + } + + /** Resets the component to the initial state. */ + fun reset() { + mGestureInProgress = false + mPointerCount = 0 + for (i in 0 until MAX_POINTERS) { + 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 open fun shouldStartGesture(): Boolean { + return true + } + + /** Starts a new gesture and calls the listener just before starting it. */ + private fun startGesture() { + if (!mGestureInProgress) { + mListener?.onGestureBegin(this) + mGestureInProgress = true + } + } + + /** Stops the current gesture and calls the listener right after stopping it. */ + private fun stopGesture() { + if (mGestureInProgress) { + mGestureInProgress = false + 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 fun getPressedPointerIndex(event: MotionEvent, i: Int): Int { + val count = event.pointerCount + val action = event.actionMasked + val index = event.actionIndex + var adjustedIndex = i + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { + if (adjustedIndex >= index) { + adjustedIndex++ + } + } + return if (adjustedIndex < count) adjustedIndex else -1 + } + + /** Gets the number of pressed pointers (fingers down). */ + private fun getPressedPointerCount(event: MotionEvent): Int { + var count = event.pointerCount + val action = event.actionMasked + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { + count-- + } + return count + } + + private fun updatePointersOnTap(event: MotionEvent) { + mPointerCount = 0 + for (i in 0 until MAX_POINTERS) { + val index = getPressedPointerIndex(event, i) + if (index == -1) { + mId[i] = MotionEvent.INVALID_POINTER_ID + } else { + mId[i] = event.getPointerId(index) + mCurrentX[i] = event.getX(index) + mStartX[i] = mCurrentX[i] + mCurrentY[i] = event.getY(index) + mStartY[i] = mCurrentY[i] + mPointerCount++ + } + } + } + + private fun updatePointersOnMove(event: MotionEvent) { + for (i in 0 until MAX_POINTERS) { + val 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 + */ + fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + 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?.onGestureUpdate(this) + } + } + + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> { + // restart gesture whenever the number of pointers changes + mNewPointerCount = getPressedPointerCount(event) + stopGesture() + updatePointersOnTap(event) + if (mPointerCount > 0 && shouldStartGesture()) { + startGesture() + } + } + + MotionEvent.ACTION_CANCEL -> { + mNewPointerCount = 0 + stopGesture() + reset() + } + } + return true + } + + /** Restarts the current gesture (if any). */ + fun restartGesture() { + if (!mGestureInProgress) { + return + } + stopGesture() + for (i in 0 until MAX_POINTERS) { + mStartX[i] = mCurrentX[i] + mStartY[i] = mCurrentY[i] + } + startGesture() + } + + /** Gets whether there is a gesture in progress */ + fun isGestureInProgress(): Boolean { + return mGestureInProgress + } + + /** Gets the number of pointers after the current gesture */ + fun getNewPointerCount(): Int { + return mNewPointerCount + } + + /** Gets the number of pointers in the current gesture */ + fun getPointerCount(): Int { + return mPointerCount + } + + /** + * Gets the start X coordinates for all pointers Mutable array is exposed for performance + * reasons and is not to be modified by the callers. + */ + fun getStartX(): FloatArray { + return mStartX + } + + /** + * Gets the start Y coordinates for all pointers Mutable array is exposed for performance + * reasons and is not to be modified by the callers. + */ + fun getStartY(): FloatArray { + return mStartY + } + + /** + * Gets the current X coordinates for all pointers Mutable array is exposed for performance + * reasons and is not to be modified by the callers. + */ + fun getCurrentX(): FloatArray { + return mCurrentX + } + + /** + * Gets the current Y coordinates for all pointers Mutable array is exposed for performance + * reasons and is not to be modified by the callers. + */ + fun getCurrentY(): FloatArray { + 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 deleted file mode 100644 index 3a6baeba2e..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.java +++ /dev/null @@ -1,164 +0,0 @@ -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/gestures/TransformGestureDetector.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.kt new file mode 100644 index 0000000000..9dd0b5813f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.kt @@ -0,0 +1,154 @@ +package fr.free.nrw.commons.media.zoomControllers.gestures + +import android.view.MotionEvent +import kotlin.math.atan2 +import kotlin.math.hypot + +/** + * 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. + */ +class TransformGestureDetector(private val mDetector: MultiPointerGestureDetector) : + MultiPointerGestureDetector.Listener { + + /** The listener for receiving notifications when gestures occur. */ + interface Listener { + /** A callback called right before the gesture is about to start. */ + fun onGestureBegin(detector: TransformGestureDetector) + + /** A callback called each time the gesture gets updated. */ + fun onGestureUpdate(detector: TransformGestureDetector) + + /** A callback called right after the gesture has finished. */ + fun onGestureEnd(detector: TransformGestureDetector) + } + + private var mListener: Listener? = null + + init { + mDetector.setListener(this) + } + + /** Factory method that creates a new instance of TransformGestureDetector */ + companion object { + fun newInstance(): TransformGestureDetector { + return TransformGestureDetector(MultiPointerGestureDetector.newInstance()) + } + } + + /** + * Sets the listener. + * + * @param listener listener to set + */ + fun setListener(listener: Listener?) { + mListener = listener + } + + /** Resets the component to the initial state. */ + fun reset() { + mDetector.reset() + } + + /** + * Handles the given motion event. + * + * @param event event to handle + * @return whether or not the event was handled + */ + fun onTouchEvent(event: MotionEvent): Boolean { + return mDetector.onTouchEvent(event) + } + + override fun onGestureBegin(detector: MultiPointerGestureDetector) { + mListener?.onGestureBegin(this) + } + + override fun onGestureUpdate(detector: MultiPointerGestureDetector) { + mListener?.onGestureUpdate(this) + } + + override fun onGestureEnd(detector: MultiPointerGestureDetector) { + mListener?.onGestureEnd(this) + } + + private fun calcAverage(arr: FloatArray, len: Int): Float { + val sum = arr.take(len).sum() + return if (len > 0) sum / len else 0f + } + + /** Restarts the current gesture (if any). */ + fun restartGesture() { + mDetector.restartGesture() + } + + /** Gets whether there is a gesture in progress */ + fun isGestureInProgress(): Boolean { + return mDetector.isGestureInProgress() + } + + /** Gets the number of pointers after the current gesture */ + fun getNewPointerCount(): Int { + return mDetector.getNewPointerCount() + } + + /** Gets the number of pointers in the current gesture */ + fun getPointerCount(): Int { + return mDetector.getPointerCount() + } + + /** Gets the X coordinate of the pivot point */ + fun getPivotX(): Float { + return calcAverage(mDetector.getStartX(), mDetector.getPointerCount()) + } + + /** Gets the Y coordinate of the pivot point */ + fun getPivotY(): Float { + return calcAverage(mDetector.getStartY(), mDetector.getPointerCount()) + } + + /** Gets the X component of the translation */ + fun getTranslationX(): Float { + return calcAverage(mDetector.getCurrentX(), mDetector.getPointerCount()) - + calcAverage(mDetector.getStartX(), mDetector.getPointerCount()) + } + + /** Gets the Y component of the translation */ + fun getTranslationY(): Float { + return calcAverage(mDetector.getCurrentY(), mDetector.getPointerCount()) - + calcAverage(mDetector.getStartY(), mDetector.getPointerCount()) + } + + /** Gets the scale */ + fun getScale(): Float { + return if (mDetector.getPointerCount() < 2) { + 1f + } else { + val startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0] + val startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0] + val currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0] + val currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0] + val startDist = hypot(startDeltaX, startDeltaY) + val currentDist = hypot(currentDeltaX, currentDeltaY) + currentDist / startDist + } + } + + /** Gets the rotation in radians */ + fun getRotation(): Float { + return if (mDetector.getPointerCount() < 2) { + 0f + } else { + val startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0] + val startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0] + val currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0] + val currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0] + val startAngle = atan2(startDeltaY, startDeltaX) + val currentAngle = atan2(currentDeltaY, currentDeltaX) + currentAngle - startAngle + } + } +} 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 deleted file mode 100644 index 700991b9e0..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.java +++ /dev/null @@ -1,160 +0,0 @@ -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/AbstractAnimatedZoomableController.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.kt new file mode 100644 index 0000000000..27aa7fe2f6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.kt @@ -0,0 +1,147 @@ +package fr.free.nrw.commons.media.zoomControllers.zoomable + +import android.graphics.Matrix +import android.graphics.PointF +import com.facebook.common.logging.FLog +import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector + +/** + * Abstract class for ZoomableController that adds animation capabilities to + * DefaultZoomableController. + */ +abstract class AbstractAnimatedZoomableController( + transformGestureDetector: TransformGestureDetector +) : DefaultZoomableController(transformGestureDetector) { + + private var isAnimating: Boolean = false + private val startValues = FloatArray(9) + private val stopValues = FloatArray(9) + private val currentValues = FloatArray(9) + private val newTransform = Matrix() + private val workingTransform = Matrix() + + override fun reset() { + FLog.v(logTag, "reset") + stopAnimation() + workingTransform.reset() + newTransform.reset() + super.reset() + } + + /** Returns true if the zoomable transform is identity matrix, and the controller is idle. */ + override fun isIdentity(): Boolean { + 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 fun zoomToPoint(scale: Float, imagePoint: PointF, viewPoint: PointF) { + 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 + */ + fun zoomToPoint( + scale: Float, + imagePoint: PointF, + viewPoint: PointF, + @LimitFlag limitFlags: Int, + durationMs: Long, + onAnimationComplete: Runnable? + ) { + FLog.v(logTag, "zoomToPoint: duration $durationMs ms") + calculateZoomToPointTransform(newTransform, scale, imagePoint, viewPoint, limitFlags) + setTransform(newTransform, 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 + */ + private fun setTransform( + newTransform: Matrix, + durationMs: Long, + onAnimationComplete: Runnable? + ) { + FLog.v(logTag, "setTransform: duration $durationMs ms") + if (durationMs <= 0) { + setTransformImmediate(newTransform) + } else { + setTransformAnimated(newTransform, durationMs, onAnimationComplete) + } + } + + private fun setTransformImmediate(newTransform: Matrix) { + FLog.v(logTag, "setTransformImmediate") + stopAnimation() + workingTransform.set(newTransform) + super.setTransform(newTransform) + getDetector().restartGesture() + } + + protected fun getIsAnimating(): Boolean = isAnimating + + protected fun setAnimating(isAnimating: Boolean) { + this.isAnimating = isAnimating + } + + protected fun getStartValues(): FloatArray = startValues + + protected fun getStopValues(): FloatArray = stopValues + + protected fun getWorkingTransform(): Matrix = workingTransform + + override fun onGestureBegin(detector: TransformGestureDetector) { + FLog.v(logTag, "onGestureBegin") + stopAnimation() + super.onGestureBegin(detector) + } + + override fun onGestureUpdate(detector: TransformGestureDetector) { + FLog.v(logTag, "onGestureUpdate ${if (isAnimating) "(ignored)" else ""}") + if (isAnimating) return + super.onGestureUpdate(detector) + } + + protected fun calculateInterpolation(outMatrix: Matrix, fraction: Float) { + for (i in 0..8) { + currentValues[i] = (1 - fraction) * startValues[i] + fraction * stopValues[i] + } + outMatrix.setValues(currentValues) + } + + abstract fun setTransformAnimated( + newTransform: Matrix, durationMs: Long, onAnimationComplete: Runnable? + ) + + protected abstract fun stopAnimation() + + protected abstract val logTag: Class<*> +} 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 deleted file mode 100644 index 471ceb973d..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.java +++ /dev/null @@ -1,96 +0,0 @@ -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/AnimatedZoomableController.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.kt new file mode 100644 index 0000000000..6ba9270078 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.kt @@ -0,0 +1,75 @@ +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.logging.FLog +import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector + +/** + * ZoomableController that adds animation capabilities to DefaultZoomableController using standard + * Android animation classes + */ +class AnimatedZoomableController private constructor() : + AbstractAnimatedZoomableController(TransformGestureDetector.newInstance()) { + + private val valueAnimator: ValueAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + interpolator = DecelerateInterpolator() + } + + companion object { + fun newInstance(): AnimatedZoomableController { + return AnimatedZoomableController() + } + } + + @SuppressLint("NewApi") + override fun setTransformAnimated( + newTransform: Matrix, durationMs: Long, onAnimationComplete: Runnable? + ) { + FLog.v(logTag, "setTransformAnimated: duration $durationMs ms") + stopAnimation() + require(durationMs > 0) { "Duration must be greater than zero" } + check(!getIsAnimating()) { "Animation is already in progress" } + setAnimating(true) + valueAnimator.duration = durationMs + getTransform().getValues(getStartValues()) + newTransform.getValues(getStopValues()) + valueAnimator.addUpdateListener { animator -> + calculateInterpolation(getWorkingTransform(), animator.animatedValue as Float) + super.setTransform(getWorkingTransform()) + } + valueAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationCancel(animation: Animator) { + FLog.v(logTag, "setTransformAnimated: animation cancelled") + onAnimationStopped() + } + + override fun onAnimationEnd(animation: Animator) { + FLog.v(logTag, "setTransformAnimated: animation finished") + onAnimationStopped() + } + + private fun onAnimationStopped() { + onAnimationComplete?.run() + setAnimating(false) + getDetector().restartGesture() + } + }) + valueAnimator.start() + } + + @SuppressLint("NewApi") + override fun stopAnimation() { + if (!getIsAnimating()) return + FLog.v(logTag, "stopAnimation") + valueAnimator.cancel() + valueAnimator.removeAllUpdateListeners() + valueAnimator.removeAllListeners() + } + + override val logTag: Class<*> = AnimatedZoomableController::class.java +} 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 deleted file mode 100644 index 9c3538f05d..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.java +++ /dev/null @@ -1,646 +0,0 @@ -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/DefaultZoomableController.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.kt new file mode 100644 index 0000000000..3d6498f6a2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.kt @@ -0,0 +1,607 @@ +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 com.facebook.common.logging.FLog +import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector +import kotlin.math.abs + +/** Zoomable controller that calculates transformation based on touch events. */ +open class DefaultZoomableController( + private val mGestureDetector: TransformGestureDetector +) : ZoomableController, TransformGestureDetector.Listener { + + /** Interface for handling call backs when the image bounds are set. */ + fun interface ImageBoundsListener { + fun onImageBoundsSet(imageBounds: RectF) + } + + @IntDef( + flag = true, + value = [LIMIT_NONE, LIMIT_TRANSLATION_X, LIMIT_TRANSLATION_Y, LIMIT_SCALE, LIMIT_ALL] + ) + @Retention + annotation class LimitFlag + + companion object { + const val LIMIT_NONE = 0 + const val LIMIT_TRANSLATION_X = 1 + const val LIMIT_TRANSLATION_Y = 2 + const val LIMIT_SCALE = 4 + const val LIMIT_ALL = LIMIT_TRANSLATION_X or LIMIT_TRANSLATION_Y or LIMIT_SCALE + + private const val EPS = 1e-3f + + private val TAG: Class<*> = DefaultZoomableController::class.java + + private val IDENTITY_RECT = RectF(0f, 0f, 1f, 1f) + + fun newInstance(): DefaultZoomableController { + return DefaultZoomableController(TransformGestureDetector.newInstance()) + } + } + + private var mImageBoundsListener: ImageBoundsListener? = null + + private var mListener: ZoomableController.Listener? = null + + private var mIsEnabled = false + private var mIsRotationEnabled = false + private var mIsScaleEnabled = true + private var mIsTranslationEnabled = true + private var mIsGestureZoomEnabled = true + + private var mMinScaleFactor = 1.0f + private var mMaxScaleFactor = 2.0f + + // View bounds, in view-absolute coordinates + private val mViewBounds = RectF() + // Non-transformed image bounds, in view-absolute coordinates + private val mImageBounds = RectF() + // Transformed image bounds, in view-absolute coordinates + private val mTransformedImageBounds = RectF() + + private val mPreviousTransform = Matrix() + private val mActiveTransform = Matrix() + private val mActiveTransformInverse = Matrix() + private val mTempValues = FloatArray(9) + private val mTempRect = RectF() + private var mWasTransformCorrected = false + + init { + mGestureDetector.setListener(this) + } + + /** Rests the controller. */ + open fun reset() { + FLog.v(TAG, "reset") + mGestureDetector.reset() + mPreviousTransform.reset() + mActiveTransform.reset() + onTransformChanged() + } + + /** Sets the zoomable listener. */ + override fun setListener(listener: ZoomableController.Listener?) { + mListener = listener + } + + /** Sets whether the controller is enabled or not. */ + override fun setEnabled(enabled: Boolean) { + mIsEnabled = enabled + if (!enabled) { + reset() + } + } + + /** Gets whether the controller is enabled or not. */ + override fun isEnabled(): Boolean { + return mIsEnabled + } + + /** Sets whether the rotation gesture is enabled or not. */ + fun setRotationEnabled(enabled: Boolean) { + mIsRotationEnabled = enabled + } + + /** Gets whether the rotation gesture is enabled or not. */ + fun isRotationEnabled(): Boolean { + return mIsRotationEnabled + } + + /** Sets whether the scale gesture is enabled or not. */ + fun setScaleEnabled(enabled: Boolean) { + mIsScaleEnabled = enabled + } + + /** Gets whether the scale gesture is enabled or not. */ + fun isScaleEnabled(): Boolean { + return mIsScaleEnabled + } + + /** Sets whether the translation gesture is enabled or not. */ + fun setTranslationEnabled(enabled: Boolean) { + mIsTranslationEnabled = enabled + } + + /** Gets whether the translations gesture is enabled or not. */ + fun isTranslationEnabled(): Boolean { + return mIsTranslationEnabled + } + + /** + * Sets the minimum scale factor allowed. + * + *

Hierarchy's scaling (if any) is not taken into account. + */ + fun setMinScaleFactor(minScaleFactor: Float) { + mMinScaleFactor = minScaleFactor + } + + /** Gets the minimum scale factor allowed. */ + fun getMinScaleFactor(): Float { + return mMinScaleFactor + } + + /** + * Sets the maximum scale factor allowed. + * + *

Hierarchy's scaling (if any) is not taken into account. + */ + fun setMaxScaleFactor(maxScaleFactor: Float) { + mMaxScaleFactor = maxScaleFactor + } + + /** Gets the maximum scale factor allowed. */ + fun getMaxScaleFactor(): Float { + return mMaxScaleFactor + } + + /** Sets whether gesture zooms are enabled or not. */ + fun setGestureZoomEnabled(isGestureZoomEnabled: Boolean) { + mIsGestureZoomEnabled = isGestureZoomEnabled + } + + /** Gets whether gesture zooms are enabled or not. */ + fun isGestureZoomEnabled(): Boolean { + return mIsGestureZoomEnabled + } + + /** Gets the current scale factor. */ + override fun getScaleFactor(): Float { + return getMatrixScaleFactor(mActiveTransform) + } + + /** Sets the image bounds, in view-absolute coordinates. */ + override fun setImageBounds(imageBounds: RectF) { + if (imageBounds != mImageBounds) { + mImageBounds.set(imageBounds) + onTransformChanged() + mImageBoundsListener?.onImageBoundsSet(mImageBounds) + } + } + + /** Gets the non-transformed image bounds, in view-absolute coordinates. */ + fun getImageBounds(): RectF { + return mImageBounds + } + + /** Gets the transformed image bounds, in view-absolute coordinates */ + private fun getTransformedImageBounds(): RectF { + return mTransformedImageBounds + } + + /** Sets the view bounds. */ + override fun setViewBounds(viewBounds: RectF) { + mViewBounds.set(viewBounds) + } + + /** Gets the view bounds. */ + fun getViewBounds(): RectF { + return mViewBounds + } + + /** Sets the image bounds listener. */ + fun setImageBoundsListener(imageBoundsListener: ImageBoundsListener?) { + mImageBoundsListener = imageBoundsListener + } + + /** Gets the image bounds listener. */ + fun getImageBoundsListener(): ImageBoundsListener? { + return mImageBoundsListener + } + + /** Returns true if the zoomable transform is identity matrix. */ + override fun isIdentity(): Boolean { + 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 fun wasTransformCorrected(): Boolean { + 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 fun getTransform(): Matrix { + return mActiveTransform + } + + /** + * Gets the matrix that transforms image-relative coordinates to view-absolute coordinates. The + * zoomable transformation is taken into account. + */ + fun getImageRelativeToViewAbsoluteTransform(outMatrix: Matrix) { + outMatrix.setRectToRect(IDENTITY_RECT, mTransformedImageBounds, Matrix.ScaleToFit.FILL) + } + + /** + * Maps point from view-absolute to image-relative coordinates. This takes into account the + * zoomable transformation. + */ + fun mapViewToImage(viewPoint: PointF): PointF { + val 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 PointF(points[0], points[1]) + } + + /** + * Maps point from image-relative to view-absolute coordinates. This takes into account the + * zoomable transformation. + */ + fun mapImageToView(imagePoint: PointF): PointF { + val points = mTempValues + points[0] = imagePoint.x + points[1] = imagePoint.y + mapRelativeToAbsolute(points, points, 1) + mActiveTransform.mapPoints(points, 0, points, 0, 1) + return 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 fun mapAbsoluteToRelative( + destPoints: FloatArray, + srcPoints: FloatArray, + numPoints: Int + ) { + for (i in 0 until numPoints) { + destPoints[i * 2] = (srcPoints[i * 2] - 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 fun mapRelativeToAbsolute( + destPoints: FloatArray, + srcPoints: FloatArray, + numPoints: Int + ) { + for (i in 0 until numPoints) { + destPoints[i * 2] = srcPoints[i * 2] * 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 + */ + open fun zoomToPoint(scale: Float, imagePoint: PointF, viewPoint: PointF) { + 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 fun calculateZoomToPointTransform( + outTransform: Matrix, + scale: Float, + imagePoint: PointF, + viewPoint: PointF, + @LimitFlag limitFlags: Int + ): Boolean { + val viewAbsolute = mTempValues + viewAbsolute[0] = imagePoint.x + viewAbsolute[1] = imagePoint.y + mapRelativeToAbsolute(viewAbsolute, viewAbsolute, 1) + val distanceX = viewPoint.x - viewAbsolute[0] + val distanceY = viewPoint.y - viewAbsolute[1] + var transformCorrected = false + outTransform.setScale(scale, scale, viewAbsolute[0], viewAbsolute[1]) + transformCorrected = transformCorrected or + limitScale(outTransform, viewAbsolute[0], viewAbsolute[1], limitFlags) + outTransform.postTranslate(distanceX, distanceY) + transformCorrected = transformCorrected or limitTranslation(outTransform, limitFlags) + return transformCorrected + } + + /** Sets a new zoom transformation. */ + fun setTransform(newTransform: Matrix) { + FLog.v(TAG, "setTransform") + mActiveTransform.set(newTransform) + onTransformChanged() + } + + /** Gets the gesture detector. */ + protected fun getDetector(): TransformGestureDetector { + return mGestureDetector + } + + /** Notifies controller of the received touch event. */ + override fun onTouchEvent(event: MotionEvent): Boolean { + FLog.v(TAG, "onTouchEvent: action: ", event.action) + return if (mIsEnabled && mIsGestureZoomEnabled) { + mGestureDetector.onTouchEvent(event) + } else { + false + } + } + + /* TransformGestureDetector.Listener methods */ + + override fun onGestureBegin(detector: TransformGestureDetector) { + FLog.v(TAG, "onGestureBegin") + mPreviousTransform.set(mActiveTransform) + onTransformBegin() + mWasTransformCorrected = !canScrollInAllDirection() + } + + override fun onGestureUpdate(detector: TransformGestureDetector) { + FLog.v(TAG, "onGestureUpdate") + val transformCorrected = calculateGestureTransform(mActiveTransform, LIMIT_ALL) + onTransformChanged() + if (transformCorrected) { + mGestureDetector.restartGesture() + } + mWasTransformCorrected = transformCorrected + } + + override fun onGestureEnd(detector: TransformGestureDetector) { + 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 + */ + private fun calculateGestureTransform( + outTransform: Matrix, + @LimitFlag limitTypes: Int + ): Boolean { + val detector = mGestureDetector + var transformCorrected = false + outTransform.set(mPreviousTransform) + if (mIsRotationEnabled) { + val angle = detector.getRotation() * (180 / Math.PI).toFloat() + outTransform.postRotate(angle, detector.getPivotX(), detector.getPivotY()) + } + if (mIsScaleEnabled) { + val scale = detector.getScale() + outTransform.postScale(scale, scale, detector.getPivotX(), detector.getPivotY()) + } + transformCorrected = transformCorrected or limitScale( + outTransform, + detector.getPivotX(), + detector.getPivotY(), + limitTypes + ) + if (mIsTranslationEnabled) { + outTransform.postTranslate(detector.getTranslationX(), detector.getTranslationY()) + } + transformCorrected = transformCorrected or limitTranslation(outTransform, limitTypes) + return transformCorrected + } + + private fun onTransformBegin() { + if (mListener != null && isEnabled()) { + mListener?.onTransformBegin(mActiveTransform) + } + } + + private fun onTransformChanged() { + mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds) + if (mListener != null && isEnabled()) { + mListener?.onTransformChanged(mActiveTransform) + } + } + + private fun 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 fun limitScale( + transform: Matrix, pivotX: Float, pivotY: Float, @LimitFlag limitTypes: Int + ): Boolean { + if (!shouldLimit(limitTypes, LIMIT_SCALE)) { + return false + } + val currentScale = getMatrixScaleFactor(transform) + val targetScale = limit(currentScale, mMinScaleFactor, mMaxScaleFactor) + return if (targetScale != currentScale) { + val scale = targetScale / currentScale + transform.postScale(scale, scale, pivotX, pivotY) + true + } else { + false + } + } + + /** + * Limits the translation so that there are no empty spaces on the sides if possible. + */ + private fun limitTranslation(transform: Matrix, @LimitFlag limitTypes: Int): Boolean { + if (!shouldLimit(limitTypes, LIMIT_TRANSLATION_X or LIMIT_TRANSLATION_Y)) { + return false + } + val b = mTempRect + b.set(mImageBounds) + transform.mapRect(b) + val offsetLeft = + if (shouldLimit(limitTypes, LIMIT_TRANSLATION_X)) getOffset( + b.left, b.right, mViewBounds.left, mViewBounds.right, mImageBounds.centerX() + ) else 0f + val offsetTop = + if (shouldLimit(limitTypes, LIMIT_TRANSLATION_Y)) getOffset( + b.top, b.bottom, mViewBounds.top, mViewBounds.bottom, mImageBounds.centerY() + ) else 0f + + return if (offsetLeft != 0f || offsetTop != 0f) { + transform.postTranslate(offsetLeft, offsetTop) + true + } else { + false + } + } + + /** + * Checks whether the specified limit flag is present in the limits provided. + */ + private fun shouldLimit(@LimitFlag limits: Int, @LimitFlag flag: Int): Boolean { + return (limits and flag) != LIMIT_NONE + } + + /** + * Returns the offset necessary to make sure that: + * - The image is centered if it's smaller than the limit + * - There is no empty space if the image is bigger than the limit + */ + private fun getOffset( + imageStart: Float, imageEnd: Float, limitStart: Float, limitEnd: Float, limitCenter: Float + ): Float { + val imageWidth = imageEnd - imageStart + val limitWidth = limitEnd - limitStart + val limitInnerWidth = minOf(limitCenter - limitStart, limitEnd - limitCenter) * 2 + + return when { + imageWidth < limitInnerWidth -> limitCenter - (imageEnd + imageStart) / 2 + imageWidth < limitWidth -> if (limitCenter < (limitStart + limitEnd) / 2) { + limitStart - imageStart + } else { + limitEnd - imageEnd + } + imageStart > limitStart -> limitStart - imageStart + imageEnd < limitEnd -> limitEnd - imageEnd + else -> 0f + } + } + + /** Limits the value to the given min and max range. */ + private fun limit(value: Float, min: Float, max: Float): Float { + return min.coerceAtLeast(value).coerceAtMost(max) + } + + /** + * Gets the scale factor for the given matrix. Assumes equal scaling for X and Y axis. + */ + private fun getMatrixScaleFactor(transform: Matrix): Float { + transform.getValues(mTempValues) + return mTempValues[Matrix.MSCALE_X] + } + + /** Checks if the matrix is an identity matrix within a given tolerance `eps`. */ + private fun isMatrixIdentity(transform: Matrix, eps: Float): Boolean { + transform.getValues(mTempValues) + mTempValues[0] -= 1.0f // m00 + mTempValues[4] -= 1.0f // m11 + mTempValues[8] -= 1.0f // m22 + return mTempValues.all { abs(it) <= eps } + } + + /** Returns whether the scroll can happen in all directions. */ + private fun canScrollInAllDirection(): Boolean { + return mTransformedImageBounds.left < mViewBounds.left - EPS && + mTransformedImageBounds.top < mViewBounds.top - EPS && + mTransformedImageBounds.right > mViewBounds.right + EPS && + mTransformedImageBounds.bottom > mViewBounds.bottom + EPS + } + + override fun computeHorizontalScrollRange(): Int { + return mTransformedImageBounds.width().toInt() + } + + override fun computeHorizontalScrollOffset(): Int { + return (mViewBounds.left - mTransformedImageBounds.left).toInt() + } + + override fun computeHorizontalScrollExtent(): Int { + return mViewBounds.width().toInt() + } + + override fun computeVerticalScrollRange(): Int { + return mTransformedImageBounds.height().toInt() + } + + override fun computeVerticalScrollOffset(): Int { + return (mViewBounds.top - mTransformedImageBounds.top).toInt() + } + + override fun computeVerticalScrollExtent(): Int { + return mViewBounds.height().toInt() + } + + fun getListener(): ZoomableController.Listener? { + return mListener + } +} 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 deleted file mode 100644 index 395b5c3881..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.java +++ /dev/null @@ -1,77 +0,0 @@ -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/DoubleTapGestureListener.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.kt new file mode 100644 index 0000000000..72082abf55 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.kt @@ -0,0 +1,85 @@ +package fr.free.nrw.commons.media.zoomControllers.zoomable + +import android.graphics.PointF +import android.view.GestureDetector +import android.view.MotionEvent +import kotlin.math.abs +import kotlin.math.hypot + +/** + * Tap gesture listener for double tap to zoom/unzoom and double-tap-and-drag to zoom. + * + * @see ZoomableDraweeView.setTapListener + */ +class DoubleTapGestureListener(private val draweeView: ZoomableDraweeView) : + GestureDetector.SimpleOnGestureListener() { + + companion object { + private const val DURATION_MS = 300L + private const val DOUBLE_TAP_SCROLL_THRESHOLD = 20 + } + + private val doubleTapViewPoint = PointF() + private val doubleTapImagePoint = PointF() + private var doubleTapScale = 1f + private var doubleTapScroll = false + + override fun onDoubleTapEvent(e: MotionEvent): Boolean { + val zc = draweeView.getZoomableController() as AbstractAnimatedZoomableController + val vp = PointF(e.x, e.y) + val ip = zc.mapViewToImage(vp) + + when (e.actionMasked) { + MotionEvent.ACTION_DOWN -> { + doubleTapViewPoint.set(vp) + doubleTapImagePoint.set(ip) + doubleTapScale = zc.getScaleFactor() + } + + MotionEvent.ACTION_MOVE -> { + doubleTapScroll = doubleTapScroll || shouldStartDoubleTapScroll(vp) + if (doubleTapScroll) { + val scale = calcScale(vp) + zc.zoomToPoint(scale, doubleTapImagePoint, doubleTapViewPoint) + } + } + + MotionEvent.ACTION_UP -> { + if (doubleTapScroll) { + val scale = calcScale(vp) + zc.zoomToPoint(scale, doubleTapImagePoint, doubleTapViewPoint) + } else { + val maxScale = zc.getMaxScaleFactor() + val minScale = zc.getMinScaleFactor() + val targetScale = + if (zc.getScaleFactor() < (maxScale + minScale) / 2) maxScale else minScale + + zc.zoomToPoint( + targetScale, + ip, + vp, + DefaultZoomableController.LIMIT_ALL, + DURATION_MS, + null + ) + } + doubleTapScroll = false + } + } + return true + } + + private fun shouldStartDoubleTapScroll(viewPoint: PointF): Boolean { + val dist = hypot( + (viewPoint.x - doubleTapViewPoint.x).toDouble(), + (viewPoint.y - doubleTapViewPoint.y).toDouble() + ) + return dist > DOUBLE_TAP_SCROLL_THRESHOLD + } + + private fun calcScale(currentViewPoint: PointF): Float { + val dy = currentViewPoint.y - doubleTapViewPoint.y + val t = 1 + abs(dy) * 0.001f + return if (dy < 0) doubleTapScale / t else doubleTapScale * t + } +} 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 deleted file mode 100644 index b8433de905..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.java +++ /dev/null @@ -1,63 +0,0 @@ -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/GestureListenerWrapper.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.kt new file mode 100644 index 0000000000..994e98cabf --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.kt @@ -0,0 +1,61 @@ +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. */ +class GestureListenerWrapper : GestureDetector.SimpleOnGestureListener() { + + private var delegate: GestureDetector.SimpleOnGestureListener = + GestureDetector.SimpleOnGestureListener() + + fun setListener(listener: GestureDetector.SimpleOnGestureListener) { + delegate = listener + } + + override fun onLongPress(e: MotionEvent) { + delegate.onLongPress(e) + } + + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + return delegate.onScroll(e1, e2, distanceX, distanceY) + } + + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + return delegate.onFling(e1, e2, velocityX, velocityY) + } + + override fun onShowPress(e: MotionEvent) { + delegate.onShowPress(e) + } + + override fun onDown(e: MotionEvent): Boolean { + return delegate.onDown(e) + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + return delegate.onDoubleTap(e) + } + + override fun onDoubleTapEvent(e: MotionEvent): Boolean { + return delegate.onDoubleTapEvent(e) + } + + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + return delegate.onSingleTapConfirmed(e) + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + return delegate.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 deleted file mode 100644 index 05be602a7e..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.java +++ /dev/null @@ -1,148 +0,0 @@ -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/MultiGestureListener.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.kt new file mode 100644 index 0000000000..70f7921c12 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.kt @@ -0,0 +1,150 @@ +package fr.free.nrw.commons.media.zoomControllers.zoomable + +import android.os.Build +import android.view.GestureDetector +import android.view.MotionEvent +import androidx.annotation.RequiresApi +import java.util.Collections.synchronizedList + +/** + * 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 [onLongPress] (the listener returned true), + * subsequent listeners will not be notified about the event anymore since it has been consumed. + */ +class MultiGestureListener : GestureDetector.SimpleOnGestureListener() { + + private val listeners: MutableList = + synchronizedList(mutableListOf()) + + /** + * 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 + */ + @Synchronized + fun addListener(listener: GestureDetector.SimpleOnGestureListener) { + listeners.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 + */ + @Synchronized + fun removeListener(listener: GestureDetector.SimpleOnGestureListener) { + listeners.remove(listener) + } + + @Synchronized + override fun onSingleTapUp(e: MotionEvent): Boolean { + for (listener in listeners) { + if (listener.onSingleTapUp(e)) { + return true + } + } + return false + } + + @Synchronized + override fun onLongPress(e: MotionEvent) { + for (listener in listeners) { + listener.onLongPress(e) + } + } + + @Synchronized + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + for (listener in listeners) { + if (listener.onScroll(e1, e2, distanceX, distanceY)) { + return true + } + } + return false + } + + @Synchronized + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + for (listener in listeners) { + if (listener.onFling(e1, e2, velocityX, velocityY)) { + return true + } + } + return false + } + + @Synchronized + override fun onShowPress(e: MotionEvent) { + for (listener in listeners) { + listener.onShowPress(e) + } + } + + @Synchronized + override fun onDown(e: MotionEvent): Boolean { + for (listener in listeners) { + if (listener.onDown(e)) { + return true + } + } + return false + } + + @Synchronized + override fun onDoubleTap(e: MotionEvent): Boolean { + for (listener in listeners) { + if (listener.onDoubleTap(e)) { + return true + } + } + return false + } + + @Synchronized + override fun onDoubleTapEvent(e: MotionEvent): Boolean { + for (listener in listeners) { + if (listener.onDoubleTapEvent(e)) { + return true + } + } + return false + } + + @Synchronized + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + for (listener in listeners) { + if (listener.onSingleTapConfirmed(e)) { + return true + } + } + return false + } + + @RequiresApi(Build.VERSION_CODES.M) + @Synchronized + override fun onContextClick(e: MotionEvent): Boolean { + for (listener in listeners) { + if (listener.onContextClick(e)) { + return true + } + } + return false + } +} \ No newline at end of file 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 deleted file mode 100644 index 33268ed293..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.java +++ /dev/null @@ -1,40 +0,0 @@ -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/MultiZoomableControllerListener.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.kt new file mode 100644 index 0000000000..3bd7c00b95 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.media.zoomControllers.zoomable + +import android.graphics.Matrix +import java.util.ArrayList + +/** + * MultiZoomableControllerListener that allows multiple listeners to be added and notified about + * transform events. + * + * NOTE: The order of the listeners is important. Listeners can consume transform events. + */ +class MultiZoomableControllerListener : ZoomableController.Listener { + + private val listeners: MutableList = mutableListOf() + + @Synchronized + override fun onTransformBegin(transform: Matrix) { + for (listener in listeners) { + listener.onTransformBegin(transform) + } + } + + @Synchronized + override fun onTransformChanged(transform: Matrix) { + for (listener in listeners) { + listener.onTransformChanged(transform) + } + } + + @Synchronized + override fun onTransformEnd(transform: Matrix) { + for (listener in listeners) { + listener.onTransformEnd(transform) + } + } + + @Synchronized + fun addListener(listener: ZoomableController.Listener) { + listeners.add(listener) + } + + @Synchronized + fun removeListener(listener: ZoomableController.Listener) { + listeners.remove(listener) + } +} 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.kt similarity index 63% rename from app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.java rename to app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.kt index 7cf6c5d441..503f671696 100644 --- 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.kt @@ -1,14 +1,14 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable; +package fr.free.nrw.commons.media.zoomControllers.zoomable -import android.graphics.Matrix; -import android.graphics.RectF; -import android.view.MotionEvent; +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 + * Interface for implementing a controller that works with [ZoomableDraweeView] to control the * zoom. */ -public interface ZoomableController { +interface ZoomableController { /** Listener interface. */ interface Listener { @@ -18,21 +18,21 @@ interface Listener { * * @param transform the current transform matrix */ - void onTransformBegin(Matrix transform); + fun onTransformBegin(transform: Matrix) /** * Notifies the view that the transform changed. * * @param transform the new matrix */ - void onTransformChanged(Matrix transform); + fun onTransformChanged(transform: Matrix) /** * Notifies the view that the transform ended. * * @param transform the current transform matrix */ - void onTransformEnd(Matrix transform); + fun onTransformEnd(transform: Matrix) } /** @@ -40,22 +40,21 @@ interface Listener { * * @param enabled whether to enable the controller */ - void setEnabled(boolean enabled); + fun setEnabled(enabled: Boolean) /** - * Gets whether the controller is enabled. This should return the last value passed to {@link - * #setEnabled}. - * + * Gets whether the controller is enabled. This should return the last value passed + * to [setEnabled]. * @return whether the controller is enabled. */ - boolean isEnabled(); + fun isEnabled(): Boolean /** * Sets the listener for the controller to call back when the matrix changes. * * @param listener the listener */ - void setListener(Listener listener); + fun setListener(listener: Listener?) /** * Gets the current scale factor. A convenience method for calculating the scale from the @@ -63,10 +62,10 @@ interface Listener { * * @return the current scale factor */ - float getScaleFactor(); + fun getScaleFactor(): Float /** Returns true if the zoomable transform is identity matrix, and the controller is idle. */ - boolean isIdentity(); + fun isIdentity(): Boolean /** * Returns true if the transform was corrected during the last update. @@ -74,27 +73,27 @@ interface Listener { *

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(); + fun wasTransformCorrected(): Boolean - /** See {@link androidx.core.view.ScrollingView}. */ - int computeHorizontalScrollRange(); + /** See [androidx.core.view.ScrollingView]. */ + fun computeHorizontalScrollRange(): Int - int computeHorizontalScrollOffset(); + fun computeHorizontalScrollOffset(): Int - int computeHorizontalScrollExtent(); + fun computeHorizontalScrollExtent(): Int - int computeVerticalScrollRange(); + fun computeVerticalScrollRange(): Int - int computeVerticalScrollOffset(); + fun computeVerticalScrollOffset(): Int - int computeVerticalScrollExtent(); + fun computeVerticalScrollExtent(): Int /** * Gets the current transform. * * @return the transform */ - Matrix getTransform(); + fun getTransform(): Matrix /** * Sets the bounds of the image post transform prior to application of the zoomable @@ -102,14 +101,14 @@ interface Listener { * * @param imageBounds the bounds of the image */ - void setImageBounds(RectF imageBounds); + fun setImageBounds(imageBounds: RectF) /** * Sets the bounds of the view. * * @param viewBounds the bounds of the view */ - void setViewBounds(RectF viewBounds); + fun setViewBounds(viewBounds: RectF) /** * Allows the controller to handle a touch event. @@ -117,5 +116,5 @@ interface Listener { * @param event the touch event * @return whether the controller handled the event */ - boolean onTouchEvent(MotionEvent event); + fun onTouchEvent(event: MotionEvent): Boolean } 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 deleted file mode 100644 index 02d93622b8..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.java +++ /dev/null @@ -1,417 +0,0 @@ -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 TransformationListener transformationListener; - - 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) { - if (null != transformationListener) { - transformationListener.onTransformationEnd(); - } - } - }; - - public void setTransformationListener( - TransformationListener transformationListener) { - this.transformationListener = transformationListener; - } - - 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(); - } - - /** - * Use this, If someone is willing to listen to scale change - */ - public interface TransformationListener{ - void onTransformationEnd(); - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.kt new file mode 100644 index 0000000000..5df63668b7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.kt @@ -0,0 +1,320 @@ +package fr.free.nrw.commons.media.zoomControllers.zoomable + +import android.annotation.SuppressLint +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.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.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. + */ +open class ZoomableDraweeView : DraweeView, ScrollingView { + + companion object { + private val TAG = ZoomableDraweeView::class.java + private const val HUGE_IMAGE_SCALE_FACTOR_THRESHOLD = 1.1f + } + + private val imageBounds = RectF() + private val viewBounds = RectF() + + private var hugeImageController: DraweeController? = null + private var zoomableController: ZoomableController = createZoomableController() + private var tapGestureDetector: GestureDetector? = null + private var allowTouchInterceptionWhileZoomed = true + private var isDialToneEnabled = false + private var zoomingEnabled = true + private var transformationListener: TransformationListener? = null + + private val controllerListener = object : BaseControllerListener() { + override fun onFinalImageSet(id: String, imageInfo: Any?, animatable: Animatable?) { + this@ZoomableDraweeView.onFinalImageSet() + } + + override fun onRelease(id: String) { + this@ZoomableDraweeView.onRelease() + } + } + + private val zoomableListener = object : ZoomableController.Listener { + override fun onTransformBegin(transform: Matrix) {} + + override fun onTransformChanged(transform: Matrix) { + this@ZoomableDraweeView.onTransformChanged(transform) + } + + override fun onTransformEnd(transform: Matrix) { + transformationListener?.onTransformationEnd() + } + } + + private val tapListenerWrapper = GestureListenerWrapper() + + constructor(context: Context, hierarchy: GenericDraweeHierarchy) : super(context) { + setHierarchy(hierarchy) + init() + } + + constructor(context: Context) : super(context) { + inflateHierarchy(context, null) + init() + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + inflateHierarchy(context, attrs) + init() + } + + constructor(context: Context, attrs: AttributeSet?, defStyle: Int) + : super(context, attrs, defStyle) { + inflateHierarchy(context, attrs) + init() + } + + fun setTransformationListener(transformationListener: TransformationListener) { + this.transformationListener = transformationListener + } + + protected fun inflateHierarchy(context: Context, attrs: AttributeSet?) { + val resources: Resources = context.resources + val builder = GenericDraweeHierarchyBuilder(resources) + .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + GenericDraweeHierarchyInflater.updateBuilder(builder, context, attrs) + aspectRatio = builder.desiredAspectRatio + setHierarchy(builder.build()) + } + + private fun init() { + zoomableController.setListener(zoomableListener) + tapGestureDetector = GestureDetector(context, tapListenerWrapper) + } + + fun setIsDialToneEnabled(isDialtoneEnabled: Boolean) { + this.isDialToneEnabled = isDialtoneEnabled + } + + protected fun getImageBounds(outBounds: RectF) { + hierarchy.getActualImageBounds(outBounds) + } + + protected fun getLimitBounds(outBounds: RectF) { + outBounds.set(0f, 0f, width.toFloat(), height.toFloat()) + } + + fun setZoomableController(zoomableController: ZoomableController) { + Preconditions.checkNotNull(zoomableController) + this.zoomableController.setListener(null) + this.zoomableController = zoomableController + this.zoomableController.setListener(zoomableListener) + } + + fun getZoomableController(): ZoomableController = zoomableController + + fun allowsTouchInterceptionWhileZoomed(): Boolean = allowTouchInterceptionWhileZoomed + + fun setAllowTouchInterceptionWhileZoomed(allow: Boolean) { + allowTouchInterceptionWhileZoomed = allow + } + + fun setTapListener(tapListener: GestureDetector.SimpleOnGestureListener) { + tapListenerWrapper.setListener(tapListener) + } + + fun setIsLongpressEnabled(enabled: Boolean) { + tapGestureDetector?.setIsLongpressEnabled(enabled) + } + + fun setZoomingEnabled(zoomingEnabled: Boolean) { + this.zoomingEnabled = zoomingEnabled + zoomableController.setEnabled(false) + } + + override fun setController(controller: DraweeController?) { + setControllers(controller, null) + } + + fun setControllers(controller: DraweeController?, hugeImageController: DraweeController?) { + setControllersInternal(null, null) + zoomableController.setEnabled(false) + setControllersInternal(controller, hugeImageController) + } + + private fun setControllersInternal( + controller: DraweeController?, + hugeImageController: DraweeController? + ) { + removeControllerListener(getController()) + addControllerListener(controller) + this.hugeImageController = hugeImageController + super.setController(controller) + } + + private fun maybeSetHugeImageController() { + if ( + hugeImageController != null + && + zoomableController.getScaleFactor() > HUGE_IMAGE_SCALE_FACTOR_THRESHOLD + ) { + setControllersInternal(hugeImageController, null) + } + } + + private fun removeControllerListener(controller: DraweeController?) { + if (controller is AbstractDraweeController<*, *>) { + controller.removeControllerListener(controllerListener) + } + } + + private fun addControllerListener(controller: DraweeController?) { + if (controller is AbstractDraweeController<*, *>) { + controller.addControllerListener(controllerListener) + } + } + + override fun onDraw(canvas: Canvas) { + val saveCount = canvas.save() + canvas.concat(zoomableController.getTransform()) + try { + super.onDraw(canvas) + } catch (e: Exception) { + val controller = controller + if (controller is AbstractDraweeController<*, *>) { + val callerContext = controller.callerContext + if (callerContext != null) { + throw RuntimeException("Exception in onDraw, callerContext=${callerContext}", e) + } + } + throw e + } + canvas.restoreToCount(saveCount) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + var action = event.actionMasked + FLog.v(getLogTag(), "onTouchEvent: $action, view ${hashCode()}, received") + + if (!isDialToneEnabled && tapGestureDetector?.onTouchEvent(event) == true) { + FLog.v( + getLogTag(), + "onTouchEvent: $action, view ${hashCode()}, handled by tap gesture detector" + ) + return true + } + + if (!isDialToneEnabled && zoomableController.onTouchEvent(event)) { + FLog.v( + getLogTag(), + "onTouchEvent: $action, view ${hashCode()}, handled by zoomable controller" + ) + if (!allowTouchInterceptionWhileZoomed && !zoomableController.isIdentity()) { + parent.requestDisallowInterceptTouchEvent(true) + } + return true + } + + if (super.onTouchEvent(event)) { + FLog.v( + getLogTag(), + "onTouchEvent: $action, view ${hashCode()}, handled by the super" + ) + return true + } + + // If none of our components handled the event, we send a cancel event to avoid unwanted actions. + val cancelEvent = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL } + tapGestureDetector?.onTouchEvent(cancelEvent) + zoomableController.onTouchEvent(cancelEvent) + cancelEvent.recycle() + + return false + } + + override fun computeHorizontalScrollRange(): Int = + zoomableController.computeHorizontalScrollRange() + + override fun computeHorizontalScrollOffset(): Int = + zoomableController.computeHorizontalScrollOffset() + + override fun computeHorizontalScrollExtent(): Int = + zoomableController.computeHorizontalScrollExtent() + + override fun computeVerticalScrollRange(): Int = + zoomableController.computeVerticalScrollRange() + + override fun computeVerticalScrollOffset(): Int = + zoomableController.computeVerticalScrollOffset() + + override fun computeVerticalScrollExtent(): Int = + zoomableController.computeVerticalScrollExtent() + + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + FLog.v(getLogTag(), "onLayout: view ${hashCode()}") + super.onLayout(changed, left, top, right, bottom) + updateZoomableControllerBounds() + } + + private fun onFinalImageSet() { + FLog.v(getLogTag(), "onFinalImageSet: view ${hashCode()}") + if (!zoomableController.isEnabled() && zoomingEnabled) { + zoomableController.setEnabled(true) + updateZoomableControllerBounds() + } + } + + private fun onRelease() { + FLog.v(getLogTag(), "onRelease: view ${hashCode()}") + zoomableController.setEnabled(false) + } + + protected fun onTransformChanged(transform: Matrix) { + FLog.v(getLogTag(), "onTransformChanged: view ${hashCode()}, transform: $transform") + maybeSetHugeImageController() + invalidate() + } + + protected fun updateZoomableControllerBounds() { + getImageBounds(imageBounds) + getLimitBounds(viewBounds) + zoomableController.setImageBounds(imageBounds) + zoomableController.setViewBounds(viewBounds) + + FLog.v( + getLogTag(), + "updateZoomableControllerBounds: view ${hashCode()}, " + + "view bounds: $viewBounds, image bounds: $imageBounds" + ) + } + + protected fun getLogTag(): Class<*> = TAG + + protected fun createZoomableController(): ZoomableController = AnimatedZoomableController.newInstance() + + /** + * Interface to listen for scale change events. + */ + interface TransformationListener { + fun onTransformationEnd() + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/MultiPointerGestureDetectorUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/MultiPointerGestureDetectorUnitTest.kt index 2f62589a74..5389c8551d 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/MultiPointerGestureDetectorUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/MultiPointerGestureDetectorUnitTest.kt @@ -107,43 +107,43 @@ class MultiPointerGestureDetectorUnitTest { @Test @Throws(Exception::class) fun testIsGestureInProgress() { - Assert.assertEquals(detector.isGestureInProgress, false) + Assert.assertEquals(detector.isGestureInProgress(), false) } @Test @Throws(Exception::class) fun testGetNewPointerCount() { - Assert.assertEquals(detector.newPointerCount, 0) + Assert.assertEquals(detector.getNewPointerCount(), 0) } @Test @Throws(Exception::class) fun testGetPointerCount() { - Assert.assertEquals(detector.pointerCount, 0) + Assert.assertEquals(detector.getPointerCount(), 0) } @Test @Throws(Exception::class) fun testGetStartX() { - Assert.assertEquals(detector.startX[0], 0.0f) + Assert.assertEquals(detector.getStartX()[0], 0.0f) } @Test @Throws(Exception::class) fun testGetStartY() { - Assert.assertEquals(detector.startY[0], 0.0f) + Assert.assertEquals(detector.getStartY()[0], 0.0f) } @Test @Throws(Exception::class) fun testGetCurrentX() { - Assert.assertEquals(detector.currentX[0], 0.0f) + Assert.assertEquals(detector.getCurrentX()[0], 0.0f) } @Test @Throws(Exception::class) fun testGetCurrentY() { - Assert.assertEquals(detector.currentY[0], 0.0f) + Assert.assertEquals(detector.getCurrentY()[0], 0.0f) } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/TransformGestureDetectorUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/TransformGestureDetectorUnitTest.kt index 6252f6fb85..3c6af2f56d 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/TransformGestureDetectorUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/TransformGestureDetectorUnitTest.kt @@ -84,51 +84,51 @@ class TransformGestureDetectorUnitTest { @Test @Throws(Exception::class) fun testIsGestureInProgress() { - Assert.assertEquals(detector.isGestureInProgress, false) + Assert.assertEquals(detector.isGestureInProgress(), false) } @Test @Throws(Exception::class) fun testGetNewPointerCount() { - Assert.assertEquals(detector.newPointerCount, 0) + Assert.assertEquals(detector.getNewPointerCount(), 0) } @Test @Throws(Exception::class) fun testGetPointerCount() { - Assert.assertEquals(detector.pointerCount, 0) + Assert.assertEquals(detector.getPointerCount(), 0) } @Test @Throws(Exception::class) fun testGetPivotX() { - Assert.assertEquals(detector.pivotX, 0.0f) + Assert.assertEquals(detector.getPivotX(), 0.0f) } @Test @Throws(Exception::class) fun testGetPivotY() { - Assert.assertEquals(detector.pivotY, 0.0f) + Assert.assertEquals(detector.getPivotY(), 0.0f) } @Test @Throws(Exception::class) fun testGetTranslationX() { - Assert.assertEquals(detector.translationX, 0.0f) + Assert.assertEquals(detector.getTranslationX(), 0.0f) } @Test @Throws(Exception::class) fun testGetTranslationY() { - Assert.assertEquals(detector.translationY, 0.0f) + Assert.assertEquals(detector.getTranslationY(), 0.0f) } @Test @Throws(Exception::class) fun testGetScaleCaseLessThan2() { Whitebox.setInternalState(detector, "mDetector", mDetector) - whenever(mDetector.pointerCount).thenReturn(1) - Assert.assertEquals(detector.scale, 1f) + whenever(mDetector.getPointerCount()).thenReturn(1) + Assert.assertEquals(detector.getScale(), 1f) } @Test @@ -138,20 +138,20 @@ class TransformGestureDetectorUnitTest { array[0] = 0.0f array[1] = 1.0f Whitebox.setInternalState(detector, "mDetector", mDetector) - whenever(mDetector.pointerCount).thenReturn(2) - whenever(mDetector.startX).thenReturn(array) - whenever(mDetector.startY).thenReturn(array) - whenever(mDetector.currentX).thenReturn(array) - whenever(mDetector.currentY).thenReturn(array) - Assert.assertEquals(detector.scale, 1f) + whenever(mDetector.getPointerCount()).thenReturn(2) + whenever(mDetector.getStartX()).thenReturn(array) + whenever(mDetector.getStartY()).thenReturn(array) + whenever(mDetector.getCurrentX()).thenReturn(array) + whenever(mDetector.getCurrentY()).thenReturn(array) + Assert.assertEquals(detector.getScale(), 1f) } @Test @Throws(Exception::class) fun testGetRotationCaseLessThan2() { Whitebox.setInternalState(detector, "mDetector", mDetector) - whenever(mDetector.pointerCount).thenReturn(1) - Assert.assertEquals(detector.rotation, 0f) + whenever(mDetector.getPointerCount()).thenReturn(1) + Assert.assertEquals(detector.getRotation(), 0f) } @Test @@ -161,12 +161,12 @@ class TransformGestureDetectorUnitTest { array[0] = 0.0f array[1] = 1.0f Whitebox.setInternalState(detector, "mDetector", mDetector) - whenever(mDetector.pointerCount).thenReturn(2) - whenever(mDetector.startX).thenReturn(array) - whenever(mDetector.startY).thenReturn(array) - whenever(mDetector.currentX).thenReturn(array) - whenever(mDetector.currentY).thenReturn(array) - Assert.assertEquals(detector.rotation, 0f) + whenever(mDetector.getPointerCount()).thenReturn(2) + whenever(mDetector.getStartX()).thenReturn(array) + whenever(mDetector.getStartY()).thenReturn(array) + whenever(mDetector.getCurrentX()).thenReturn(array) + whenever(mDetector.getCurrentY()).thenReturn(array) + Assert.assertEquals(detector.getRotation(), 0f) } @Test