mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-26 12:23:58 +01:00
* MediaDetailFragment: add zoom feature * fragment_media_detail: add SimpleDrawee for Scroll picture * ZoomableActivity: activity which facilitates zoom in * activity_zoomable: xml for zoom activity * zoomControllers: controllers for handling gesture and zooming * MediaDetailFragment: fixing name of image variable * MediaDetailFragment: display as per the aspect ratio of image * add zoom activity to AndroidManifest * fix travis ci faliure * fix resizing of image
This commit is contained in:
parent
5fd88ef1a8
commit
642ed51c8c
16 changed files with 2295 additions and 32 deletions
|
|
@ -41,6 +41,9 @@
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:finishOnTaskLaunch="true" />
|
android:finishOnTaskLaunch="true" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".media.ZoomableActivity" />
|
||||||
|
|
||||||
<activity android:name=".auth.LoginActivity">
|
<activity android:name=".auth.LoginActivity">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package fr.free.nrw.commons.media;
|
package fr.free.nrw.commons.media;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
|
import android.graphics.drawable.Animatable;
|
||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.Context;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.Editable;
|
import android.text.Editable;
|
||||||
|
|
@ -23,7 +25,10 @@ import android.widget.Toast;
|
||||||
|
|
||||||
import com.facebook.drawee.backends.pipeline.Fresco;
|
import com.facebook.drawee.backends.pipeline.Fresco;
|
||||||
import com.facebook.drawee.interfaces.DraweeController;
|
import com.facebook.drawee.interfaces.DraweeController;
|
||||||
|
import com.facebook.drawee.controller.BaseControllerListener;
|
||||||
|
import com.facebook.drawee.controller.ControllerListener;
|
||||||
import com.facebook.drawee.view.SimpleDraweeView;
|
import com.facebook.drawee.view.SimpleDraweeView;
|
||||||
|
import com.facebook.imagepipeline.image.ImageInfo;
|
||||||
import com.facebook.imagepipeline.request.ImageRequest;
|
import com.facebook.imagepipeline.request.ImageRequest;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
@ -39,6 +44,7 @@ import javax.inject.Inject;
|
||||||
import butterknife.BindView;
|
import butterknife.BindView;
|
||||||
import butterknife.ButterKnife;
|
import butterknife.ButterKnife;
|
||||||
import butterknife.OnClick;
|
import butterknife.OnClick;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import fr.free.nrw.commons.Media;
|
import fr.free.nrw.commons.Media;
|
||||||
import fr.free.nrw.commons.MediaDataExtractor;
|
import fr.free.nrw.commons.MediaDataExtractor;
|
||||||
import fr.free.nrw.commons.R;
|
import fr.free.nrw.commons.R;
|
||||||
|
|
@ -97,10 +103,8 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment {
|
||||||
|
|
||||||
private int initialListTop = 0;
|
private int initialListTop = 0;
|
||||||
|
|
||||||
@BindView(R.id.mediaDetailImage)
|
@BindView(R.id.mediaDetailImageView)
|
||||||
SimpleDraweeView image;
|
SimpleDraweeView image;
|
||||||
@BindView(R.id.mediaDetailSpacer)
|
|
||||||
MediaDetailSpacer spacer;
|
|
||||||
@BindView(R.id.mediaDetailTitle)
|
@BindView(R.id.mediaDetailTitle)
|
||||||
TextView title;
|
TextView title;
|
||||||
@BindView(R.id.mediaDetailDesc)
|
@BindView(R.id.mediaDetailDesc)
|
||||||
|
|
@ -197,36 +201,18 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment {
|
||||||
// Progressively darken the image in the background when we scroll detail pane up
|
// Progressively darken the image in the background when we scroll detail pane up
|
||||||
scrollListener = this::updateTheDarkness;
|
scrollListener = this::updateTheDarkness;
|
||||||
view.getViewTreeObserver().addOnScrollChangedListener(scrollListener);
|
view.getViewTreeObserver().addOnScrollChangedListener(scrollListener);
|
||||||
|
|
||||||
// Layout layoutListener to size the spacer item relative to the available space.
|
|
||||||
// There may be a .... better way to do this.
|
|
||||||
layoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
|
|
||||||
private int currentHeight = -1;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onGlobalLayout() {
|
|
||||||
int viewHeight = view.getHeight();
|
|
||||||
//int textHeight = title.getLineHeight();
|
|
||||||
int paddingDp = 112;
|
|
||||||
float paddingPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingDp, getResources().getDisplayMetrics());
|
|
||||||
int newHeight = viewHeight - Math.round(paddingPx);
|
|
||||||
|
|
||||||
if (newHeight != currentHeight) {
|
|
||||||
currentHeight = newHeight;
|
|
||||||
ViewGroup.LayoutParams params = spacer.getLayoutParams();
|
|
||||||
params.height = newHeight;
|
|
||||||
spacer.setLayoutParams(params);
|
|
||||||
|
|
||||||
scrollView.scrollTo(0, initialListTop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
view.getViewTreeObserver().addOnGlobalLayoutListener(layoutListener);
|
|
||||||
locale = getResources().getConfiguration().locale;
|
locale = getResources().getConfiguration().locale;
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnClick(R.id.mediaDetailImageView)
|
||||||
|
public void launchZoomActivity(View view) {
|
||||||
|
Context ctx = view.getContext();
|
||||||
|
ctx.startActivity(
|
||||||
|
new Intent(ctx,ZoomableActivity.class).setData(Uri.parse(media.getImageUrl()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
|
@ -255,6 +241,26 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment {
|
||||||
compositeDisposable.add(disposable);
|
compositeDisposable.add(disposable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateAspectRatio(ImageInfo imageInfo) {
|
||||||
|
if (imageInfo != null) {
|
||||||
|
int finalHeight = (scrollView.getWidth()*imageInfo.getHeight()) / imageInfo.getWidth();
|
||||||
|
ViewGroup.LayoutParams params = image.getLayoutParams();
|
||||||
|
params.height = finalHeight;
|
||||||
|
image.setLayoutParams(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ControllerListener aspectRatioListener = new BaseControllerListener<ImageInfo>() {
|
||||||
|
@Override
|
||||||
|
public void onIntermediateImageSet(String id, @Nullable ImageInfo imageInfo) {
|
||||||
|
updateAspectRatio(imageInfo);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void onFinalImageSet(String id, @Nullable ImageInfo imageInfo, @Nullable Animatable animatable) {
|
||||||
|
updateAspectRatio(imageInfo);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uses two image sources.
|
* Uses two image sources.
|
||||||
* - low resolution thumbnail is shown initially
|
* - low resolution thumbnail is shown initially
|
||||||
|
|
@ -264,6 +270,7 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment {
|
||||||
DraweeController controller = Fresco.newDraweeControllerBuilder()
|
DraweeController controller = Fresco.newDraweeControllerBuilder()
|
||||||
.setLowResImageRequest(ImageRequest.fromUri(media.getThumbUrl()))
|
.setLowResImageRequest(ImageRequest.fromUri(media.getThumbUrl()))
|
||||||
.setImageRequest(ImageRequest.fromUri(media.getImageUrl()))
|
.setImageRequest(ImageRequest.fromUri(media.getImageUrl()))
|
||||||
|
.setControllerListener(aspectRatioListener)
|
||||||
.setOldController(image.getController())
|
.setOldController(image.getController())
|
||||||
.build();
|
.build();
|
||||||
image.setController(controller);
|
image.setController(controller);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package fr.free.nrw.commons.media;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import butterknife.BindView;
|
||||||
|
import butterknife.ButterKnife;
|
||||||
|
import fr.free.nrw.commons.R;
|
||||||
|
import fr.free.nrw.commons.media.zoomControllers.zoomable.DoubleTapGestureListener;
|
||||||
|
import fr.free.nrw.commons.media.zoomControllers.zoomable.ZoomableDraweeView;
|
||||||
|
import timber.log.Timber;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import com.facebook.drawee.backends.pipeline.Fresco;
|
||||||
|
import com.facebook.drawee.interfaces.DraweeController;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import com.facebook.drawee.view.SimpleDraweeView;
|
||||||
|
import com.github.chrisbanes.photoview.PhotoView;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
|
||||||
|
public class ZoomableActivity extends AppCompatActivity {
|
||||||
|
private Uri imageUri;
|
||||||
|
|
||||||
|
@BindView(R.id.zoomable)
|
||||||
|
ZoomableDraweeView photo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
imageUri = getIntent().getData();
|
||||||
|
if (null == imageUri) {
|
||||||
|
throw new IllegalArgumentException("No data to display");
|
||||||
|
}
|
||||||
|
Timber.d("URl = " + imageUri);
|
||||||
|
|
||||||
|
setContentView(R.layout.activity_zoomable);
|
||||||
|
ButterKnife.bind(this);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
if( imageUri != null ) {
|
||||||
|
photo.setAllowTouchInterceptionWhileZoomed(true);
|
||||||
|
photo.setIsLongpressEnabled(false);
|
||||||
|
photo.setTapListener(new DoubleTapGestureListener(photo));
|
||||||
|
DraweeController controller = Fresco.newDraweeControllerBuilder()
|
||||||
|
.setUri(imageUri)
|
||||||
|
.build();
|
||||||
|
photo.setController(controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.gestures;
|
||||||
|
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that detects and tracks multiple pointers based on touch events.
|
||||||
|
*
|
||||||
|
* Each time a pointer gets pressed or released, the current gesture (if any) will end, and a new
|
||||||
|
* one will be started (if there are still pressed pointers left). It is guaranteed that the number
|
||||||
|
* of pointers within the single gesture will remain the same during the whole gesture.
|
||||||
|
*/
|
||||||
|
public class MultiPointerGestureDetector {
|
||||||
|
|
||||||
|
/** The listener for receiving notifications when gestures occur. */
|
||||||
|
public interface Listener {
|
||||||
|
/** A callback called right before the gesture is about to start. */
|
||||||
|
public void onGestureBegin(MultiPointerGestureDetector detector);
|
||||||
|
|
||||||
|
/** A callback called each time the gesture gets updated. */
|
||||||
|
public void onGestureUpdate(MultiPointerGestureDetector detector);
|
||||||
|
|
||||||
|
/** A callback called right after the gesture has finished. */
|
||||||
|
public void onGestureEnd(MultiPointerGestureDetector detector);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int MAX_POINTERS = 2;
|
||||||
|
|
||||||
|
private boolean mGestureInProgress;
|
||||||
|
private int mPointerCount;
|
||||||
|
private int mNewPointerCount;
|
||||||
|
private final int mId[] = new int[MAX_POINTERS];
|
||||||
|
private final float mStartX[] = new float[MAX_POINTERS];
|
||||||
|
private final float mStartY[] = new float[MAX_POINTERS];
|
||||||
|
private final float mCurrentX[] = new float[MAX_POINTERS];
|
||||||
|
private final float mCurrentY[] = new float[MAX_POINTERS];
|
||||||
|
|
||||||
|
private Listener mListener = null;
|
||||||
|
|
||||||
|
public MultiPointerGestureDetector() {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Factory method that creates a new instance of MultiPointerGestureDetector */
|
||||||
|
public static MultiPointerGestureDetector newInstance() {
|
||||||
|
return new MultiPointerGestureDetector();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the listener.
|
||||||
|
*
|
||||||
|
* @param listener listener to set
|
||||||
|
*/
|
||||||
|
public void setListener(Listener listener) {
|
||||||
|
mListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets the component to the initial state. */
|
||||||
|
public void reset() {
|
||||||
|
mGestureInProgress = false;
|
||||||
|
mPointerCount = 0;
|
||||||
|
for (int i = 0; i < MAX_POINTERS; i++) {
|
||||||
|
mId[i] = MotionEvent.INVALID_POINTER_ID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method can be overridden in order to perform threshold check or something similar.
|
||||||
|
*
|
||||||
|
* @return whether or not to start a new gesture
|
||||||
|
*/
|
||||||
|
protected boolean shouldStartGesture() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts a new gesture and calls the listener just before starting it. */
|
||||||
|
private void startGesture() {
|
||||||
|
if (!mGestureInProgress) {
|
||||||
|
if (mListener != null) {
|
||||||
|
mListener.onGestureBegin(this);
|
||||||
|
}
|
||||||
|
mGestureInProgress = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops the current gesture and calls the listener right after stopping it. */
|
||||||
|
private void stopGesture() {
|
||||||
|
if (mGestureInProgress) {
|
||||||
|
mGestureInProgress = false;
|
||||||
|
if (mListener != null) {
|
||||||
|
mListener.onGestureEnd(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the index of the i-th pressed pointer. Normally, the index will be equal to i, except in
|
||||||
|
* the case when the pointer is released.
|
||||||
|
*
|
||||||
|
* @return index of the specified pointer or -1 if not found (i.e. not enough pointers are down)
|
||||||
|
*/
|
||||||
|
private int getPressedPointerIndex(MotionEvent event, int i) {
|
||||||
|
final int count = event.getPointerCount();
|
||||||
|
final int action = event.getActionMasked();
|
||||||
|
final int index = event.getActionIndex();
|
||||||
|
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) {
|
||||||
|
if (i >= index) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (i < count) ? i : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the number of pressed pointers (fingers down). */
|
||||||
|
private static int getPressedPointerCount(MotionEvent event) {
|
||||||
|
int count = event.getPointerCount();
|
||||||
|
int action = event.getActionMasked();
|
||||||
|
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) {
|
||||||
|
count--;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updatePointersOnTap(MotionEvent event) {
|
||||||
|
mPointerCount = 0;
|
||||||
|
for (int i = 0; i < MAX_POINTERS; i++) {
|
||||||
|
int index = getPressedPointerIndex(event, i);
|
||||||
|
if (index == -1) {
|
||||||
|
mId[i] = MotionEvent.INVALID_POINTER_ID;
|
||||||
|
} else {
|
||||||
|
mId[i] = event.getPointerId(index);
|
||||||
|
mCurrentX[i] = mStartX[i] = event.getX(index);
|
||||||
|
mCurrentY[i] = mStartY[i] = event.getY(index);
|
||||||
|
mPointerCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updatePointersOnMove(MotionEvent event) {
|
||||||
|
for (int i = 0; i < MAX_POINTERS; i++) {
|
||||||
|
int index = event.findPointerIndex(mId[i]);
|
||||||
|
if (index != -1) {
|
||||||
|
mCurrentX[i] = event.getX(index);
|
||||||
|
mCurrentY[i] = event.getY(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the given motion event.
|
||||||
|
*
|
||||||
|
* @param event event to handle
|
||||||
|
* @return whether or not the event was handled
|
||||||
|
*/
|
||||||
|
public boolean onTouchEvent(final MotionEvent event) {
|
||||||
|
switch (event.getActionMasked()) {
|
||||||
|
case MotionEvent.ACTION_MOVE:
|
||||||
|
{
|
||||||
|
// update pointers
|
||||||
|
updatePointersOnMove(event);
|
||||||
|
// start a new gesture if not already started
|
||||||
|
if (!mGestureInProgress && mPointerCount > 0 && shouldStartGesture()) {
|
||||||
|
startGesture();
|
||||||
|
}
|
||||||
|
// notify listener
|
||||||
|
if (mGestureInProgress && mListener != null) {
|
||||||
|
mListener.onGestureUpdate(this);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_DOWN:
|
||||||
|
case MotionEvent.ACTION_POINTER_DOWN:
|
||||||
|
case MotionEvent.ACTION_POINTER_UP:
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
{
|
||||||
|
// restart gesture whenever the number of pointers changes
|
||||||
|
mNewPointerCount = getPressedPointerCount(event);
|
||||||
|
stopGesture();
|
||||||
|
updatePointersOnTap(event);
|
||||||
|
if (mPointerCount > 0 && shouldStartGesture()) {
|
||||||
|
startGesture();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_CANCEL:
|
||||||
|
{
|
||||||
|
mNewPointerCount = 0;
|
||||||
|
stopGesture();
|
||||||
|
reset();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restarts the current gesture (if any). */
|
||||||
|
public void restartGesture() {
|
||||||
|
if (!mGestureInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopGesture();
|
||||||
|
for (int i = 0; i < MAX_POINTERS; i++) {
|
||||||
|
mStartX[i] = mCurrentX[i];
|
||||||
|
mStartY[i] = mCurrentY[i];
|
||||||
|
}
|
||||||
|
startGesture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets whether there is a gesture in progress */
|
||||||
|
public boolean isGestureInProgress() {
|
||||||
|
return mGestureInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the number of pointers after the current gesture */
|
||||||
|
public int getNewPointerCount() {
|
||||||
|
return mNewPointerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the number of pointers in the current gesture */
|
||||||
|
public int getPointerCount() {
|
||||||
|
return mPointerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the start X coordinates for the all pointers Mutable array is exposed for performance
|
||||||
|
* reasons and is not to be modified by the callers.
|
||||||
|
*/
|
||||||
|
public float[] getStartX() {
|
||||||
|
return mStartX;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the start Y coordinates for the all pointers Mutable array is exposed for performance
|
||||||
|
* reasons and is not to be modified by the callers.
|
||||||
|
*/
|
||||||
|
public float[] getStartY() {
|
||||||
|
return mStartY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current X coordinates for the all pointers Mutable array is exposed for performance
|
||||||
|
* reasons and is not to be modified by the callers.
|
||||||
|
*/
|
||||||
|
public float[] getCurrentX() {
|
||||||
|
return mCurrentX;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current Y coordinates for the all pointers Mutable array is exposed for performance
|
||||||
|
* reasons and is not to be modified by the callers.
|
||||||
|
*/
|
||||||
|
public float[] getCurrentY() {
|
||||||
|
return mCurrentY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.gestures;
|
||||||
|
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that detects translation, scale and rotation based on touch events.
|
||||||
|
*
|
||||||
|
* This class notifies its listeners whenever a gesture begins, updates or ends. The instance of
|
||||||
|
* this detector is passed to the listeners, so it can be queried for pivot, translation, scale or
|
||||||
|
* rotation.
|
||||||
|
*/
|
||||||
|
public class TransformGestureDetector implements MultiPointerGestureDetector.Listener {
|
||||||
|
|
||||||
|
/** The listener for receiving notifications when gestures occur. */
|
||||||
|
public interface Listener {
|
||||||
|
/** A callback called right before the gesture is about to start. */
|
||||||
|
public void onGestureBegin(TransformGestureDetector detector);
|
||||||
|
|
||||||
|
/** A callback called each time the gesture gets updated. */
|
||||||
|
public void onGestureUpdate(TransformGestureDetector detector);
|
||||||
|
|
||||||
|
/** A callback called right after the gesture has finished. */
|
||||||
|
public void onGestureEnd(TransformGestureDetector detector);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final MultiPointerGestureDetector mDetector;
|
||||||
|
|
||||||
|
private Listener mListener = null;
|
||||||
|
|
||||||
|
public TransformGestureDetector(MultiPointerGestureDetector multiPointerGestureDetector) {
|
||||||
|
mDetector = multiPointerGestureDetector;
|
||||||
|
mDetector.setListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Factory method that creates a new instance of TransformGestureDetector */
|
||||||
|
public static TransformGestureDetector newInstance() {
|
||||||
|
return new TransformGestureDetector(MultiPointerGestureDetector.newInstance());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the listener.
|
||||||
|
*
|
||||||
|
* @param listener listener to set
|
||||||
|
*/
|
||||||
|
public void setListener(Listener listener) {
|
||||||
|
mListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets the component to the initial state. */
|
||||||
|
public void reset() {
|
||||||
|
mDetector.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the given motion event.
|
||||||
|
*
|
||||||
|
* @param event event to handle
|
||||||
|
* @return whether or not the event was handled
|
||||||
|
*/
|
||||||
|
public boolean onTouchEvent(final MotionEvent event) {
|
||||||
|
return mDetector.onTouchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onGestureBegin(MultiPointerGestureDetector detector) {
|
||||||
|
if (mListener != null) {
|
||||||
|
mListener.onGestureBegin(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onGestureUpdate(MultiPointerGestureDetector detector) {
|
||||||
|
if (mListener != null) {
|
||||||
|
mListener.onGestureUpdate(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onGestureEnd(MultiPointerGestureDetector detector) {
|
||||||
|
if (mListener != null) {
|
||||||
|
mListener.onGestureEnd(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float calcAverage(float[] arr, int len) {
|
||||||
|
float sum = 0;
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
sum += arr[i];
|
||||||
|
}
|
||||||
|
return (len > 0) ? sum / len : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restarts the current gesture (if any). */
|
||||||
|
public void restartGesture() {
|
||||||
|
mDetector.restartGesture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets whether there is a gesture in progress */
|
||||||
|
public boolean isGestureInProgress() {
|
||||||
|
return mDetector.isGestureInProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the number of pointers after the current gesture */
|
||||||
|
public int getNewPointerCount() {
|
||||||
|
return mDetector.getNewPointerCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the number of pointers in the current gesture */
|
||||||
|
public int getPointerCount() {
|
||||||
|
return mDetector.getPointerCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the X coordinate of the pivot point */
|
||||||
|
public float getPivotX() {
|
||||||
|
return calcAverage(mDetector.getStartX(), mDetector.getPointerCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the Y coordinate of the pivot point */
|
||||||
|
public float getPivotY() {
|
||||||
|
return calcAverage(mDetector.getStartY(), mDetector.getPointerCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the X component of the translation */
|
||||||
|
public float getTranslationX() {
|
||||||
|
return calcAverage(mDetector.getCurrentX(), mDetector.getPointerCount())
|
||||||
|
- calcAverage(mDetector.getStartX(), mDetector.getPointerCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the Y component of the translation */
|
||||||
|
public float getTranslationY() {
|
||||||
|
return calcAverage(mDetector.getCurrentY(), mDetector.getPointerCount())
|
||||||
|
- calcAverage(mDetector.getStartY(), mDetector.getPointerCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the scale */
|
||||||
|
public float getScale() {
|
||||||
|
if (mDetector.getPointerCount() < 2) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0];
|
||||||
|
float startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0];
|
||||||
|
float currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0];
|
||||||
|
float currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0];
|
||||||
|
float startDist = (float) Math.hypot(startDeltaX, startDeltaY);
|
||||||
|
float currentDist = (float) Math.hypot(currentDeltaX, currentDeltaY);
|
||||||
|
return currentDist / startDist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the rotation in radians */
|
||||||
|
public float getRotation() {
|
||||||
|
if (mDetector.getPointerCount() < 2) {
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0];
|
||||||
|
float startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0];
|
||||||
|
float currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0];
|
||||||
|
float currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0];
|
||||||
|
float startAngle = (float) Math.atan2(startDeltaY, startDeltaX);
|
||||||
|
float currentAngle = (float) Math.atan2(currentDeltaY, currentDeltaX);
|
||||||
|
return currentAngle - startAngle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.graphics.PointF;
|
||||||
|
import com.facebook.common.logging.FLog;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class for ZoomableController that adds animation capabilities to
|
||||||
|
* DefaultZoomableController.
|
||||||
|
*/
|
||||||
|
public abstract class AbstractAnimatedZoomableController extends DefaultZoomableController {
|
||||||
|
|
||||||
|
private boolean mIsAnimating;
|
||||||
|
private final float[] mStartValues = new float[9];
|
||||||
|
private final float[] mStopValues = new float[9];
|
||||||
|
private final float[] mCurrentValues = new float[9];
|
||||||
|
private final Matrix mNewTransform = new Matrix();
|
||||||
|
private final Matrix mWorkingTransform = new Matrix();
|
||||||
|
|
||||||
|
public AbstractAnimatedZoomableController(TransformGestureDetector transformGestureDetector) {
|
||||||
|
super(transformGestureDetector);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset() {
|
||||||
|
FLog.v(getLogTag(), "reset");
|
||||||
|
stopAnimation();
|
||||||
|
mWorkingTransform.reset();
|
||||||
|
mNewTransform.reset();
|
||||||
|
super.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the zoomable transform is identity matrix, and the controller is idle. */
|
||||||
|
@Override
|
||||||
|
public boolean isIdentity() {
|
||||||
|
return !isAnimating() && super.isIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zooms to the desired scale and positions the image so that the given image point corresponds to
|
||||||
|
* the given view point.
|
||||||
|
*
|
||||||
|
* <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();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.animation.Animator;
|
||||||
|
import android.animation.AnimatorListenerAdapter;
|
||||||
|
import android.animation.ValueAnimator;
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.view.animation.DecelerateInterpolator;
|
||||||
|
import com.facebook.common.internal.Preconditions;
|
||||||
|
import com.facebook.common.logging.FLog;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZoomableController that adds animation capabilities to DefaultZoomableController using standard
|
||||||
|
* Android animation classes
|
||||||
|
*/
|
||||||
|
public class AnimatedZoomableController extends AbstractAnimatedZoomableController {
|
||||||
|
|
||||||
|
private static final Class<?> TAG = AnimatedZoomableController.class;
|
||||||
|
|
||||||
|
private final ValueAnimator mValueAnimator;
|
||||||
|
|
||||||
|
public static AnimatedZoomableController newInstance() {
|
||||||
|
return new AnimatedZoomableController(TransformGestureDetector.newInstance());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
public AnimatedZoomableController(TransformGestureDetector transformGestureDetector) {
|
||||||
|
super(transformGestureDetector);
|
||||||
|
mValueAnimator = ValueAnimator.ofFloat(0, 1);
|
||||||
|
mValueAnimator.setInterpolator(new DecelerateInterpolator());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
@Override
|
||||||
|
public void setTransformAnimated(
|
||||||
|
final Matrix newTransform, long durationMs, @Nullable final Runnable onAnimationComplete) {
|
||||||
|
FLog.v(getLogTag(), "setTransformAnimated: duration %d ms", durationMs);
|
||||||
|
stopAnimation();
|
||||||
|
Preconditions.checkArgument(durationMs > 0);
|
||||||
|
Preconditions.checkState(!isAnimating());
|
||||||
|
setAnimating(true);
|
||||||
|
mValueAnimator.setDuration(durationMs);
|
||||||
|
getTransform().getValues(getStartValues());
|
||||||
|
newTransform.getValues(getStopValues());
|
||||||
|
mValueAnimator.addUpdateListener(
|
||||||
|
new ValueAnimator.AnimatorUpdateListener() {
|
||||||
|
@Override
|
||||||
|
public void onAnimationUpdate(ValueAnimator valueAnimator) {
|
||||||
|
calculateInterpolation(getWorkingTransform(), (float) valueAnimator.getAnimatedValue());
|
||||||
|
AnimatedZoomableController.super.setTransform(getWorkingTransform());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mValueAnimator.addListener(
|
||||||
|
new AnimatorListenerAdapter() {
|
||||||
|
@Override
|
||||||
|
public void onAnimationCancel(Animator animation) {
|
||||||
|
FLog.v(getLogTag(), "setTransformAnimated: animation cancelled");
|
||||||
|
onAnimationStopped();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAnimationEnd(Animator animation) {
|
||||||
|
FLog.v(getLogTag(), "setTransformAnimated: animation finished");
|
||||||
|
onAnimationStopped();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onAnimationStopped() {
|
||||||
|
if (onAnimationComplete != null) {
|
||||||
|
onAnimationComplete.run();
|
||||||
|
}
|
||||||
|
setAnimating(false);
|
||||||
|
getDetector().restartGesture();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mValueAnimator.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
@Override
|
||||||
|
public void stopAnimation() {
|
||||||
|
if (!isAnimating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FLog.v(getLogTag(), "stopAnimation");
|
||||||
|
mValueAnimator.cancel();
|
||||||
|
mValueAnimator.removeAllUpdateListeners();
|
||||||
|
mValueAnimator.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Class<?> getLogTag() {
|
||||||
|
return TAG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,646 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.graphics.PointF;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import androidx.annotation.IntDef;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector;
|
||||||
|
import com.facebook.common.logging.FLog;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
|
||||||
|
/** Zoomable controller that calculates transformation based on touch events. */
|
||||||
|
public class DefaultZoomableController
|
||||||
|
implements ZoomableController, TransformGestureDetector.Listener {
|
||||||
|
|
||||||
|
/** Interface for handling call backs when the image bounds are set. */
|
||||||
|
public interface ImageBoundsListener {
|
||||||
|
void onImageBoundsSet(RectF imageBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@IntDef(
|
||||||
|
flag = true,
|
||||||
|
value = {LIMIT_NONE, LIMIT_TRANSLATION_X, LIMIT_TRANSLATION_Y, LIMIT_SCALE, LIMIT_ALL})
|
||||||
|
@Retention(RetentionPolicy.SOURCE)
|
||||||
|
public @interface LimitFlag {}
|
||||||
|
|
||||||
|
public static final int LIMIT_NONE = 0;
|
||||||
|
public static final int LIMIT_TRANSLATION_X = 1;
|
||||||
|
public static final int LIMIT_TRANSLATION_Y = 2;
|
||||||
|
public static final int LIMIT_SCALE = 4;
|
||||||
|
public static final int LIMIT_ALL = LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y | LIMIT_SCALE;
|
||||||
|
|
||||||
|
private static final float EPS = 1e-3f;
|
||||||
|
|
||||||
|
private static final Class<?> TAG = DefaultZoomableController.class;
|
||||||
|
|
||||||
|
private static final RectF IDENTITY_RECT = new RectF(0, 0, 1, 1);
|
||||||
|
|
||||||
|
private TransformGestureDetector mGestureDetector;
|
||||||
|
|
||||||
|
private @Nullable ImageBoundsListener mImageBoundsListener;
|
||||||
|
|
||||||
|
private @Nullable Listener mListener = null;
|
||||||
|
|
||||||
|
private boolean mIsEnabled = false;
|
||||||
|
private boolean mIsRotationEnabled = false;
|
||||||
|
private boolean mIsScaleEnabled = true;
|
||||||
|
private boolean mIsTranslationEnabled = true;
|
||||||
|
private boolean mIsGestureZoomEnabled = true;
|
||||||
|
|
||||||
|
private float mMinScaleFactor = 1.0f;
|
||||||
|
private float mMaxScaleFactor = 2.0f;
|
||||||
|
|
||||||
|
// View bounds, in view-absolute coordinates
|
||||||
|
private final RectF mViewBounds = new RectF();
|
||||||
|
// Non-transformed image bounds, in view-absolute coordinates
|
||||||
|
private final RectF mImageBounds = new RectF();
|
||||||
|
// Transformed image bounds, in view-absolute coordinates
|
||||||
|
private final RectF mTransformedImageBounds = new RectF();
|
||||||
|
|
||||||
|
private final Matrix mPreviousTransform = new Matrix();
|
||||||
|
private final Matrix mActiveTransform = new Matrix();
|
||||||
|
private final Matrix mActiveTransformInverse = new Matrix();
|
||||||
|
private final float[] mTempValues = new float[9];
|
||||||
|
private final RectF mTempRect = new RectF();
|
||||||
|
private boolean mWasTransformCorrected;
|
||||||
|
|
||||||
|
public static DefaultZoomableController newInstance() {
|
||||||
|
return new DefaultZoomableController(TransformGestureDetector.newInstance());
|
||||||
|
}
|
||||||
|
|
||||||
|
public DefaultZoomableController(TransformGestureDetector gestureDetector) {
|
||||||
|
mGestureDetector = gestureDetector;
|
||||||
|
mGestureDetector.setListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rests the controller. */
|
||||||
|
public void reset() {
|
||||||
|
FLog.v(TAG, "reset");
|
||||||
|
mGestureDetector.reset();
|
||||||
|
mPreviousTransform.reset();
|
||||||
|
mActiveTransform.reset();
|
||||||
|
onTransformChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the zoomable listener. */
|
||||||
|
@Override
|
||||||
|
public void setListener(Listener listener) {
|
||||||
|
mListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets whether the controller is enabled or not. */
|
||||||
|
@Override
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
mIsEnabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets whether the controller is enabled or not. */
|
||||||
|
@Override
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return mIsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets whether the rotation gesture is enabled or not. */
|
||||||
|
public void setRotationEnabled(boolean enabled) {
|
||||||
|
mIsRotationEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets whether the rotation gesture is enabled or not. */
|
||||||
|
public boolean isRotationEnabled() {
|
||||||
|
return mIsRotationEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets whether the scale gesture is enabled or not. */
|
||||||
|
public void setScaleEnabled(boolean enabled) {
|
||||||
|
mIsScaleEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets whether the scale gesture is enabled or not. */
|
||||||
|
public boolean isScaleEnabled() {
|
||||||
|
return mIsScaleEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets whether the translation gesture is enabled or not. */
|
||||||
|
public void setTranslationEnabled(boolean enabled) {
|
||||||
|
mIsTranslationEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets whether the translations gesture is enabled or not. */
|
||||||
|
public boolean isTranslationEnabled() {
|
||||||
|
return mIsTranslationEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the minimum scale factor allowed.
|
||||||
|
*
|
||||||
|
* <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.graphics.PointF;
|
||||||
|
import android.view.GestureDetector;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tap gesture listener for double tap to zoom / unzoom and double-tap-and-drag to zoom.
|
||||||
|
*
|
||||||
|
* @see ZoomableDraweeView#setTapListener(GestureDetector.SimpleOnGestureListener)
|
||||||
|
*/
|
||||||
|
public class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener {
|
||||||
|
private static final int DURATION_MS = 300;
|
||||||
|
private static final int DOUBLE_TAP_SCROLL_THRESHOLD = 20;
|
||||||
|
|
||||||
|
private final ZoomableDraweeView mDraweeView;
|
||||||
|
private final PointF mDoubleTapViewPoint = new PointF();
|
||||||
|
private final PointF mDoubleTapImagePoint = new PointF();
|
||||||
|
private float mDoubleTapScale = 1;
|
||||||
|
private boolean mDoubleTapScroll = false;
|
||||||
|
|
||||||
|
public DoubleTapGestureListener(ZoomableDraweeView zoomableDraweeView) {
|
||||||
|
mDraweeView = zoomableDraweeView;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onDoubleTapEvent(MotionEvent e) {
|
||||||
|
AbstractAnimatedZoomableController zc =
|
||||||
|
(AbstractAnimatedZoomableController) mDraweeView.getZoomableController();
|
||||||
|
PointF vp = new PointF(e.getX(), e.getY());
|
||||||
|
PointF ip = zc.mapViewToImage(vp);
|
||||||
|
switch (e.getActionMasked()) {
|
||||||
|
case MotionEvent.ACTION_DOWN:
|
||||||
|
mDoubleTapViewPoint.set(vp);
|
||||||
|
mDoubleTapImagePoint.set(ip);
|
||||||
|
mDoubleTapScale = zc.getScaleFactor();
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_MOVE:
|
||||||
|
mDoubleTapScroll = mDoubleTapScroll || shouldStartDoubleTapScroll(vp);
|
||||||
|
if (mDoubleTapScroll) {
|
||||||
|
float scale = calcScale(vp);
|
||||||
|
zc.zoomToPoint(scale, mDoubleTapImagePoint, mDoubleTapViewPoint);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
if (mDoubleTapScroll) {
|
||||||
|
float scale = calcScale(vp);
|
||||||
|
zc.zoomToPoint(scale, mDoubleTapImagePoint, mDoubleTapViewPoint);
|
||||||
|
} else {
|
||||||
|
final float maxScale = zc.getMaxScaleFactor();
|
||||||
|
final float minScale = zc.getMinScaleFactor();
|
||||||
|
if (zc.getScaleFactor() < (maxScale + minScale) / 2) {
|
||||||
|
zc.zoomToPoint(
|
||||||
|
maxScale, ip, vp, DefaultZoomableController.LIMIT_ALL, DURATION_MS, null);
|
||||||
|
} else {
|
||||||
|
zc.zoomToPoint(
|
||||||
|
minScale, ip, vp, DefaultZoomableController.LIMIT_ALL, DURATION_MS, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mDoubleTapScroll = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldStartDoubleTapScroll(PointF viewPoint) {
|
||||||
|
double dist =
|
||||||
|
Math.hypot(viewPoint.x - mDoubleTapViewPoint.x, viewPoint.y - mDoubleTapViewPoint.y);
|
||||||
|
return dist > DOUBLE_TAP_SCROLL_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float calcScale(PointF currentViewPoint) {
|
||||||
|
float dy = (currentViewPoint.y - mDoubleTapViewPoint.y);
|
||||||
|
float t = 1 + Math.abs(dy) * 0.001f;
|
||||||
|
return (dy < 0) ? mDoubleTapScale / t : mDoubleTapScale * t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.view.GestureDetector;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
|
||||||
|
/** Wrapper for SimpleOnGestureListener as GestureDetector does not allow changing its listener. */
|
||||||
|
public class GestureListenerWrapper extends GestureDetector.SimpleOnGestureListener {
|
||||||
|
|
||||||
|
private GestureDetector.SimpleOnGestureListener mDelegate;
|
||||||
|
|
||||||
|
public GestureListenerWrapper() {
|
||||||
|
mDelegate = new GestureDetector.SimpleOnGestureListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setListener(GestureDetector.SimpleOnGestureListener listener) {
|
||||||
|
mDelegate = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLongPress(MotionEvent e) {
|
||||||
|
mDelegate.onLongPress(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
|
||||||
|
return mDelegate.onScroll(e1, e2, distanceX, distanceY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
|
||||||
|
return mDelegate.onFling(e1, e2, velocityX, velocityY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onShowPress(MotionEvent e) {
|
||||||
|
mDelegate.onShowPress(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onDown(MotionEvent e) {
|
||||||
|
return mDelegate.onDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onDoubleTap(MotionEvent e) {
|
||||||
|
return mDelegate.onDoubleTap(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onDoubleTapEvent(MotionEvent e) {
|
||||||
|
return mDelegate.onDoubleTapEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onSingleTapConfirmed(MotionEvent e) {
|
||||||
|
return mDelegate.onSingleTapConfirmed(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onSingleTapUp(MotionEvent e) {
|
||||||
|
return mDelegate.onSingleTapUp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.view.GestureDetector;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesture listener that allows multiple child listeners to be added and notified about gesture
|
||||||
|
* events.
|
||||||
|
*
|
||||||
|
* NOTE: The order of the listeners is important. Listeners can consume gesture events. For
|
||||||
|
* example, if one of the child listeners consumes {@link #onLongPress(MotionEvent)} (the listener
|
||||||
|
* returned true), subsequent listeners will not be notified about the event any more since it has
|
||||||
|
* been consumed.
|
||||||
|
*/
|
||||||
|
public class MultiGestureListener extends GestureDetector.SimpleOnGestureListener {
|
||||||
|
|
||||||
|
private final List<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
|
||||||
|
public class MultiZoomableControllerListener implements ZoomableController.Listener {
|
||||||
|
|
||||||
|
private final List<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for implementing a controller that works with {@link ZoomableDraweeView} to control the
|
||||||
|
* zoom.
|
||||||
|
*/
|
||||||
|
public interface ZoomableController {
|
||||||
|
|
||||||
|
/** Listener interface. */
|
||||||
|
interface Listener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the view that the transform began.
|
||||||
|
*
|
||||||
|
* @param transform the current transform matrix
|
||||||
|
*/
|
||||||
|
void onTransformBegin(Matrix transform);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the view that the transform changed.
|
||||||
|
*
|
||||||
|
* @param transform the new matrix
|
||||||
|
*/
|
||||||
|
void onTransformChanged(Matrix transform);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the view that the transform ended.
|
||||||
|
*
|
||||||
|
* @param transform the current transform matrix
|
||||||
|
*/
|
||||||
|
void onTransformEnd(Matrix transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables the controller. The controller is enabled when the image has been loaded.
|
||||||
|
*
|
||||||
|
* @param enabled whether to enable the controller
|
||||||
|
*/
|
||||||
|
void setEnabled(boolean enabled);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets whether the controller is enabled. This should return the last value passed to {@link
|
||||||
|
* #setEnabled}.
|
||||||
|
*
|
||||||
|
* @return whether the controller is enabled.
|
||||||
|
*/
|
||||||
|
boolean isEnabled();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the listener for the controller to call back when the matrix changes.
|
||||||
|
*
|
||||||
|
* @param listener the listener
|
||||||
|
*/
|
||||||
|
void setListener(Listener listener);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current scale factor. A convenience method for calculating the scale from the
|
||||||
|
* transform.
|
||||||
|
*
|
||||||
|
* @return the current scale factor
|
||||||
|
*/
|
||||||
|
float getScaleFactor();
|
||||||
|
|
||||||
|
/** Returns true if the zoomable transform is identity matrix, and the controller is idle. */
|
||||||
|
boolean isIdentity();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the transform was corrected during the last update.
|
||||||
|
*
|
||||||
|
* <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();
|
||||||
|
|
||||||
|
/** See {@link androidx.core.view.ScrollingView}. */
|
||||||
|
int computeHorizontalScrollRange();
|
||||||
|
|
||||||
|
int computeHorizontalScrollOffset();
|
||||||
|
|
||||||
|
int computeHorizontalScrollExtent();
|
||||||
|
|
||||||
|
int computeVerticalScrollRange();
|
||||||
|
|
||||||
|
int computeVerticalScrollOffset();
|
||||||
|
|
||||||
|
int computeVerticalScrollExtent();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current transform.
|
||||||
|
*
|
||||||
|
* @return the transform
|
||||||
|
*/
|
||||||
|
Matrix getTransform();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the bounds of the image post transform prior to application of the zoomable
|
||||||
|
* transformation.
|
||||||
|
*
|
||||||
|
* @param imageBounds the bounds of the image
|
||||||
|
*/
|
||||||
|
void setImageBounds(RectF imageBounds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the bounds of the view.
|
||||||
|
*
|
||||||
|
* @param viewBounds the bounds of the view
|
||||||
|
*/
|
||||||
|
void setViewBounds(RectF viewBounds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows the controller to handle a touch event.
|
||||||
|
*
|
||||||
|
* @param event the touch event
|
||||||
|
* @return whether the controller handled the event
|
||||||
|
*/
|
||||||
|
boolean onTouchEvent(MotionEvent event);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
package fr.free.nrw.commons.media.zoomControllers.zoomable;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.res.Resources;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.graphics.drawable.Animatable;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.GestureDetector;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.view.ScrollingView;
|
||||||
|
import com.facebook.common.internal.Preconditions;
|
||||||
|
import com.facebook.common.logging.FLog;
|
||||||
|
import com.facebook.drawee.controller.AbstractDraweeController;
|
||||||
|
import com.facebook.drawee.controller.BaseControllerListener;
|
||||||
|
import com.facebook.drawee.controller.ControllerListener;
|
||||||
|
import com.facebook.drawee.drawable.ScalingUtils;
|
||||||
|
import com.facebook.drawee.generic.GenericDraweeHierarchy;
|
||||||
|
import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder;
|
||||||
|
import com.facebook.drawee.generic.GenericDraweeHierarchyInflater;
|
||||||
|
import com.facebook.drawee.interfaces.DraweeController;
|
||||||
|
import com.facebook.drawee.view.DraweeView;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DraweeView that has zoomable capabilities.
|
||||||
|
*
|
||||||
|
* <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 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) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/src/main/res/layout/activity_zoomable.xml
Normal file
17
app/src/main/res/layout/activity_zoomable.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/black"
|
||||||
|
tools:context="fr.free.nrw.commons.media.ZoomableActivity">
|
||||||
|
|
||||||
|
<fr.free.nrw.commons.media.zoomControllers.zoomable.ZoomableDraweeView
|
||||||
|
android:id="@+id/zoomable"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
app:actualImageScaleType="fitCenter"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
@ -40,10 +40,11 @@
|
||||||
|
|
||||||
<!-- Placeholder. Height gets set at runtime based on container size; the initial value is a hack to keep
|
<!-- Placeholder. Height gets set at runtime based on container size; the initial value is a hack to keep
|
||||||
the detail info offscreen until it's placed properly. May be a better way to do this. -->
|
the detail info offscreen until it's placed properly. May be a better way to do this. -->
|
||||||
<fr.free.nrw.commons.media.MediaDetailSpacer
|
<com.facebook.drawee.view.SimpleDraweeView
|
||||||
android:id="@+id/mediaDetailSpacer"
|
android:id="@+id/mediaDetailImageView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="@dimen/standard_gap" />
|
android:layout_height="@dimen/dimen_250"
|
||||||
|
app:actualImageScaleType="none" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue