package oak.widget;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.graphics.PointF;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.animation.Animation;
import android.view.animation.Interpolator;
import android.view.animation.Transformation;
import android.widget.ImageView;
import oak.util.ScaleGestureDetector;
/**
* An ImageView that supports pinch-to-zoom, double-tap-to-zoom, and swipe panning.
* <p/>
* Usage: - Set image source via android:src, setImageBitmap(), or setImageResource() - Supports
* scale type of CENTER_CROP (default) and CENTER_INSIDE - Default maximum zoom scale is 2.5x
*
* @author Nate Vogt Based loosely on TouchImageView by Michael Ortiz
*/
public class SwankyImageView extends ImageView {
private Matrix matrix = new Matrix();
// We can be in one of these 3 states
private static final int NONE = 0, DRAG = 1, ZOOM = 2;
private static final float MIN_SCALE = 1f;
private float maxScale = 2.5f;
private static final float FRICTION_K = .01f; // lose this fraction of velocity per millisecond
private static final float VEL_THRESHOLD = .05f; // pixels per millisecond
private static final String ANDROID_SCHEMA = "http://schemas.android.com/apk/res/android";
private int mode = NONE;
private long timeOfLastMoveEvent;
// Remember some things for zooming
private PointF last = new PointF(), start = new PointF();
private float saveScale = 1f;
private float[] m;
private float redundantXSpace, redundantYSpace;
private boolean isCenterInside;
private float viewWidth, viewHeight;
private float right, bottom, origWidth, origHeight, bitmapWidth, bitmapHeight;
private ScaleGestureDetector mScaleDetector;
private GestureDetector mTapSwipeDetector;
private OnClickListener clickListener;
private static final Interpolator ZOOM_INTERPOLATOR = new Interpolator() {
public float getInterpolation(float input) {
return input * 2 - input * input; // parabolic curve, similar to DECELERATE
}
};
private AsyncTask<Void, Void, Void> mFlingTask;
private long lastFrameCompleted;
private static final int MSG_REDRAW = 1, MSG_STOP_ANIM = 2;
private final Handler FLING_ANIM_HANDLER = new Handler() {
private long lastUpdated;
private final int FRAME_RATE = 25; // ms per frame
public void handleMessage(Message msg) {
if (lastUpdated < lastFrameCompleted) {
lastUpdated = lastFrameCompleted;
setImageMatrix(matrix);
invalidate();
} else if (msg.what == MSG_REDRAW) {
sendEmptyMessage(MSG_REDRAW);
return;
}
if (msg.what == MSG_REDRAW) {
sendEmptyMessageDelayed(MSG_REDRAW, FRAME_RATE);
} else if (msg.what == MSG_STOP_ANIM) {
removeMessages(MSG_REDRAW);
removeMessages(MSG_STOP_ANIM);
}
}
};
public SwankyImageView(Context context) {
super(context);
initialize(null);
}
public SwankyImageView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
public SwankyImageView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize(attrs);
}
private void initialize(AttributeSet attrs) {
mScaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener());
mTapSwipeDetector = new GestureDetector(getContext(), new TapSwipeListener());
if (attrs != null) {
// default to centerCrop, but check to see if centerInside is requested
int scaleType = attrs.getAttributeIntValue(ANDROID_SCHEMA, "scaleType", 6);
if (scaleType == 7) {
isCenterInside = true;
}
}
setScaleType(ScaleType.MATRIX);
matrix.setTranslate(1f, 1f);
m = new float[9];
Drawable d = getDrawable();
setImageMatrix(matrix);
if (d != null)
// avoid overwriting src if it was already set in xml
{
setImageBitmap(((BitmapDrawable) d).getBitmap());
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mScaleDetector.onTouchEvent(event);
mTapSwipeDetector.onTouchEvent(event);
matrix.getValues(m);
float x = m[Matrix.MTRANS_X];
float y = m[Matrix.MTRANS_Y];
PointF curr = new PointF(event.getX(), event.getY());
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
last.set(event.getX(), event.getY());
start.set(last);
mode = DRAG;
break;
case MotionEvent.ACTION_MOVE:
timeOfLastMoveEvent = System.currentTimeMillis();
if (mode == DRAG) {
float deltaX = curr.x - last.x;
float deltaY = curr.y - last.y;
float scaleWidth = Math.round(origWidth * saveScale);
float scaleHeight = Math.round(origHeight * saveScale);
if (scaleWidth < viewWidth) {
deltaX = 0;
if (y + deltaY > 0) {
deltaY = -y;
} else if (y + deltaY < -bottom) {
deltaY = -(y + bottom);
}
} else if (scaleHeight < viewHeight) {
deltaY = 0;
if (x + deltaX > 0) {
deltaX = -x;
} else if (x + deltaX < -right) {
deltaX = -(x + right);
}
} else {
if (x + deltaX > 0) {
deltaX = -x;
} else if (x + deltaX < -right) {
deltaX = -(x + right);
}
if (y + deltaY > 0) {
deltaY = -y;
} else if (y + deltaY < -bottom) {
deltaY = -(y + bottom);
}
}
matrix.postTranslate(deltaX, deltaY);
last.set(curr.x, curr.y);
}
break;
case MotionEvent.ACTION_POINTER_UP:
if (event.getPointerCount() == 2) {
int pointerRemaining = event.getAction() == MotionEvent.ACTION_POINTER_2_UP ? 0
: 1;
last.set(event.getX(pointerRemaining), event.getY(pointerRemaining));
mode = DRAG;
}
break;
case MotionEvent.ACTION_UP:
mode = NONE;
break;
}
setImageMatrix(matrix);
invalidate();
return true; // indicate event was handled
}
@Override
public void setImageBitmap(Bitmap bm) {
super.setImageBitmap(bm);
bitmapWidth = bm.getWidth();
bitmapHeight = bm.getHeight();
}
@Override
public void setImageResource(int resId) {
setImageBitmap(BitmapFactory.decodeResource(getResources(), resId));
}
/**
* Fetches the image's current zoom scale, between 1 and max.
*
* @return current scale
*/
public float getCurrentScale() {
return saveScale;
}
/**
* @param scale new max scale value
* @throws IllegalArgumentException if scale is less than or equal to 1
*/
public void setMaxScale(float scale) throws IllegalArgumentException {
if (scale <= 1) {
throw new IllegalArgumentException("Max scale must be greater than 1.");
} else {
resetScale();
maxScale = scale;
}
}
public void resetScale() {
last = new PointF();
start = new PointF();
fitToScreen();
}
private class TapSwipeListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDoubleTap(MotionEvent e) {
ZoomAnimation anim;
if (saveScale > MIN_SCALE) {
anim = new ZoomAnimation(saveScale, MIN_SCALE, e.getX(), e.getY());
} else {
anim = new ZoomAnimation(MIN_SCALE, maxScale, e.getX(), e.getY());
}
startAnimation(anim);
return true;
}
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
onClick();
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
mode = NONE;
mFlingTask = new FlingTask(timeOfLastMoveEvent, velocityX / 1000, velocityY / 1000)
.execute();
return true;
}
}
private class FlingTask extends AsyncTask<Void, Void, Void> {
private long curTimestamp;
private float velX, velY; // pixels per millisecond
public FlingTask(long prevTimestamp, float velX, float velY) {
lastFrameCompleted = timeOfLastMoveEvent;
this.velX = velX;
this.velY = velY;
}
@Override
protected Void doInBackground(Void... params) {
float x, y, deltaX, deltaY, scaleWidth, scaleHeight;
FLING_ANIM_HANDLER.sendEmptyMessage(MSG_REDRAW);
while (!isCancelled()) {
curTimestamp = System.currentTimeMillis();
int deltaT = (int) (curTimestamp - lastFrameCompleted);
matrix.getValues(m);
x = m[Matrix.MTRANS_X];
y = m[Matrix.MTRANS_Y];
deltaX = velX * deltaT;
deltaY = velY * deltaT;
scaleWidth = Math.round(origWidth * saveScale);
scaleHeight = Math.round(origHeight * saveScale);
if (scaleWidth <= viewWidth) {
deltaX = 0;
velX = 0;
} else if (x + deltaX >= 0) {
deltaX = -x;
velX = 0;
} else if (x + deltaX <= -right) {
deltaX = -x - right;
velX = 0;
}
if (scaleHeight <= viewHeight) {
deltaY = 0;
velY = 0;
} else if (y + deltaY >= 0) {
deltaY = -y;
velY = 0;
} else if (y + deltaY <= -bottom) {
deltaY = -y - bottom;
velY = 0;
}
if (velX != 0) {
velX *= 1 - FRICTION_K * deltaT;
}
if (velY != 0) {
velY *= 1 - FRICTION_K * deltaT;
}
if (Math.abs(velX) < VEL_THRESHOLD && Math.abs(velY) < VEL_THRESHOLD) {
velX = velY = 0;
}
matrix.postTranslate(deltaX, deltaY);
lastFrameCompleted = curTimestamp;
if (velX == 0 && velY == 0) {
return null;
}
}
return null;
}
@Override
protected void onPostExecute(Void result) {
FLING_ANIM_HANDLER.sendEmptyMessage(MSG_STOP_ANIM);
mFlingTask = null;
}
@Override
protected void onCancelled() {
FLING_ANIM_HANDLER.sendEmptyMessage(MSG_STOP_ANIM);
mFlingTask = null;
}
}
private class ScaleListener extends
ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
mode = ZOOM;
return true;
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
float mScaleFactor = (float) Math.min(
Math.max(.95f, detector.getScaleFactor()), 1.05);
float origScale = saveScale;
saveScale *= mScaleFactor;
if (saveScale > maxScale) {
saveScale = maxScale;
mScaleFactor = maxScale / origScale;
} else if (saveScale < MIN_SCALE) {
saveScale = MIN_SCALE;
mScaleFactor = MIN_SCALE / origScale;
}
right = viewWidth * saveScale - viewWidth
- (2 * redundantXSpace * saveScale);
bottom = viewHeight * saveScale - viewHeight
- (2 * redundantYSpace * saveScale);
boolean viewWiderThanImg = origWidth * saveScale <= viewWidth;
boolean viewTallerThanImg = origHeight * saveScale <= viewHeight;
if (viewWiderThanImg || viewTallerThanImg) {
if (viewWiderThanImg && !viewTallerThanImg) {
matrix.postScale(mScaleFactor, mScaleFactor, viewWidth / 2,
detector.getFocusY());
} else if (!viewWiderThanImg && viewTallerThanImg) {
matrix.postScale(mScaleFactor, mScaleFactor, detector.getFocusX(),
viewHeight / 2);
} else {
matrix.postScale(mScaleFactor, mScaleFactor, viewWidth / 2, viewHeight / 2);
}
if (mScaleFactor < 1) {
matrix.getValues(m);
float x = m[Matrix.MTRANS_X];
float y = m[Matrix.MTRANS_Y];
if (Math.round(origWidth * saveScale) < viewWidth) {
if (y < -bottom) {
matrix.postTranslate(0, -(y + bottom));
} else if (y > 0) {
matrix.postTranslate(0, -y);
}
} else {
if (x < -right) {
matrix.postTranslate(-(x + right), 0);
} else if (x > 0) {
matrix.postTranslate(-x, 0);
}
}
// why was this here again? removing for now.
// if (!isCenterInside) {
// matrix.postTranslate(-x - right / 2, bottom / 2 - y);
// }
}
} else {
matrix.postScale(mScaleFactor, mScaleFactor, detector.getFocusX(),
detector.getFocusY());
matrix.getValues(m);
float x = m[Matrix.MTRANS_X];
float y = m[Matrix.MTRANS_Y];
if (mScaleFactor < 1) {
if (x < -right) {
matrix.postTranslate(-(x + right), 0);
} else if (x > 0) {
matrix.postTranslate(-x, 0);
}
if (y < -bottom) {
matrix.postTranslate(0, -(y + bottom));
} else if (y > 0) {
matrix.postTranslate(0, -y);
}
}
}
return true;
}
}
@Override
public void setOnClickListener(OnClickListener l) {
clickListener = l;
}
private void onClick() {
if (clickListener != null) {
clickListener.onClick(this);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
viewWidth = MeasureSpec.getSize(widthMeasureSpec);
viewHeight = MeasureSpec.getSize(heightMeasureSpec);
fitToScreen();
}
private void fitToScreen() {
float scale;
if (isCenterInside) {
scale = Math.min(viewWidth / bitmapWidth, viewHeight / bitmapHeight);
} else // centerCrop
{
scale = Math.max(viewWidth / bitmapWidth, viewHeight / bitmapHeight);
}
matrix.setScale(scale, scale);
saveScale = 1f;
redundantYSpace = (viewHeight - scale * bitmapHeight) / 2f;
redundantXSpace = (viewWidth - scale * bitmapWidth) / 2f;
matrix.postTranslate(redundantXSpace, redundantYSpace);
origWidth = viewWidth - 2 * redundantXSpace;
origHeight = viewHeight - 2 * redundantYSpace;
right = viewWidth * saveScale - viewWidth - (2 * redundantXSpace * saveScale);
bottom = viewHeight * saveScale - viewHeight - (2 * redundantYSpace * saveScale);
setImageMatrix(matrix);
}
private class ZoomAnimation extends Animation {
private float startScale, endScale, centerX, centerY;
public ZoomAnimation(float startScale, float endScale, float centerX, float centerY) {
this.startScale = startScale;
this.endScale = endScale;
if (this.startScale > this.endScale) {
matrix.getValues(m);
float x = m[Matrix.MTRANS_X];
float y = m[Matrix.MTRANS_Y];
if (x >= 0) {
this.centerX = viewWidth / 2f;
} else {
this.centerX = viewWidth * ((-x) / right);
}
if (y >= 0) {
this.centerY = viewHeight / 2f;
} else {
this.centerY = viewHeight * ((-y) / bottom);
}
} else {
this.centerX = centerX;
this.centerY = centerY;
}
setDuration(250);
setInterpolator(ZOOM_INTERPOLATOR);
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
float origScale = saveScale;
saveScale = startScale + (endScale - startScale) * interpolatedTime;
float scaleFactor = saveScale / origScale;
right = viewWidth * saveScale - viewWidth
- (2 * redundantXSpace * saveScale);
bottom = viewHeight * saveScale - viewHeight
- (2 * redundantYSpace * saveScale);
boolean viewWiderThanImg = origWidth * saveScale <= viewWidth;
boolean viewTallerThanImg = origHeight * saveScale <= viewHeight;
if (viewWiderThanImg || viewTallerThanImg) {
if (viewWiderThanImg && !viewTallerThanImg) {
matrix.postScale(scaleFactor, scaleFactor, viewWidth / 2, centerY);
} else if (!viewWiderThanImg && viewTallerThanImg) {
matrix.postScale(scaleFactor, scaleFactor, centerX, viewHeight / 2);
} else {
matrix.postScale(scaleFactor, scaleFactor, viewWidth / 2, viewHeight / 2);
}
if (scaleFactor < 1) {
matrix.getValues(m);
float x = m[Matrix.MTRANS_X];
float y = m[Matrix.MTRANS_Y];
if (Math.round(origWidth * saveScale) < viewWidth) {
if (y < -bottom) {
matrix.postTranslate(0, -(y + bottom));
} else if (y > 0) {
matrix.postTranslate(0, -y);
}
} else {
if (x < -right) {
matrix.postTranslate(-(x + right), 0);
} else if (x > 0) {
matrix.postTranslate(-x, 0);
}
}
// why was this here again? removing for now.
// if (!isCenterInside) {
// matrix.postTranslate(-x - right / 2, bottom / 2 - y);
// }
}
} else {
matrix.postScale(scaleFactor, scaleFactor, centerX, centerY);
matrix.getValues(m);
float x = m[Matrix.MTRANS_X];
float y = m[Matrix.MTRANS_Y];
if (scaleFactor < 1) {
if (x < -right) {
matrix.postTranslate(-(x + right), 0);
} else if (x > 0) {
matrix.postTranslate(-x, 0);
}
if (y < -bottom) {
matrix.postTranslate(0, -(y + bottom));
} else if (y > 0) {
matrix.postTranslate(0, -y);
}
}
}
setImageMatrix(matrix);
invalidate();
}
}
public boolean canScrollLeft() {
matrix.getValues(m);
if (m[Matrix.MTRANS_X] < 0) {
return true;
} else {
return false;
}
}
public boolean canScrollRight() {
matrix.getValues(m);
if (-m[Matrix.MTRANS_X] < right) {
return true;
} else {
return false;
}
}
@Override
protected void onWindowVisibilityChanged(int visibility) {
super.onWindowVisibilityChanged(visibility);
if (visibility == GONE || visibility == INVISIBLE) {
if (mFlingTask != null) {
mFlingTask.cancel(true);
mFlingTask = null;
}
FLING_ANIM_HANDLER.sendEmptyMessage(MSG_STOP_ANIM);
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mFlingTask != null) {
mFlingTask.cancel(true);
mFlingTask = null;
}
FLING_ANIM_HANDLER.sendEmptyMessage(MSG_STOP_ANIM);
}
}