Merge branch 'commons-app:main' into explore_migration_kotlin

This commit is contained in:
Saifuddin Adenwala 2025-03-17 10:48:42 +05:30 committed by GitHub
commit e5f42562b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2015 additions and 2177 deletions

View file

@ -387,35 +387,40 @@ after opening the app.
}
override fun onBackPressed() {
if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) {
when (activeFragment) {
ActiveFragment.CONTRIBUTIONS -> {
// Means that contribution fragment is visible
if (!contributionsFragment!!.backButtonClicked()) { //If this one does not wan't to handle
if (contributionsFragment?.backButtonClicked() != true) { //If this one does not want to handle
// the back press, let the activity do so
super.onBackPressed()
}
}
} else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) {
ActiveFragment.NEARBY -> {
// Means that nearby fragment is visible
/* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is
not expanded. So if the back button is pressed, then go back to the Contributions tab */
if (!nearbyParentFragment!!.backButtonClicked()) {
supportFragmentManager.beginTransaction().remove(nearbyParentFragment!!)
.commit()
if (nearbyParentFragment?.backButtonClicked() != true) {
nearbyParentFragment?.let {
supportFragmentManager.beginTransaction().remove(it).commit()
}
setSelectedItemId(NavTab.CONTRIBUTIONS.code())
}
}
} else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) {
// Means that explore fragment is visible
if (!exploreFragment!!.onBackPressed()) {
if (applicationKvStore!!.getBoolean("login_skipped")) {
ActiveFragment.EXPLORE -> {
// Explore Fragment is visible
if (exploreFragment?.onBackPressed() != true) {
if (applicationKvStore?.getBoolean("login_skipped") == true) {
super.onBackPressed()
} else {
setSelectedItemId(NavTab.CONTRIBUTIONS.code())
}
}
}
} else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) {
ActiveFragment.BOOKMARK -> {
// Means that bookmark fragment is visible
bookmarkFragment!!.onBackPressed()
} else {
bookmarkFragment?.onBackPressed()
}
else -> {
super.onBackPressed()
}
}
}

View file

@ -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
}

View file

@ -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;
}
}

View file

@ -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
}
}

View file

@ -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;
}
}
}

View file

@ -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
}
}
}

View file

@ -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.
*
* <p>If this method is called while an animation or gesture is already in progress, the current
* animation or gesture will be stopped first.
*
* @param scale desired scale, will be limited to {min, max} scale factor
* @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1)
* @param viewPoint 2D point in view's absolute coordinate system
*/
@Override
public void zoomToPoint(float scale, PointF imagePoint, PointF viewPoint) {
zoomToPoint(scale, imagePoint, viewPoint, LIMIT_ALL, 0, null);
}
/**
* Zooms to the desired scale and positions the image so that the given image point corresponds to
* the given view point.
*
* <p>If this method is called while an animation or gesture is already in progress, the current
* animation or gesture will be stopped first.
*
* @param scale desired scale, will be limited to {min, max} scale factor
* @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1)
* @param viewPoint 2D point in view's absolute coordinate system
* @param limitFlags whether to limit translation and/or scale.
* @param durationMs length of animation of the zoom, or 0 if no animation desired
* @param onAnimationComplete code to run when the animation completes. Ignored if durationMs=0
*/
public void zoomToPoint(
float scale,
PointF imagePoint,
PointF viewPoint,
@LimitFlag int limitFlags,
long durationMs,
@Nullable Runnable onAnimationComplete) {
FLog.v(getLogTag(), "zoomToPoint: duration %d ms", durationMs);
calculateZoomToPointTransform(mNewTransform, scale, imagePoint, viewPoint, limitFlags);
setTransform(mNewTransform, durationMs, onAnimationComplete);
}
/**
* Sets a new zoomable transformation and animates to it if desired.
*
* <p>If this method is called while an animation or gesture is already in progress, the current
* animation or gesture will be stopped first.
*
* @param newTransform new transform to make active
* @param durationMs duration of the animation, or 0 to not animate
* @param onAnimationComplete code to run when the animation completes. Ignored if durationMs=0
*/
public void setTransform(
Matrix newTransform, long durationMs, @Nullable Runnable onAnimationComplete) {
FLog.v(getLogTag(), "setTransform: duration %d ms", durationMs);
if (durationMs <= 0) {
setTransformImmediate(newTransform);
} else {
setTransformAnimated(newTransform, durationMs, onAnimationComplete);
}
}
private void setTransformImmediate(final Matrix newTransform) {
FLog.v(getLogTag(), "setTransformImmediate");
stopAnimation();
mWorkingTransform.set(newTransform);
super.setTransform(newTransform);
getDetector().restartGesture();
}
protected boolean isAnimating() {
return mIsAnimating;
}
protected void setAnimating(boolean isAnimating) {
mIsAnimating = isAnimating;
}
protected float[] getStartValues() {
return mStartValues;
}
protected float[] getStopValues() {
return mStopValues;
}
protected Matrix getWorkingTransform() {
return mWorkingTransform;
}
@Override
public void onGestureBegin(TransformGestureDetector detector) {
FLog.v(getLogTag(), "onGestureBegin");
stopAnimation();
super.onGestureBegin(detector);
}
@Override
public void onGestureUpdate(TransformGestureDetector detector) {
FLog.v(getLogTag(), "onGestureUpdate %s", isAnimating() ? "(ignored)" : "");
if (isAnimating()) {
return;
}
super.onGestureUpdate(detector);
}
protected void calculateInterpolation(Matrix outMatrix, float fraction) {
for (int i = 0; i < 9; i++) {
mCurrentValues[i] = (1 - fraction) * mStartValues[i] + fraction * mStopValues[i];
}
outMatrix.setValues(mCurrentValues);
}
public abstract void setTransformAnimated(
final Matrix newTransform, long durationMs, @Nullable final Runnable onAnimationComplete);
protected abstract void stopAnimation();
protected abstract Class<?> getLogTag();
}

