package de.jeisfeld.augendiagnoselib.components;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.os.Bundle;
import android.os.Handler;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.widget.ImageView;
import de.jeisfeld.augendiagnoselib.util.imagefile.ImageUtil;
/**
* A view for displaying an image, allowing moving and resizing with pinching.
*/
public class PinchImageView extends ImageView {
/**
* The default maximum resolution of a bitmap.
*/
private static final int DEFAULT_MAX_BITMAP_SIZE = 2048;
/**
* One half - the relative middle position.
*/
protected static final float ONE_HALF = 0.5f;
/**
* The minimum scale factor allowed.
*/
private static final float MIN_SCALE_FACTOR = 0.1f;
/**
* The maximum scale factor allowed.
*/
private static final float MAX_SCALE_FACTOR = 30f;
// PUBLIC_FIELDS:START
// Fields are used also in OverlayPinchImageView.
/**
* Pointer id used in case of invalid pointer.
*/
protected static final int INVALID_POINTER_ID = -1;
/**
* Indicator if the view is initialized with the image bitmap, i.e. the initial scaling has been done.
*/
protected boolean mInitialized = false;
/**
* Indicator if the view has been populated with the bitmap.
*/
protected boolean mIsBitmapSet = false;
/**
* Field used to check if a gesture was moving the image (then no context menu will appear).
*/
private boolean mHasMoved = false;
/**
* These are the relative positions of the Bitmap which are displayed in center of the screen. Range: [0,1]
*/
protected float mPosX, mPosY;
/**
* This is the scale factor of the image. Value 1 means that one image pixel corresponds to one screen pixel.
*/
protected float mScaleFactor = 1.f;
/**
* The last touch position.
*/
protected float mLastTouchX, mLastTouchY;
/**
* The last average touch position (used when pinching and moving at the same time).
*/
protected float mLastTouchX0, mLastTouchY0;
/**
* The primary pointer id.
*/
protected int mActivePointerId = INVALID_POINTER_ID;
/**
* The secondary pointer id.
*/
protected int mActivePointerId2 = INVALID_POINTER_ID;
/**
* A ScaleGestureDetector detecting the scale change.
*/
protected ScaleGestureDetector mScaleDetector;
/**
* An additional GestureDetector which may be applied.
*/
@Nullable
private GestureDetector mGestureDetector = null;
/**
* The path name of the displayed image.
*/
@Nullable
protected String mPathName = null;
/**
* The resource id of the displayed image.
*/
private int mImageResource = -1;
/**
* The displayed bitmap.
*/
@Nullable
protected Bitmap mBitmap;
/**
* The maximum allowed resolution of the bitmap. The image is scaled to this size.
*/
protected static int mMaxBitmapSize = DEFAULT_MAX_BITMAP_SIZE;
/**
* The last scale factor.
*/
protected float mLastScaleFactor = 1.f;
// PUBLIC_FIELDS:END
/**
* Standard constructor to be implemented for all views.
*
* @param context The Context the view is running in, through which it can access the current theme, resources, etc.
* @see android.view.View#View(Context)
*/
public PinchImageView(final Context context) {
this(context, null, 0);
}
/**
* Standard constructor to be implemented for all views.
*
* @param context The Context the view is running in, through which it can access the current theme, resources, etc.
* @param attrs The attributes of the XML tag that is inflating the view.
* @see android.view.View#View(Context, AttributeSet)
*/
public PinchImageView(final Context context, final AttributeSet attrs) {
this(context, attrs, 0);
}
/**
* Standard constructor to be implemented for all views.
*
* @param context The Context the view is running in, through which it can access the current theme, resources, etc.
* @param attrs The attributes of the XML tag that is inflating the view.
* @param defStyle An attribute in the current theme that contains a reference to a style resource that supplies default
* values for the view. Can be 0 to not look for defaults.
* @see android.view.View#View(Context, AttributeSet, int)
*/
public PinchImageView(final Context context, final AttributeSet attrs, final int defStyle) {
super(context, attrs, defStyle);
setScaleType(ScaleType.MATRIX);
mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
}
/**
* Fill with an image, making the image fit into the view. If the pathName is unchanged (restored), then it is not
* refilled. The sizing (for fit) happens only once at first initialization of the view.
*
* @param pathName The pathname of the image
* @param activity The triggering activity (required for bitmap caching)
* @param cacheIndex A unique index of the view in the activity
*/
// OVERRIDABLE
public void setImage(@NonNull final String pathName, @NonNull final Activity activity, final int cacheIndex) {
// retrieve bitmap from cache if possible
final RetainFragment retainFragment = RetainFragment.findOrCreateRetainFragment(activity.getFragmentManager(),
cacheIndex);
if (!pathName.equals(mPathName)) {
retainFragment.setBitmap(null);
}
mBitmap = retainFragment.getBitmap();
if (mBitmap == null) {
final Handler handler = new Handler();
// populate bitmaps in separate thread, so that screen keeps fluid.
// This also ensures that this happens only after view is visible and sized.
new Thread() {
@Override
public void run() {
mBitmap = ImageUtil.getImageBitmap(pathName, mMaxBitmapSize);
retainFragment.mRetainBitmap = mBitmap;
mPathName = pathName;
handler.post(new Runnable() {
@Override
public void run() {
PinchImageView.super.setImageBitmap(mBitmap);
mIsBitmapSet = true;
doInitialScaling();
}
});
}
}.start();
}
else {
super.setImageBitmap(mBitmap);
mIsBitmapSet = true;
doInitialScaling();
}
}
/**
* Fill with an image from image resource, making the image fit into the view.
*
* @param imageResource The image resource id
* @param activity The triggering activity (required for bitmap caching)
* @param cacheIndex A unique index of the view in the activity
*/
public final void setImage(final int imageResource, @NonNull final Activity activity, final int cacheIndex) {
// retrieve bitmap from cache if possible
final RetainFragment retainFragment = RetainFragment.findOrCreateRetainFragment(activity.getFragmentManager(),
cacheIndex);
if (imageResource != mImageResource) {
retainFragment.setBitmap(null);
}
mBitmap = retainFragment.getBitmap();
if (mBitmap == null || imageResource != mImageResource) {
final Handler handler = new Handler();
new Thread() {
@Override
public void run() {
mBitmap = BitmapFactory.decodeResource(getResources(), imageResource);
retainFragment.setBitmap(mBitmap);
mImageResource = imageResource;
handler.post(new Runnable() {
@Override
public void run() {
PinchImageView.super.setImageBitmap(mBitmap);
mIsBitmapSet = true;
doInitialScaling();
}
});
}
}.start();
}
else {
super.setImageBitmap(mBitmap);
mIsBitmapSet = true;
doInitialScaling();
}
}
/**
* Fill with an image from a bitmap object, making the image fit into the view.
*
* @param bitmap The image resource id
*/
public final void setImage(final Bitmap bitmap) {
// do not use retainFragment in this case - only used on CameraActivity, which is landscape only.
mBitmap = bitmap;
super.setImageBitmap(mBitmap);
mIsBitmapSet = true;
mInitialized = false;
doInitialScaling();
}
/**
* Return the natural scale factor that fits the image into the view.
*
* @return The natural scale factor fitting the image into the view.
*/
private float getNaturalScaleFactor() {
float heightFactor = 1f * getHeight() / mBitmap.getHeight();
float widthFactor = 1f * getWidth() / mBitmap.getWidth();
return Math.min(widthFactor, heightFactor);
}
/**
* Return an orientation independent scale factor that fits the smaller image dimension into the smaller view
* dimension.
*
* @return A scale factor fitting the image independent of the orientation.
*/
protected final float getOrientationIndependentScaleFactor() {
float viewSize = Math.min(getWidth(), getHeight());
float imageSize = Math.min(mBitmap.getWidth(), mBitmap.getHeight());
return 1f * viewSize / imageSize;
}
/**
* Scale the image to fit into the view.
*/
// OVERRIDABLE
protected void doInitialScaling() {
if (mIsBitmapSet && !mInitialized) {
mPosX = ONE_HALF;
mPosY = ONE_HALF;
mScaleFactor = getNaturalScaleFactor();
if (mScaleFactor > 0) {
mInitialized = true;
mLastScaleFactor = mScaleFactor;
requestLayout();
invalidate();
}
}
}
/**
* Set the maximum size in which a bitmap is held in memory.
*
* @param size the maximum size (pixels)
*/
public static void setMaxBitmapSize(final int size) {
mMaxBitmapSize = size;
}
/**
* Redo the scaling.
*/
// OVERRIDABLE
protected void setMatrix() {
if (mBitmap != null) {
Matrix matrix = new Matrix();
matrix.setTranslate(-mPosX * mBitmap.getWidth(), -mPosY * mBitmap.getHeight());
matrix.postScale(mScaleFactor, mScaleFactor);
matrix.postTranslate(getWidth() / 2, getHeight() / 2);
setImageMatrix(matrix);
}
}
/**
* Override requestLayout to reposition the image.
*/
// OVERRIDABLE
@Override
public void requestLayout() {
super.requestLayout();
setMatrix();
}
@Override
protected final void onSizeChanged(final int w, final int h, final int oldw, final int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (mIsBitmapSet) {
if (mInitialized) {
requestLayout();
invalidate();
}
else {
doInitialScaling();
}
}
}
/*
* Method to do the scaling based on pinching.
*/
// OVERRIDABLE
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(@NonNull final MotionEvent ev) {
boolean isProcessed = false;
// Let the ScaleGestureDetector inspect all events.
mScaleDetector.onTouchEvent(ev);
// If available, do the same for the Gesture Detector.
if (mGestureDetector != null) {
isProcessed = mGestureDetector.onTouchEvent(ev);
}
final int action = ev.getActionMasked();
switch (action) {
case MotionEvent.ACTION_DOWN:
startPointerMove(ev);
mHasMoved = false;
mLastTouchX = ev.getX();
mLastTouchY = ev.getY();
mActivePointerId = ev.getPointerId(0);
break;
case MotionEvent.ACTION_POINTER_DOWN:
mHasMoved = true;
if (ev.getPointerCount() == 2) {
final int pointerIndex = ev.getActionIndex();
mActivePointerId2 = ev.getPointerId(pointerIndex);
mLastTouchX0 = (ev.getX(pointerIndex) + mLastTouchX) / 2;
mLastTouchY0 = (ev.getY(pointerIndex) + mLastTouchY) / 2;
}
break;
case MotionEvent.ACTION_MOVE:
// Prevent NullPointerException if bitmap is not yet loaded
if (mBitmap != null) {
boolean moved = handlePointerMove(ev);
mHasMoved = mHasMoved || moved;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (!mHasMoved && !isProcessed) {
isProcessed = super.performClick();
}
mHasMoved = false;
mActivePointerId = INVALID_POINTER_ID;
mActivePointerId2 = INVALID_POINTER_ID;
finishPointerMove(ev);
break;
case MotionEvent.ACTION_POINTER_UP:
final int pointerIndex = ev.getActionIndex();
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
// This was our active pointer going up. Choose a new active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mLastTouchX = ev.getX(newPointerIndex);
mLastTouchY = ev.getY(newPointerIndex);
mActivePointerId = ev.getPointerId(newPointerIndex);
if (mActivePointerId == mActivePointerId2) {
mActivePointerId2 = INVALID_POINTER_ID;
}
}
else if (pointerId == mActivePointerId2) {
mActivePointerId2 = INVALID_POINTER_ID;
}
break;
default:
break;
}
return isProcessed || !isLongClickable() || super.onTouchEvent(ev);
}
/*
* Perform long click only if no move has happened.
*/
@Override
public final boolean performLongClick() {
return mHasMoved || super.performLongClick();
}
/**
* Utility method to allow actions after starting the pointer move.
*
* @param ev The motion event.
*/
// OVERRIDABLE
protected void startPointerMove(final MotionEvent ev) {
// do nothing
}
/**
* Utility method to do the refresh after finishing the pointer move.
*
* @param ev The motion event.
*/
// OVERRIDABLE
protected void finishPointerMove(final MotionEvent ev) {
// do nothing
}
/**
* Utility method to make the calculations in case of a pointer move.
*
* @param ev The motion event.
* @return true if a move has been made (i.e. the position of the image changed).
*/
// OVERRIDABLE
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(value = "FE_FLOATING_POINT_EQUALITY",
justification = "Using floating point equality to see if value has changed")
protected boolean handlePointerMove(@NonNull final MotionEvent ev) {
if (!mInitialized) {
return false;
}
boolean moved = false;
final int pointerIndex = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(pointerIndex);
final float y = ev.getY(pointerIndex);
if (mActivePointerId2 == INVALID_POINTER_ID) {
// Only move if the ScaleGestureDetector isn't processing a gesture.
final float dx = x - mLastTouchX;
final float dy = y - mLastTouchY;
mPosX -= dx / mScaleFactor / mBitmap.getWidth();
mPosY -= dy / mScaleFactor / mBitmap.getHeight();
}
else {
// When resizing, move according to the center of the two pinch points
final int pointerIndex2 = ev.findPointerIndex(mActivePointerId2);
final float x0 = (ev.getX(pointerIndex2) + x) / 2;
final float y0 = (ev.getY(pointerIndex2) + y) / 2;
final float dx = x0 - mLastTouchX0;
final float dy = y0 - mLastTouchY0;
mPosX -= dx / mScaleFactor / mBitmap.getWidth();
mPosY -= dy / mScaleFactor / mBitmap.getHeight();
if (mScaleFactor != mLastScaleFactor) {
// When resizing, then position also changes
final float changeFactor = mScaleFactor / mLastScaleFactor;
mPosX = mPosX + (x0 - getWidth() / 2) * (changeFactor - 1) / mScaleFactor / mBitmap.getWidth();
mPosY = mPosY + (y0 - getHeight() / 2) * (changeFactor - 1) / mScaleFactor / mBitmap.getHeight();
mLastScaleFactor = mScaleFactor;
moved = true;
}
mLastTouchX0 = x0;
mLastTouchY0 = y0;
}
if (x != mLastTouchX || y != mLastTouchY) {
mLastTouchX = x;
mLastTouchY = y;
moved = true;
}
// setMatrix invalidates if matrix is changed.
setMatrix();
return moved;
}
public final void setGestureDetector(final GestureDetector gestureDetector) {
this.mGestureDetector = gestureDetector;
}
/*
* Save scale factor, center position, path name and bitmap. (Bitmap to be retained if the view is recreated with
* same pathname.)
*/
// OVERRIDABLE
@NonNull
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putParcelable("instanceState", super.onSaveInstanceState());
bundle.putFloat("mScaleFactor", this.mScaleFactor);
bundle.putFloat("mPosX", this.mPosX);
bundle.putFloat("mPosY", this.mPosY);
bundle.putString("mPathName", this.mPathName);
bundle.putInt("mImageResource", this.mImageResource);
bundle.putBoolean("mInitialized", mInitialized);
return bundle;
}
// OVERRIDABLE
@Override
protected void onRestoreInstanceState(final Parcelable state) {
Parcelable enhancedState = state;
if (state instanceof Bundle) {
Bundle bundle = (Bundle) state;
this.mScaleFactor = bundle.getFloat("mScaleFactor");
this.mLastScaleFactor = this.mScaleFactor;
this.mPosX = bundle.getFloat("mPosX");
this.mPosY = bundle.getFloat("mPosY");
this.mPathName = bundle.getString("mPathName");
this.mImageResource = bundle.getInt("mImageResource");
this.mInitialized = bundle.getBoolean("mInitialized");
enhancedState = bundle.getParcelable("instanceState");
}
super.onRestoreInstanceState(enhancedState);
}
/**
* A listener determining the scale factor.
*/
protected class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public final boolean onScale(@NonNull final ScaleGestureDetector detector) {
mScaleFactor *= detector.getScaleFactor();
// Don't let the object get too small or too large.
mScaleFactor = Math.max(MIN_SCALE_FACTOR, Math.min(mScaleFactor, MAX_SCALE_FACTOR));
invalidate();
return true;
}
}
/**
* Helper listFoldersFragment to retain the bitmap on configuration change.
*/
public static class RetainFragment extends Fragment {
/**
* Tag to be used as identifier of the fragment.
*/
private static final String TAG = "RetainFragment";
/**
* The bitmap to be stored.
*/
@Nullable
private Bitmap mRetainBitmap;
@Nullable
public final Bitmap getBitmap() {
return mRetainBitmap;
}
public final void setBitmap(final Bitmap bitmap) {
this.mRetainBitmap = bitmap;
}
/**
* Get the retainFragment - search it by the index. If not found, create a new one.
*
* @param fm The fragment manager handling this fragment.
* @param index The index of the view (required in case of multiple PinchImageViews to be retained).
* @return the retainFragment.
*/
@NonNull
public static RetainFragment findOrCreateRetainFragment(@NonNull final FragmentManager fm, final int index) {
RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG + index);
if (fragment == null) {
fragment = new RetainFragment();
fm.beginTransaction().add(fragment, TAG + index).commit();
}
return fragment;
}
@Override
public final void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
}
}