Fixes #3414: For v2.13, Handle zoom in media details view (#3422)

* MediaDetailFragment: add zoom feature

* fragment_media_detail: add SimpleDrawee for Scroll picture

* ZoomableActivity: activity which facilitates zoom in

* activity_zoomable: xml for zoom activity

* zoomControllers: controllers for handling gesture and zooming

* MediaDetailFragment: fixing name of image variable

* MediaDetailFragment: display as per the aspect ratio of image

* add zoom activity to AndroidManifest

* fix travis ci faliure

* fix resizing of image
This commit is contained in:
Kshitij Bhardwaj 2020-03-09 14:07:48 -04:00 committed by GitHub
parent 5fd88ef1a8
commit 642ed51c8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 2295 additions and 32 deletions

View file

@ -41,6 +41,9 @@
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
<activity
android:name=".media.ZoomableActivity" />
<activity android:name=".auth.LoginActivity">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER" />

View file

@ -1,8 +1,10 @@
package fr.free.nrw.commons.media;
import android.annotation.SuppressLint;
import android.graphics.drawable.Animatable;
import android.app.AlertDialog;
import android.content.Intent;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.text.Editable;
@ -23,7 +25,10 @@ import android.widget.Toast;
import com.facebook.drawee.backends.pipeline.Fresco;
import com.facebook.drawee.interfaces.DraweeController;
import com.facebook.drawee.controller.BaseControllerListener;
import com.facebook.drawee.controller.ControllerListener;
import com.facebook.drawee.view.SimpleDraweeView;
import com.facebook.imagepipeline.image.ImageInfo;
import com.facebook.imagepipeline.request.ImageRequest;
import org.apache.commons.lang3.StringUtils;
@ -39,6 +44,7 @@ import javax.inject.Inject;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.MediaDataExtractor;
import fr.free.nrw.commons.R;
@ -97,10 +103,8 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment {
private int initialListTop = 0;
@BindView(R.id.mediaDetailImage)
@BindView(R.id.mediaDetailImageView)
SimpleDraweeView image;
@BindView(R.id.mediaDetailSpacer)
MediaDetailSpacer spacer;
@BindView(R.id.mediaDetailTitle)
TextView title;
@BindView(R.id.mediaDetailDesc)
@ -197,36 +201,18 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment {
// Progressively darken the image in the background when we scroll detail pane up
scrollListener = this::updateTheDarkness;
view.getViewTreeObserver().addOnScrollChangedListener(scrollListener);
// Layout layoutListener to size the spacer item relative to the available space.
// There may be a .... better way to do this.
layoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
private int currentHeight = -1;
@Override
public void onGlobalLayout() {
int viewHeight = view.getHeight();
//int textHeight = title.getLineHeight();
int paddingDp = 112;
float paddingPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingDp, getResources().getDisplayMetrics());
int newHeight = viewHeight - Math.round(paddingPx);
if (newHeight != currentHeight) {
currentHeight = newHeight;
ViewGroup.LayoutParams params = spacer.getLayoutParams();
params.height = newHeight;
spacer.setLayoutParams(params);
scrollView.scrollTo(0, initialListTop);
}
}
};
view.getViewTreeObserver().addOnGlobalLayoutListener(layoutListener);
locale = getResources().getConfiguration().locale;
return view;
}
@OnClick(R.id.mediaDetailImageView)
public void launchZoomActivity(View view) {
Context ctx = view.getContext();
ctx.startActivity(
new Intent(ctx,ZoomableActivity.class).setData(Uri.parse(media.getImageUrl()))
);
}
@Override
public void onResume() {
super.onResume();
@ -255,6 +241,26 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment {
compositeDisposable.add(disposable);
}
private void updateAspectRatio(ImageInfo imageInfo) {
if (imageInfo != null) {
int finalHeight = (scrollView.getWidth()*imageInfo.getHeight()) / imageInfo.getWidth();
ViewGroup.LayoutParams params = image.getLayoutParams();
params.height = finalHeight;
image.setLayoutParams(params);
}
}
private final ControllerListener aspectRatioListener = new BaseControllerListener<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.
* - low resolution thumbnail is shown initially
@ -264,6 +270,7 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment {
DraweeController controller = Fresco.newDraweeControllerBuilder()
.setLowResImageRequest(ImageRequest.fromUri(media.getThumbUrl()))
.setImageRequest(ImageRequest.fromUri(media.getImageUrl()))
.setControllerListener(aspectRatioListener)
.setOldController(image.getController())
.build();
image.setController(controller);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -40,10 +40,11 @@
<!-- 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. -->
<fr.free.nrw.commons.media.MediaDetailSpacer
android:id="@+id/mediaDetailSpacer"
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/mediaDetailImageView"
android:layout_width="match_parent"
android:layout_height="@dimen/standard_gap" />
android:layout_height="@dimen/dimen_250"
app:actualImageScaleType="none" />
<LinearLayout
android:layout_width="match_parent"