View file

@ -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<*>
}

View file

@ -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;
}
}

View file

@ -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
}

View file

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>The image is attempted to be centered within the view bounds if the transformed image is
* smaller. There will be no empty spaces within the view bounds if the transformed image is
* bigger. This applies to each dimension (horizontal and vertical) independently.
*
* @param limitTypes whether to limit translation along the specific axis.
* @return whether limiting has been applied or not
*/
private boolean limitTranslation(Matrix transform, @LimitFlag int limitTypes) {
if (!shouldLimit(limitTypes, LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y)) {
return false;
}
RectF b = mTempRect;
b.set(mImageBounds);
transform.mapRect(b);
float offsetLeft =
shouldLimit(limitTypes, LIMIT_TRANSLATION_X)
? getOffset(
b.left, b.right, mViewBounds.left, mViewBounds.right, mImageBounds.centerX())
: 0;
float offsetTop =
shouldLimit(limitTypes, LIMIT_TRANSLATION_Y)
? getOffset(
b.top, b.bottom, mViewBounds.top, mViewBounds.bottom, mImageBounds.centerY())
: 0;
if (offsetLeft != 0 || offsetTop != 0) {
transform.postTranslate(offsetLeft, offsetTop);
return true;
}
return false;
}
/**
* Checks whether the specified limit flag is present in the limits provided.
*
* <p>If the flag contains multiple flags together using a bitwise OR, this only checks that at
* least one of the flags is included.
*
* @param limits the limits to apply
* @param flag the limit flag(s) to check for
* @return true if the flag (or one of the flags) is included in the limits
*/
private static boolean shouldLimit(@LimitFlag int limits, @LimitFlag int flag) {
return (limits & flag) != LIMIT_NONE;
}
/**
* Returns the offset necessary to make sure that: - the image is centered within the limit if the
* image is smaller than the limit - there is no empty space on left/right if the image is bigger
* than the limit
*/
private float getOffset(
float imageStart, float imageEnd, float limitStart, float limitEnd, float limitCenter) {
float imageWidth = imageEnd - imageStart, limitWidth = limitEnd - limitStart;
float limitInnerWidth = Math.min(limitCenter - limitStart, limitEnd - limitCenter) * 2;
// center if smaller than limitInnerWidth
if (imageWidth < limitInnerWidth) {
return limitCenter - (imageEnd + imageStart) / 2;
}
// to the edge if in between and limitCenter is not (limitLeft + limitRight) / 2
if (imageWidth < limitWidth) {
if (limitCenter < (limitStart + limitEnd) / 2) {
return limitStart - imageStart;
} else {
return limitEnd - imageEnd;
}
}
// to the edge if larger than limitWidth and empty space visible
if (imageStart > limitStart) {
return limitStart - imageStart;
}
if (imageEnd < limitEnd) {
return limitEnd - imageEnd;
}
return 0;
}
/** Limits the value to the given min and max range. */
private float limit(float value, float min, float max) {
return Math.min(Math.max(min, value), max);
}
/**
* Gets the scale factor for the given matrix. This method assumes the equal scaling factor for X
* and Y axis.
*/
private float getMatrixScaleFactor(Matrix transform) {
transform.getValues(mTempValues);
return mTempValues[Matrix.MSCALE_X];
}
/** Same as {@code Matrix.isIdentity()}, but with tolerance {@code eps}. */
private boolean isMatrixIdentity(Matrix transform, float eps) {
// Checks whether the given matrix is close enough to the identity matrix:
// 1 0 0
// 0 1 0
// 0 0 1
// Or equivalently to the zero matrix, after subtracting 1.0f from the diagonal elements:
// 0 0 0
// 0 0 0
// 0 0 0
transform.getValues(mTempValues);
mTempValues[0] -= 1.0f; // m00
mTempValues[4] -= 1.0f; // m11
mTempValues[8] -= 1.0f; // m22
for (int i = 0; i < 9; i++) {
if (Math.abs(mTempValues[i]) > eps) {
return false;
}
}
return true;
}
/** Returns whether the scroll can happen in all directions. I.e. the image is not on any edge. */
private boolean canScrollInAllDirection() {
return mTransformedImageBounds.left < mViewBounds.left - EPS
&& mTransformedImageBounds.top < mViewBounds.top - EPS
&& mTransformedImageBounds.right > mViewBounds.right + EPS
&& mTransformedImageBounds.bottom > mViewBounds.bottom + EPS;
}
@Override
public int computeHorizontalScrollRange() {
return (int) mTransformedImageBounds.width();
}
@Override
public int computeHorizontalScrollOffset() {
return (int) (mViewBounds.left - mTransformedImageBounds.left);
}
@Override
public int computeHorizontalScrollExtent() {
return (int) mViewBounds.width();
}
@Override
public int computeVerticalScrollRange() {
return (int) mTransformedImageBounds.height();
}
@Override
public int computeVerticalScrollOffset() {
return (int) (mViewBounds.top - mTransformedImageBounds.top);
}
@Override
public int computeVerticalScrollExtent() {
return (int) mViewBounds.height();
}
public Listener getListener() {
return mListener;
}
}

View file

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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
}
}

View file

@ -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;
}
}

View file

@ -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
}
}

View file

@ -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);
}
}

View file

@ -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)
}
}

View file

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

View file

@ -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<GestureDetector.SimpleOnGestureListener> =
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
}
}

View file

@ -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<ZoomableController.Listener> 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);
}
}

View file

@ -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<ZoomableController.Listener> = 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)
}
}

View file

@ -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 @@ public interface ZoomableController {
*
* @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 @@ public interface ZoomableController {
*
* @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 @@ public interface ZoomableController {
*
* @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 @@ public interface ZoomableController {
* <p>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 @@ public interface ZoomableController {
*
* @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 @@ public interface ZoomableController {
* @param event the touch event
* @return whether the controller handled the event
*/
boolean onTouchEvent(MotionEvent event);
fun onTouchEvent(event: MotionEvent): Boolean
}

View file

@ -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.
*
* <p>Once the image loads, pinch-to-zoom and translation gestures are enabled.
*/
public class ZoomableDraweeView extends DraweeView<GenericDraweeHierarchy>
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<Object>() {
@Override
public void onFinalImageSet(
String id, @Nullable Object imageInfo, @Nullable Animatable animatable) {
ZoomableDraweeView.this.onFinalImageSet();
}
@Override
public void onRelease(String id) {
ZoomableDraweeView.this.onRelease();
}
};
private 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.
*
* <p>The original image bounds are those reported by the hierarchy. The hierarchy itself may
* apply scaling on its own (e.g. due to scale type) so the reported bounds are not necessarily
* the same as the actual bitmap dimensions. In other words, the original image bounds correspond
* to the image bounds within this view when no zoomable transformation is applied, but including
* the potential scaling of the hierarchy. Having the actual bitmap dimensions abstracted away
* from this view greatly simplifies implementation because the actual bitmap may change (e.g.
* when a high-res image arrives and replaces the previously set low-res image). With proper
* hierarchy scaling (e.g. FIT_CENTER), this underlying change will not affect this view nor the
* zoomable transformation in any way.
*/
protected void getImageBounds(RectF outBounds) {
getHierarchy().getActualImageBounds(outBounds);
}
/**
* Gets the bounds used to limit the translation, in view-absolute coordinates.
*
* <p>These bounds are passed to the zoomable controller in order to limit the translation. The
* image is attempted to be centered within the limit bounds if the transformed image is smaller.
* There will be no empty spaces within the limit bounds if the transformed image is bigger. This
* applies to each dimension (horizontal and vertical) independently.
*
* <p>Unless overridden by a subclass, these bounds are same as the view bounds.
*/
protected void getLimitBounds(RectF outBounds) {
outBounds.set(0, 0, getWidth(), getHeight());
}
/** Sets a custom zoomable controller, instead of using the default one. */
public void setZoomableController(ZoomableController zoomableController) {
Preconditions.checkNotNull(zoomableController);
mZoomableController.setListener(null);
mZoomableController = zoomableController;
mZoomableController.setListener(mZoomableListener);
}
/**
* Gets the zoomable controller.
*
* <p>Zoomable controller can be used to zoom to point, or to map point from view to image
* coordinates for instance.
*/
public ZoomableController getZoomableController() {
return mZoomableController;
}
/**
* Check whether the parent view can intercept touch events while zoomed. This can be used, for
* example, to swipe between images in a view pager while zoomed.
*
* @return true if touch events can be intercepted
*/
public boolean allowsTouchInterceptionWhileZoomed() {
return mAllowTouchInterceptionWhileZoomed;
}
/**
* If this is set to true, parent views can intercept touch events while the view is zoomed. For
* example, this can be used to swipe between images in a view pager while zoomed.
*
* @param allowTouchInterceptionWhileZoomed true if the parent needs to intercept touches
*/
public void setAllowTouchInterceptionWhileZoomed(boolean allowTouchInterceptionWhileZoomed) {
mAllowTouchInterceptionWhileZoomed = allowTouchInterceptionWhileZoomed;
}
/** Sets the tap listener. */
public void setTapListener(GestureDetector.SimpleOnGestureListener tapListener) {
mTapListenerWrapper.setListener(tapListener);
}
/**
* Sets whether long-press tap detection is enabled. Unfortunately, long-press conflicts with
* onDoubleTapEvent.
*/
public void setIsLongpressEnabled(boolean enabled) {
mTapGestureDetector.setIsLongpressEnabled(enabled);
}
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.
*
* <p>The huge image controller is used after the image gets scaled above a certain threshold.
*
* <p>IMPORTANT: in order to avoid a flicker when switching to the huge image, the huge image
* controller should have the normal-image-uri set as its low-res-uri.
*
* @param controller controller to be initially used
* @param hugeImageController controller to be used after the client starts zooming-in
*/
public void setControllers(
@Nullable DraweeController controller, @Nullable DraweeController hugeImageController) {
setControllersInternal(null, null);
mZoomableController.setEnabled(false);
setControllersInternal(controller, hugeImageController);
}
private void setControllersInternal(
@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();
}
}

View file

@ -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.
*
* <p>Once the image loads, pinch-to-zoom and translation gestures are enabled.
*/
open class ZoomableDraweeView : DraweeView<GenericDraweeHierarchy>, 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<Any>() {
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()
}
}

View file

@ -97,9 +97,9 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
if (media == null) {
if (callback != null) {
binding!!.tvTitle.text = getString(R.string.step_count,
callback!!.getIndexInViewFlipper(this) + 1,
callback!!.totalNumberOfSteps,
binding?.tvTitle?.text = getString(R.string.step_count,
callback?.getIndexInViewFlipper(this)?.plus(1) ?: 1,
callback?.totalNumberOfSteps ?: 1,
getString(R.string.categories_activity_title))
}
} else {
@ -110,7 +110,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
setTvSubTitle()
binding!!.tooltip.setOnClickListener {
binding?.let { it.tooltip.setOnClickListener {
showAlertDialog(
requireActivity(),
getString(R.string.categories_activity_title),
@ -119,10 +119,11 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
null
)
}
}
if (media == null) {
presenter!!.onAttachView(this)
presenter?.onAttachView(this)
} else {
presenter!!.onAttachViewWithMedia(this, media!!)
presenter?.onAttachViewWithMedia(this, media!!)
}
binding!!.btnNext.setOnClickListener { v: View? -> onNextButtonClicked() }
binding!!.btnPrevious.setOnClickListener { v: View? -> onPreviousButtonClicked() }
@ -137,7 +138,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
subscribe = RxTextView.textChanges(binding!!.etSearch)
.doOnEach { v: Notification<CharSequence?>? ->
binding!!.tilContainerSearch.error =
binding?.tilContainerSearch?.error =
null
}
.takeUntil(RxView.detaches(binding!!.etSearch))
@ -163,25 +164,25 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
private fun searchForCategory(query: String) {
presenter!!.searchForCategories(query)
presenter?.searchForCategories(query)
}
private fun initRecyclerView() {
adapter = UploadCategoryAdapter({ categoryItem: CategoryItem? ->
presenter!!.onCategoryItemClicked(categoryItem!!)
if (adapter == null) { adapter = UploadCategoryAdapter({ categoryItem: CategoryItem? ->
presenter?.onCategoryItemClicked(categoryItem!!)
Unit
}, nearbyPlaceCategory)
if (binding != null) {
binding!!.rvCategories.layoutManager = LinearLayoutManager(context)
binding!!.rvCategories.adapter = adapter
}
binding?.rvCategories?.apply {
layoutManager = LinearLayoutManager(context)
adapter = this@UploadCategoriesFragment.adapter
}
}
override fun onDestroyView() {
super.onDestroyView()
presenter!!.onDetachView()
subscribe!!.dispose()
presenter?.onDetachView()
subscribe?.dispose()
}
override fun showProgress(shouldShow: Boolean) {
@ -197,6 +198,11 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
override fun setCategories(categories: List<CategoryItem>?) {
if (adapter == null) {
Timber.e("Adapter is null in setCategories")
return
}
if (categories == null) {
adapter!!.clear()
} else {
@ -204,19 +210,16 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
adapter!!.notifyDataSetChanged()
if (binding == null) {
return
}
// Nested waiting for search result data to load into the category
// list and smoothly scroll to the top of the search result list.
binding!!.rvCategories.post {
binding!!.rvCategories.smoothScrollToPosition(0)
binding!!.rvCategories.post {
binding!!.rvCategories.smoothScrollToPosition(
binding?.let {
it.rvCategories.post {
it.rvCategories.smoothScrollToPosition(0)
it.rvCategories.post {
it.rvCategories.smoothScrollToPosition(
0
)
}
}
}
} ?: Timber.e("Binding is null in setCategories")
}
override fun goToNextScreen() {
@ -308,7 +311,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
fun onNextButtonClicked() {
if (media != null) {
presenter!!.updateCategories(media!!, wikiText!!)
presenter?.updateCategories(media!!, wikiText!!)
} else {
presenter!!.verifyCategories()
}
@ -318,7 +321,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
if (media != null) {
presenter!!.clearPreviousSelection()
adapter!!.items = null
val mediaDetailFragment = checkNotNull(parentFragment as MediaDetailFragment?)
val mediaDetailFragment = parentFragment as? MediaDetailFragment?: return
mediaDetailFragment.onResume()
goBackToPreviousScreen()
} else {
@ -345,7 +348,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
super.onResume()
if (media != null) {
binding!!.etSearch.setOnKeyListener { v: View?, keyCode: Int, event: KeyEvent? ->
binding?.etSearch?.setOnKeyListener { v: View?, keyCode: Int, event: KeyEvent? ->
if (keyCode == KeyEvent.KEYCODE_BACK) {
binding!!.etSearch.clearFocus()
presenter!!.clearPreviousSelection()

View file

@ -60,7 +60,7 @@
<string name="login_success">ਦਾਖ਼ਲ ਹੋਣਾ ਸਫ਼ਲ!</string>
<string name="login_failed">ਦਾਖ਼ਲ ਹੋਣਾ ਅਸਫ਼ਲ!</string>
<string name="upload_failed">ਫ਼ਾਇਲ ਦੀ ਖੋਜ ਨਹੀਂ ਹੋ ਸਕੀ। ਕਿਰਪਾ ਕਰਕੇ ਹੋਰ ਫ਼ਾਇਲ ਖੋਜੋ।</string>
<string name="authentication_failed">ਪ੍ਰਮਾਣੀਕਰਨ ਅਸਫਲ ਰਿਹਾ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਦਾਖਲ ਹੋਵੋ।</string>
<string name="authentication_failed">ਤਸਦੀਕ ਨਾਕਾਮ ਰਹੀ। ਕਿਰਪਾ ਕਰਕੇ ਮੁੜ ਦਾਖਲ ਹੋਵੋ।</string>
<string name="uploading_started">ਅੱਪਲੋਡ ਸ਼ੁਰੂ ਹੋਇਆ!</string>
<string name="upload_completed_notification_title">%1$s ਅੱਪਲੋਡ ਹੋ ਗਏ!</string>
<string name="upload_completed_notification_text">ਆਪਣਾ ਅੱਪਲੋਡ ਵੇਖਣ ਲਈ ਥਪੇੜੋ</string>
@ -91,10 +91,10 @@
<string name="login_failed_network">ਦਾਖ਼ਲ ਹੋਣ ਵਿੱਚ ਅਸਮਰੱਥ - ਨੈੱਟਵਰਕ ਫੇਲ੍ਹ ਹੋਇਆ ਹੈ</string>
<string name="login_failed_throttled">ਬਹੁਤ ਸਾਰੀਆਂ ਅਸਫ਼ਲ ਕੋਸ਼ਿਸ਼ਾਂ। ਥੋੜ੍ਹੀ ਦੇਰ ਬਾਅਦ ਕੋਸ਼ਿਸ਼ ਕਰੋ ਜੀ।</string>
<string name="login_failed_blocked">ਅਫ਼ਸੋਸ, ਇਹ ਵਰਤੋਂਕਾਰ ਨੂੰ ਕਾਮਨਜ਼ ਤੇ ਰੋਕ ਲਾਈ ਗਈ ਹੈ।</string>
<string name="login_failed_2fa_needed">ਤੁਹਾਨੂੰ ਆਪਣਾ ਦੋ-ਕਾਰਕ ਪ੍ਰਮਾਣੀਕਰਨ ਕੋਡ ਦੇਣਾ ਪਵੇਗਾ।</string>
<string name="login_failed_2fa_needed">ਤੁਹਾਨੂੰ ਆਪਣਾ ਦੋ-ਪੱਖੀ ਤਸਦੀਕ ਕੋਡ ਦੇਣਾ ਪਵੇਗਾ।</string>
<string name="login_failed_generic">ਦਾਖ਼ਲ ਹੋਣਾ ਅਸਫ਼ਲ!</string>
<string name="share_upload_button">ਚੜ੍ਹਾਉ</string>
<string name="multiple_share_base_title">ਇਸ ਸੈੱਟ ਨੂੰ ਨਾਂ ਦਿਓ</string>
<string name="multiple_share_base_title">ਇਸ ਟੋਲੀ ਨੂੰ ਨਾਂ ਦਿਓ</string>
<string name="provider_modifications">ਸੋਧਾਂ</string>
<string name="menu_upload_single">ਚੜ੍ਹਾਉ</string>
<string name="categories_search_text_hint">ਸ਼੍ਰੇਣੀਆਂ ਖੋਜੋ</string>

View file

@ -9,6 +9,7 @@
* شاه زمان پټان
-->
<resources>
<string name="commons_facebook">د خونديځ فيسبوک پاڼه</string>
<string name="commons_github">خونديځ ګيټهوب سرچينه کوډ</string>
<string name="commons_logo">خونديځ نښان</string>
<string name="commons_website">خونديځ وېبپاڼه</string>

View file

@ -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

View file

@ -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