/*
* Copyright (C) 2011 Google Inc.
* Licensed to The Android Open Source Project.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.ex.photo.views;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.support.v4.view.GestureDetectorCompat;
import android.support.v4.view.ScaleGestureDetectorCompat;
import android.util.AttributeSet;
import android.view.GestureDetector.OnDoubleTapListener;
import android.view.GestureDetector.OnGestureListener;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import android.view.ViewConfiguration;
import com.android.ex.photo.R;
import com.android.ex.photo.fragments.PhotoViewFragment.HorizontallyScrollable;
/**
* Layout for the photo list view header.
*/
public class PhotoView extends View implements OnGestureListener,
OnDoubleTapListener, ScaleGestureDetector.OnScaleGestureListener,
HorizontallyScrollable {
public static final int TRANSLATE_NONE = 0;
public static final int TRANSLATE_X_ONLY = 1;
public static final int TRANSLATE_Y_ONLY = 2;
public static final int TRANSLATE_BOTH = 3;
/** Zoom animation duration; in milliseconds */
private final static long ZOOM_ANIMATION_DURATION = 200L;
/** Rotate animation duration; in milliseconds */
private final static long ROTATE_ANIMATION_DURATION = 500L;
/** Snap animation duration; in milliseconds */
private static final long SNAP_DURATION = 100L;
/** Amount of time to wait before starting snap animation; in milliseconds */
private static final long SNAP_DELAY = 250L;
/** By how much to scale the image when double click occurs */
private final static float DOUBLE_TAP_SCALE_FACTOR = 2.0f;
/** Amount which can be zoomed in past the maximum scale, and then scaled back */
private final static float SCALE_OVERZOOM_FACTOR = 1.5f;
/** Amount of translation needed before starting a snap animation */
private final static float SNAP_THRESHOLD = 20.0f;
/** The width & height of the bitmap returned by {@link #getCroppedPhoto()} */
private final static float CROPPED_SIZE = 256.0f;
/**
* Touch slop used to determine if this double tap is valid for starting a scale or should be
* ignored.
*/
private static int sTouchSlopSquare;
/** If {@code true}, the static values have been initialized */
private static boolean sInitialized;
// Various dimensions
/** Width & height of the crop region */
private static int sCropSize;
// Bitmaps
/** Video icon */
private static Bitmap sVideoImage;
/** Video icon */
private static Bitmap sVideoNotReadyImage;
// Paints
/** Paint to partially dim the photo during crop */
private static Paint sCropDimPaint;
/** Paint to highlight the cropped portion of the photo */
private static Paint sCropPaint;
/** The photo to display */
private Drawable mDrawable;
/** The matrix used for drawing; this may be {@code null} */
private Matrix mDrawMatrix;
/** A matrix to apply the scaling of the photo */
private Matrix mMatrix = new Matrix();
/** The original matrix for this image; used to reset any transformations applied by the user */
private Matrix mOriginalMatrix = new Matrix();
/** The fixed height of this view. If {@code -1}, calculate the height */
private int mFixedHeight = -1;
/** When {@code true}, the view has been laid out */
private boolean mHaveLayout;
/** Whether or not the photo is full-screen */
private boolean mFullScreen;
/** Whether or not this is a still image of a video */
private byte[] mVideoBlob;
/** Whether or not this is a still image of a video */
private boolean mVideoReady;
/** Whether or not crop is allowed */
private boolean mAllowCrop;
/** The crop region */
private Rect mCropRect = new Rect();
/** Actual crop size; may differ from {@link #sCropSize} if the screen is smaller */
private int mCropSize;
/** The maximum amount of scaling to apply to images */
private float mMaxInitialScaleFactor;
/** Gesture detector */
private GestureDetectorCompat mGestureDetector;
/** Gesture detector that detects pinch gestures */
private ScaleGestureDetector mScaleGetureDetector;
/** An external click listener */
private OnClickListener mExternalClickListener;
/** When {@code true}, allows gestures to scale / pan the image */
private boolean mTransformsEnabled;
// To support zooming
/** When {@code true}, a double tap scales the image by {@link #DOUBLE_TAP_SCALE_FACTOR} */
private boolean mDoubleTapToZoomEnabled = true;
/** When {@code true}, prevents scale end gesture from falsely triggering a double click. */
private boolean mDoubleTapDebounce;
/** When {@code false}, event is a scale gesture. Otherwise, event is a double touch. */
private boolean mIsDoubleTouch;
/** Runnable that scales the image */
private ScaleRunnable mScaleRunnable;
/** Minimum scale the image can have. */
private float mMinScale;
/** Maximum scale to limit scaling to, 0 means no limit. */
private float mMaxScale;
// To support translation [i.e. panning]
/** Runnable that can move the image */
private TranslateRunnable mTranslateRunnable;
private SnapRunnable mSnapRunnable;
// To support rotation
/** The rotate runnable used to animate rotations of the image */
private RotateRunnable mRotateRunnable;
/** The current rotation amount, in degrees */
private float mRotation;
// Convenience fields
// These are declared here not because they are important properties of the view. Rather, we
// declare them here to avoid object allocation during critical graphics operations; such as
// layout or drawing.
/** Source (i.e. the photo size) bounds */
private RectF mTempSrc = new RectF();
/** Destination (i.e. the display) bounds. The image is scaled to this size. */
private RectF mTempDst = new RectF();
/** Rectangle to handle translations */
private RectF mTranslateRect = new RectF();
/** Array to store a copy of the matrix values */
private float[] mValues = new float[9];
/**
* Track whether a double tap event occurred.
*/
private boolean mDoubleTapOccurred;
/**
* X and Y coordinates for the current down event. Since mDoubleTapOccurred only contains the
* information that there was a double tap event, use these to get the secondary tap
* information to determine if a user has moved beyond touch slop.
*/
private float mDownFocusX;
private float mDownFocusY;
/**
* Whether the QuickSale gesture is enabled.
*/
private boolean mQuickScaleEnabled;
public PhotoView(Context context) {
super(context);
initialize();
}
public PhotoView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public PhotoView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mScaleGetureDetector == null || mGestureDetector == null) {
// We're being destroyed; ignore any touch events
return true;
}
mScaleGetureDetector.onTouchEvent(event);
mGestureDetector.onTouchEvent(event);
final int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (!mTranslateRunnable.mRunning) {
snap();
}
break;
}
return true;
}
@Override
public boolean onDoubleTap(MotionEvent e) {
mDoubleTapOccurred = true;
if (!mQuickScaleEnabled) {
return scale(e);
}
return false;
}
@Override
public boolean onDoubleTapEvent(MotionEvent e) {
final int action = e.getAction();
boolean handled = false;
switch (action) {
case MotionEvent.ACTION_DOWN:
if (mQuickScaleEnabled) {
mDownFocusX = e.getX();
mDownFocusY = e.getY();
}
break;
case MotionEvent.ACTION_UP:
if (mQuickScaleEnabled) {
handled = scale(e);
}
break;
case MotionEvent.ACTION_MOVE:
if (mQuickScaleEnabled && mDoubleTapOccurred) {
final int deltaX = (int) (e.getX() - mDownFocusX);
final int deltaY = (int) (e.getY() - mDownFocusY);
int distance = (deltaX * deltaX) + (deltaY * deltaY);
if (distance > sTouchSlopSquare) {
mDoubleTapOccurred = false;
}
}
break;
}
return handled;
}
private boolean scale(MotionEvent e) {
boolean handled = false;
if (mDoubleTapToZoomEnabled && mTransformsEnabled && mDoubleTapOccurred) {
if (!mDoubleTapDebounce) {
float currentScale = getScale();
float targetScale;
float centerX, centerY;
// Zoom out if not default scale, otherwise zoom in
if (currentScale > mMinScale) {
targetScale = mMinScale;
float relativeScale = targetScale / currentScale;
// Find the apparent origin for scaling that equals this scale and translate
centerX = (getWidth() / 2 - relativeScale * mTranslateRect.centerX()) /
(1 - relativeScale);
centerY = (getHeight() / 2 - relativeScale * mTranslateRect.centerY()) /
(1 - relativeScale);
} else {
targetScale = currentScale * DOUBLE_TAP_SCALE_FACTOR;
// Ensure the target scale is within our bounds
targetScale = Math.max(mMinScale, targetScale);
targetScale = Math.min(mMaxScale, targetScale);
float relativeScale = targetScale / currentScale;
float widthBuffer = (getWidth() - mTranslateRect.width()) / relativeScale;
float heightBuffer = (getHeight() - mTranslateRect.height()) / relativeScale;
// Clamp the center if it would result in uneven borders
if (mTranslateRect.width() <= widthBuffer * 2) {
centerX = mTranslateRect.centerX();
} else {
centerX = Math.min(Math.max(mTranslateRect.left + widthBuffer,
e.getX()), mTranslateRect.right - widthBuffer);
}
if (mTranslateRect.height() <= heightBuffer * 2) {
centerY = mTranslateRect.centerY();
} else {
centerY = Math.min(Math.max(mTranslateRect.top + heightBuffer,
e.getY()), mTranslateRect.bottom - heightBuffer);
}
}
mScaleRunnable.start(currentScale, targetScale, centerX, centerY);
handled = true;
}
mDoubleTapDebounce = false;
}
mDoubleTapOccurred = false;
return handled;
}
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
if (mExternalClickListener != null && !mIsDoubleTouch) {
mExternalClickListener.onClick(this);
}
mIsDoubleTouch = false;
return true;
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
return false;
}
@Override
public void onLongPress(MotionEvent e) {
}
@Override
public void onShowPress(MotionEvent e) {
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (mTransformsEnabled && !mScaleRunnable.mRunning) {
translate(-distanceX, -distanceY);
}
return true;
}
@Override
public boolean onDown(MotionEvent e) {
if (mTransformsEnabled) {
mTranslateRunnable.stop();
mSnapRunnable.stop();
}
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (mTransformsEnabled && !mScaleRunnable.mRunning) {
mTranslateRunnable.start(velocityX, velocityY);
}
return true;
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
if (mTransformsEnabled) {
mIsDoubleTouch = false;
float currentScale = getScale();
float newScale = currentScale * detector.getScaleFactor();
scale(newScale, detector.getFocusX(), detector.getFocusY());
}
return true;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
if (mTransformsEnabled) {
mScaleRunnable.stop();
mIsDoubleTouch = true;
}
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
// Scale back to the maximum if over-zoomed
float currentScale = getScale();
if (currentScale > mMaxScale) {
// The number of times the crop amount pulled in can fit on the screen
float marginFit = 1 / (1 - mMaxScale / currentScale);
// The (negative) relative maximum distance from an image edge such that when scaled
// this far from the edge, all of the image off-screen in that direction is pulled in
float relativeDistance = 1 - marginFit;
float centerX = getWidth() / 2;
float centerY = getHeight() / 2;
// This center will pull all of the margin from the lesser side, over will expose trim
float maxX = mTranslateRect.left * relativeDistance;
float maxY = mTranslateRect.top * relativeDistance;
// This center will pull all of the margin from the greater side, over will expose trim
float minX = getWidth() * marginFit + mTranslateRect.right * relativeDistance;
float minY = getHeight() * marginFit + mTranslateRect.bottom * relativeDistance;
// Adjust center according to bounds to avoid bad crop
if (minX > maxX) {
// Border is inevitable due to small image size, so we split the crop difference
centerX = (minX + maxX) / 2;
} else {
centerX = Math.min(Math.max(minX, centerX), maxX);
}
if (minY > maxY) {
// Border is inevitable due to small image size, so we split the crop difference
centerY = (minY + maxY) / 2;
} else {
centerY = Math.min(Math.max(minY, centerY), maxY);
}
mScaleRunnable.start(currentScale, mMaxScale, centerX, centerY);
}
if (mTransformsEnabled && mIsDoubleTouch) {
mDoubleTapDebounce = true;
resetTransformations();
}
}
@Override
public void setOnClickListener(OnClickListener listener) {
mExternalClickListener = listener;
}
@Override
public boolean interceptMoveLeft(float origX, float origY) {
if (!mTransformsEnabled) {
// Allow intercept if we're not in transform mode
return false;
} else if (mTranslateRunnable.mRunning) {
// Don't allow touch intercept until we've stopped flinging
return true;
} else {
mMatrix.getValues(mValues);
mTranslateRect.set(mTempSrc);
mMatrix.mapRect(mTranslateRect);
final float viewWidth = getWidth();
final float transX = mValues[Matrix.MTRANS_X];
final float drawWidth = mTranslateRect.right - mTranslateRect.left;
if (!mTransformsEnabled || drawWidth <= viewWidth) {
// Allow intercept if not in transform mode or the image is smaller than the view
return false;
} else if (transX == 0) {
// We're at the left-side of the image; allow intercepting movements to the right
return false;
} else if (viewWidth >= drawWidth + transX) {
// We're at the right-side of the image; allow intercepting movements to the left
return true;
} else {
// We're in the middle of the image; don't allow touch intercept
return true;
}
}
}
@Override
public boolean interceptMoveRight(float origX, float origY) {
if (!mTransformsEnabled) {
// Allow intercept if we're not in transform mode
return false;
} else if (mTranslateRunnable.mRunning) {
// Don't allow touch intercept until we've stopped flinging
return true;
} else {
mMatrix.getValues(mValues);
mTranslateRect.set(mTempSrc);
mMatrix.mapRect(mTranslateRect);
final float viewWidth = getWidth();
final float transX = mValues[Matrix.MTRANS_X];
final float drawWidth = mTranslateRect.right - mTranslateRect.left;
if (!mTransformsEnabled || drawWidth <= viewWidth) {
// Allow intercept if not in transform mode or the image is smaller than the view
return false;
} else if (transX == 0) {
// We're at the left-side of the image; allow intercepting movements to the right
return true;
} else if (viewWidth >= drawWidth + transX) {
// We're at the right-side of the image; allow intercepting movements to the left
return false;
} else {
// We're in the middle of the image; don't allow touch intercept
return true;
}
}
}
/**
* Free all resources held by this view.
* The view is on its way to be collected and will not be reused.
*/
public void clear() {
mGestureDetector = null;
mScaleGetureDetector = null;
mDrawable = null;
mScaleRunnable.stop();
mScaleRunnable = null;
mTranslateRunnable.stop();
mTranslateRunnable = null;
mSnapRunnable.stop();
mSnapRunnable = null;
mRotateRunnable.stop();
mRotateRunnable = null;
setOnClickListener(null);
mExternalClickListener = null;
mDoubleTapOccurred = false;
}
public void bindDrawable(Drawable drawable) {
boolean changed = false;
if (drawable != null && drawable != mDrawable) {
// Clear previous state.
if (mDrawable != null) {
mDrawable.setCallback(null);
}
mDrawable = drawable;
// Reset mMinScale to ensure the bounds / matrix are recalculated
mMinScale = 0f;
// Set a callback?
mDrawable.setCallback(this);
changed = true;
}
configureBounds(changed);
invalidate();
}
/**
* Binds a bitmap to the view.
*
* @param photoBitmap the bitmap to bind.
*/
public void bindPhoto(Bitmap photoBitmap) {
boolean currentDrawableIsBitmapDrawable = mDrawable instanceof BitmapDrawable;
boolean changed = !(currentDrawableIsBitmapDrawable);
if (mDrawable != null && currentDrawableIsBitmapDrawable) {
final Bitmap drawableBitmap = ((BitmapDrawable) mDrawable).getBitmap();
if (photoBitmap == drawableBitmap) {
// setting the same bitmap; do nothing
return;
}
changed = photoBitmap != null &&
(mDrawable.getIntrinsicWidth() != photoBitmap.getWidth() ||
mDrawable.getIntrinsicHeight() != photoBitmap.getHeight());
// Reset mMinScale to ensure the bounds / matrix are recalculated
mMinScale = 0f;
mDrawable = null;
}
if (mDrawable == null && photoBitmap != null) {
mDrawable = new BitmapDrawable(getResources(), photoBitmap);
}
configureBounds(changed);
invalidate();
}
/**
* Returns the bound photo data if set. Otherwise, {@code null}.
*/
public Bitmap getPhoto() {
if (mDrawable != null && mDrawable instanceof BitmapDrawable) {
return ((BitmapDrawable) mDrawable).getBitmap();
}
return null;
}
/**
* Returns the bound drawable. May be {@code null} if no drawable is bound.
*/
public Drawable getDrawable() {
return mDrawable;
}
/**
* Gets video data associated with this item. Returns {@code null} if this is not a video.
*/
public byte[] getVideoData() {
return mVideoBlob;
}
/**
* Returns {@code true} if the photo represents a video. Otherwise, {@code false}.
*/
public boolean isVideo() {
return mVideoBlob != null;
}
/**
* Returns {@code true} if the video is ready to play. Otherwise, {@code false}.
*/
public boolean isVideoReady() {
return mVideoBlob != null && mVideoReady;
}
/**
* Returns {@code true} if a photo has been bound. Otherwise, {@code false}.
*/
public boolean isPhotoBound() {
return mDrawable != null;
}
/**
* Hides the photo info portion of the header. As a side effect, this automatically enables
* or disables image transformations [eg zoom, pan, etc...] depending upon the value of
* fullScreen. If this is not desirable, enable / disable image transformations manually.
*/
public void setFullScreen(boolean fullScreen, boolean animate) {
if (fullScreen != mFullScreen) {
mFullScreen = fullScreen;
requestLayout();
invalidate();
}
}
/**
* Enable or disable cropping of the displayed image. Cropping can only be enabled
* <em>before</em> the view has been laid out. Additionally, once cropping has been
* enabled, it cannot be disabled.
*/
public void enableAllowCrop(boolean allowCrop) {
if (allowCrop && mHaveLayout) {
throw new IllegalArgumentException("Cannot set crop after view has been laid out");
}
if (!allowCrop && mAllowCrop) {
throw new IllegalArgumentException("Cannot unset crop mode");
}
mAllowCrop = allowCrop;
}
/**
* Gets a bitmap of the cropped region. If cropping is not enabled, returns {@code null}.
*/
public Bitmap getCroppedPhoto() {
if (!mAllowCrop) {
return null;
}
final Bitmap croppedBitmap = Bitmap.createBitmap(
(int) CROPPED_SIZE, (int) CROPPED_SIZE, Bitmap.Config.ARGB_8888);
final Canvas croppedCanvas = new Canvas(croppedBitmap);
// scale for the final dimensions
final int cropWidth = mCropRect.right - mCropRect.left;
final float scaleWidth = CROPPED_SIZE / cropWidth;
final float scaleHeight = CROPPED_SIZE / cropWidth;
// translate to the origin & scale
final Matrix matrix = new Matrix(mDrawMatrix);
matrix.postTranslate(-mCropRect.left, -mCropRect.top);
matrix.postScale(scaleWidth, scaleHeight);
// draw the photo
if (mDrawable != null) {
croppedCanvas.concat(matrix);
mDrawable.draw(croppedCanvas);
}
return croppedBitmap;
}
/**
* Resets the image transformation to its original value.
*/
public void resetTransformations() {
// snap transformations; we don't animate
mMatrix.set(mOriginalMatrix);
// Invalidate the view because if you move off this PhotoView
// to another one and come back, you want it to draw from scratch
// in case you were zoomed in or translated (since those settings
// are not preserved and probably shouldn't be).
invalidate();
}
/**
* Rotates the image 90 degrees, clockwise.
*/
public void rotateClockwise() {
rotate(90, true);
}
/**
* Rotates the image 90 degrees, counter clockwise.
*/
public void rotateCounterClockwise() {
rotate(-90, true);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// draw the photo
if (mDrawable != null) {
int saveCount = canvas.getSaveCount();
canvas.save();
if (mDrawMatrix != null) {
canvas.concat(mDrawMatrix);
}
mDrawable.draw(canvas);
canvas.restoreToCount(saveCount);
if (mVideoBlob != null) {
final Bitmap videoImage = (mVideoReady ? sVideoImage : sVideoNotReadyImage);
final int drawLeft = (getWidth() - videoImage.getWidth()) / 2;
final int drawTop = (getHeight() - videoImage.getHeight()) / 2;
canvas.drawBitmap(videoImage, drawLeft, drawTop, null);
}
// Extract the drawable's bounds (in our own copy, to not alter the image)
mTranslateRect.set(mDrawable.getBounds());
if (mDrawMatrix != null) {
mDrawMatrix.mapRect(mTranslateRect);
}
if (mAllowCrop) {
int previousSaveCount = canvas.getSaveCount();
canvas.drawRect(0, 0, getWidth(), getHeight(), sCropDimPaint);
canvas.save();
canvas.clipRect(mCropRect);
if (mDrawMatrix != null) {
canvas.concat(mDrawMatrix);
}
mDrawable.draw(canvas);
canvas.restoreToCount(previousSaveCount);
canvas.drawRect(mCropRect, sCropPaint);
}
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
mHaveLayout = true;
final int layoutWidth = getWidth();
final int layoutHeight = getHeight();
if (mAllowCrop) {
mCropSize = Math.min(sCropSize, Math.min(layoutWidth, layoutHeight));
final int cropLeft = (layoutWidth - mCropSize) / 2;
final int cropTop = (layoutHeight - mCropSize) / 2;
final int cropRight = cropLeft + mCropSize;
final int cropBottom = cropTop + mCropSize;
// Create a crop region overlay. We need a separate canvas to be able to "punch
// a hole" through to the underlying image.
mCropRect.set(cropLeft, cropTop, cropRight, cropBottom);
}
configureBounds(changed);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mFixedHeight != -1) {
super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(mFixedHeight,
MeasureSpec.AT_MOST));
setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
} else {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
@Override
public boolean verifyDrawable(Drawable drawable) {
return mDrawable == drawable || super.verifyDrawable(drawable);
}
@Override
/**
* {@inheritDoc}
*/
public void invalidateDrawable(Drawable drawable) {
// Only invalidate this view if the passed in drawable is displayed within this view. If
// another drawable is passed in, have the parent view handle invalidation.
if (mDrawable == drawable) {
invalidate();
} else {
super.invalidateDrawable(drawable);
}
}
/**
* Forces a fixed height for this view.
*
* @param fixedHeight The height. If {@code -1}, use the measured height.
*/
public void setFixedHeight(int fixedHeight) {
final boolean adjustBounds = (fixedHeight != mFixedHeight);
mFixedHeight = fixedHeight;
setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
if (adjustBounds) {
configureBounds(true);
requestLayout();
}
}
/**
* Enable or disable image transformations. When transformations are enabled, this view
* consumes all touch events.
*/
public void enableImageTransforms(boolean enable) {
mTransformsEnabled = enable;
if (!mTransformsEnabled) {
resetTransformations();
}
}
/**
* Configures the bounds of the photo. The photo will always be scaled to fit center.
*/
private void configureBounds(boolean changed) {
if (mDrawable == null || !mHaveLayout) {
return;
}
final int dwidth = mDrawable.getIntrinsicWidth();
final int dheight = mDrawable.getIntrinsicHeight();
final int vwidth = getWidth();
final int vheight = getHeight();
final boolean fits = (dwidth < 0 || vwidth == dwidth) &&
(dheight < 0 || vheight == dheight);
// We need to do the scaling ourself, so have the drawable use its native size.
mDrawable.setBounds(0, 0, dwidth, dheight);
// Create a matrix with the proper transforms
if (changed || (mMinScale == 0 && mDrawable != null && mHaveLayout)) {
generateMatrix();
generateScale();
}
if (fits || mMatrix.isIdentity()) {
// The bitmap fits exactly, no transform needed.
mDrawMatrix = null;
} else {
mDrawMatrix = mMatrix;
}
}
/**
* Generates the initial transformation matrix for drawing. Additionally, it sets the
* minimum and maximum scale values.
*/
private void generateMatrix() {
final int dwidth = mDrawable.getIntrinsicWidth();
final int dheight = mDrawable.getIntrinsicHeight();
final int vwidth = mAllowCrop ? sCropSize : getWidth();
final int vheight = mAllowCrop ? sCropSize : getHeight();
final boolean fits = (dwidth < 0 || vwidth == dwidth) &&
(dheight < 0 || vheight == dheight);
if (fits && !mAllowCrop) {
mMatrix.reset();
} else {
// Generate the required transforms for the photo
mTempSrc.set(0, 0, dwidth, dheight);
if (mAllowCrop) {
mTempDst.set(mCropRect);
} else {
mTempDst.set(0, 0, vwidth, vheight);
}
RectF scaledDestination = new RectF(
(vwidth / 2) - (dwidth * mMaxInitialScaleFactor / 2),
(vheight / 2) - (dheight * mMaxInitialScaleFactor / 2),
(vwidth / 2) + (dwidth * mMaxInitialScaleFactor / 2),
(vheight / 2) + (dheight * mMaxInitialScaleFactor / 2));
if(mTempDst.contains(scaledDestination)) {
mMatrix.setRectToRect(mTempSrc, scaledDestination, Matrix.ScaleToFit.CENTER);
} else {
mMatrix.setRectToRect(mTempSrc, mTempDst, Matrix.ScaleToFit.CENTER);
}
}
mOriginalMatrix.set(mMatrix);
}
private void generateScale() {
final int dwidth = mDrawable.getIntrinsicWidth();
final int dheight = mDrawable.getIntrinsicHeight();
final int vwidth = mAllowCrop ? getCropSize() : getWidth();
final int vheight = mAllowCrop ? getCropSize() : getHeight();
if (dwidth < vwidth && dheight < vheight && !mAllowCrop) {
mMinScale = 1.0f;
} else {
mMinScale = getScale();
}
mMaxScale = Math.max(mMinScale * 4, 4);
}
/**
* @return the size of the crop regions
*/
private int getCropSize() {
return mCropSize > 0 ? mCropSize : sCropSize;
}
/**
* Returns the currently applied scale factor for the image.
* <p>
* NOTE: This method overwrites any values stored in {@link #mValues}.
*/
private float getScale() {
mMatrix.getValues(mValues);
return mValues[Matrix.MSCALE_X];
}
/**
* Scales the image while keeping the aspect ratio.
*
* The given scale is capped so that the resulting scale of the image always remains
* between {@link #mMinScale} and {@link #mMaxScale}.
*
* If the image is smaller than the viewable area, it will be centered.
*
* @param newScale the new scale
* @param centerX the center horizontal point around which to scale
* @param centerY the center vertical point around which to scale
*/
private void scale(float newScale, float centerX, float centerY) {
// rotate back to the original orientation
mMatrix.postRotate(-mRotation, getWidth() / 2, getHeight() / 2);
// ensure that mMinScale <= newScale <= mMaxScale
newScale = Math.max(newScale, mMinScale);
newScale = Math.min(newScale, mMaxScale * SCALE_OVERZOOM_FACTOR);
float currentScale = getScale();
float factor = newScale / currentScale;
// apply the scale factor
mMatrix.postScale(factor, factor, centerX, centerY);
// re-apply any rotation
mMatrix.postRotate(mRotation, getWidth() / 2, getHeight() / 2);
invalidate();
}
/**
* Translates the image.
*
* This method will not allow the image to be translated outside of the visible area.
*
* @param tx how many pixels to translate horizontally
* @param ty how many pixels to translate vertically
* @return result of the translation, represented as either {@link TRANSLATE_NONE},
* {@link TRANSLATE_X_ONLY}, {@link TRANSLATE_Y_ONLY}, or {@link TRANSLATE_BOTH}
*/
private int translate(float tx, float ty) {
mTranslateRect.set(mTempSrc);
mMatrix.mapRect(mTranslateRect);
final float maxLeft = mAllowCrop ? mCropRect.left : 0.0f;
final float maxRight = mAllowCrop ? mCropRect.right : getWidth();
float l = mTranslateRect.left;
float r = mTranslateRect.right;
final float translateX;
if (mAllowCrop) {
// If we're cropping, allow the image to scroll off the edge of the screen
translateX = Math.max(maxLeft - mTranslateRect.right,
Math.min(maxRight - mTranslateRect.left, tx));
} else {
// Otherwise, ensure the image never leaves the screen
if (r - l < maxRight - maxLeft) {
translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2;
} else {
translateX = Math.max(maxRight - r, Math.min(maxLeft - l, tx));
}
}
float maxTop = mAllowCrop ? mCropRect.top: 0.0f;
float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
float t = mTranslateRect.top;
float b = mTranslateRect.bottom;
final float translateY;
if (mAllowCrop) {
// If we're cropping, allow the image to scroll off the edge of the screen
translateY = Math.max(maxTop - mTranslateRect.bottom,
Math.min(maxBottom - mTranslateRect.top, ty));
} else {
// Otherwise, ensure the image never leaves the screen
if (b - t < maxBottom - maxTop) {
translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2;
} else {
translateY = Math.max(maxBottom - b, Math.min(maxTop - t, ty));
}
}
// Do the translation
mMatrix.postTranslate(translateX, translateY);
invalidate();
boolean didTranslateX = translateX == tx;
boolean didTranslateY = translateY == ty;
if (didTranslateX && didTranslateY) {
return TRANSLATE_BOTH;
} else if (didTranslateX) {
return TRANSLATE_X_ONLY;
} else if (didTranslateY) {
return TRANSLATE_Y_ONLY;
}
return TRANSLATE_NONE;
}
/**
* Snaps the image so it touches all edges of the view.
*/
private void snap() {
mTranslateRect.set(mTempSrc);
mMatrix.mapRect(mTranslateRect);
// Determine how much to snap in the horizontal direction [if any]
float maxLeft = mAllowCrop ? mCropRect.left : 0.0f;
float maxRight = mAllowCrop ? mCropRect.right : getWidth();
float l = mTranslateRect.left;
float r = mTranslateRect.right;
final float translateX;
if (r - l < maxRight - maxLeft) {
// Image is narrower than view; translate to the center of the view
translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2;
} else if (l > maxLeft) {
// Image is off right-edge of screen; bring it into view
translateX = maxLeft - l;
} else if (r < maxRight) {
// Image is off left-edge of screen; bring it into view
translateX = maxRight - r;
} else {
translateX = 0.0f;
}
// Determine how much to snap in the vertical direction [if any]
float maxTop = mAllowCrop ? mCropRect.top : 0.0f;
float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
float t = mTranslateRect.top;
float b = mTranslateRect.bottom;
final float translateY;
if (b - t < maxBottom - maxTop) {
// Image is shorter than view; translate to the bottom edge of the view
translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2;
} else if (t > maxTop) {
// Image is off bottom-edge of screen; bring it into view
translateY = maxTop - t;
} else if (b < maxBottom) {
// Image is off top-edge of screen; bring it into view
translateY = maxBottom - b;
} else {
translateY = 0.0f;
}
if (Math.abs(translateX) > SNAP_THRESHOLD || Math.abs(translateY) > SNAP_THRESHOLD) {
mSnapRunnable.start(translateX, translateY);
} else {
mMatrix.postTranslate(translateX, translateY);
invalidate();
}
}
/**
* Rotates the image, either instantly or gradually
*
* @param degrees how many degrees to rotate the image, positive rotates clockwise
* @param animate if {@code true}, animate during the rotation. Otherwise, snap rotate.
*/
private void rotate(float degrees, boolean animate) {
if (animate) {
mRotateRunnable.start(degrees);
} else {
mRotation += degrees;
mMatrix.postRotate(degrees, getWidth() / 2, getHeight() / 2);
invalidate();
}
}
/**
* Initializes the header and any static values
*/
private void initialize() {
Context context = getContext();
if (!sInitialized) {
sInitialized = true;
Resources resources = context.getApplicationContext().getResources();
sCropSize = resources.getDimensionPixelSize(R.dimen.photo_crop_width);
sCropDimPaint = new Paint();
sCropDimPaint.setAntiAlias(true);
sCropDimPaint.setColor(resources.getColor(R.color.photo_crop_dim_color));
sCropDimPaint.setStyle(Style.FILL);
sCropPaint = new Paint();
sCropPaint.setAntiAlias(true);
sCropPaint.setColor(resources.getColor(R.color.photo_crop_highlight_color));
sCropPaint.setStyle(Style.STROKE);
sCropPaint.setStrokeWidth(resources.getDimension(R.dimen.photo_crop_stroke_width));
final ViewConfiguration configuration = ViewConfiguration.get(context);
final int touchSlop = configuration.getScaledTouchSlop();
sTouchSlopSquare = touchSlop * touchSlop;
}
mGestureDetector = new GestureDetectorCompat(context, this, null);
mScaleGetureDetector = new ScaleGestureDetector(context, this);
mQuickScaleEnabled = ScaleGestureDetectorCompat.isQuickScaleEnabled(mScaleGetureDetector);
mScaleRunnable = new ScaleRunnable(this);
mTranslateRunnable = new TranslateRunnable(this);
mSnapRunnable = new SnapRunnable(this);
mRotateRunnable = new RotateRunnable(this);
}
/**
* Runnable that animates an image scale operation.
*/
private static class ScaleRunnable implements Runnable {
private final PhotoView mHeader;
private float mCenterX;
private float mCenterY;
private boolean mZoomingIn;
private float mTargetScale;
private float mStartScale;
private float mVelocity;
private long mStartTime;
private boolean mRunning;
private boolean mStop;
public ScaleRunnable(PhotoView header) {
mHeader = header;
}
/**
* Starts the animation. There is no target scale bounds check.
*/
public boolean start(float startScale, float targetScale, float centerX, float centerY) {
if (mRunning) {
return false;
}
mCenterX = centerX;
mCenterY = centerY;
// Ensure the target scale is within the min/max bounds
mTargetScale = targetScale;
mStartTime = System.currentTimeMillis();
mStartScale = startScale;
mZoomingIn = mTargetScale > mStartScale;
mVelocity = (mTargetScale - mStartScale) / ZOOM_ANIMATION_DURATION;
mRunning = true;
mStop = false;
mHeader.post(this);
return true;
}
/**
* Stops the animation in place. It does not snap the image to its final zoom.
*/
public void stop() {
mRunning = false;
mStop = true;
}
@Override
public void run() {
if (mStop) {
return;
}
// Scale
long now = System.currentTimeMillis();
long ellapsed = now - mStartTime;
float newScale = (mStartScale + mVelocity * ellapsed);
mHeader.scale(newScale, mCenterX, mCenterY);
// Stop when done
if (newScale == mTargetScale || (mZoomingIn == (newScale > mTargetScale))) {
mHeader.scale(mTargetScale, mCenterX, mCenterY);
stop();
}
if (!mStop) {
mHeader.post(this);
}
}
}
/**
* Runnable that animates an image translation operation.
*/
private static class TranslateRunnable implements Runnable {
private static final float DECELERATION_RATE = 1000f;
private static final long NEVER = -1L;
private final PhotoView mHeader;
private float mVelocityX;
private float mVelocityY;
private float mDecelerationX;
private float mDecelerationY;
private long mLastRunTime;
private boolean mRunning;
private boolean mStop;
public TranslateRunnable(PhotoView header) {
mLastRunTime = NEVER;
mHeader = header;
}
/**
* Starts the animation.
*/
public boolean start(float velocityX, float velocityY) {
if (mRunning) {
return false;
}
mLastRunTime = NEVER;
mVelocityX = velocityX;
mVelocityY = velocityY;
float angle = (float) Math.atan2(mVelocityY, mVelocityX);
mDecelerationX = (float) (DECELERATION_RATE * Math.cos(angle));
mDecelerationY = (float) (DECELERATION_RATE * Math.sin(angle));
mStop = false;
mRunning = true;
mHeader.post(this);
return true;
}
/**
* Stops the animation in place. It does not snap the image to its final translation.
*/
public void stop() {
mRunning = false;
mStop = true;
}
@Override
public void run() {
// See if we were told to stop:
if (mStop) {
return;
}
// Translate according to current velocities and time delta:
long now = System.currentTimeMillis();
float delta = (mLastRunTime != NEVER) ? (now - mLastRunTime) / 1000f : 0f;
final int translateResult = mHeader.translate(mVelocityX * delta, mVelocityY * delta);
mLastRunTime = now;
// Slow down:
float slowDownX = mDecelerationX * delta;
if (Math.abs(mVelocityX) > Math.abs(slowDownX)) {
mVelocityX -= slowDownX;
} else {
mVelocityX = 0f;
}
float slowDownY = mDecelerationY * delta;
if (Math.abs(mVelocityY) > Math.abs(slowDownY)) {
mVelocityY -= slowDownY;
} else {
mVelocityY = 0f;
}
// Stop when done
if ((mVelocityX == 0f && mVelocityY == 0f)
|| translateResult == TRANSLATE_NONE) {
stop();
mHeader.snap();
} else if (translateResult == TRANSLATE_X_ONLY) {
mDecelerationX = (mVelocityX > 0) ? DECELERATION_RATE : -DECELERATION_RATE;
mDecelerationY = 0;
mVelocityY = 0f;
} else if (translateResult == TRANSLATE_Y_ONLY) {
mDecelerationX = 0;
mDecelerationY = (mVelocityY > 0) ? DECELERATION_RATE : -DECELERATION_RATE;
mVelocityX = 0f;
}
// See if we need to continue flinging:
if (mStop) {
return;
}
mHeader.post(this);
}
}
/**
* Runnable that animates an image translation operation.
*/
private static class SnapRunnable implements Runnable {
private static final long NEVER = -1L;
private final PhotoView mHeader;
private float mTranslateX;
private float mTranslateY;
private long mStartRunTime;
private boolean mRunning;
private boolean mStop;
public SnapRunnable(PhotoView header) {
mStartRunTime = NEVER;
mHeader = header;
}
/**
* Starts the animation.
*/
public boolean start(float translateX, float translateY) {
if (mRunning) {
return false;
}
mStartRunTime = NEVER;
mTranslateX = translateX;
mTranslateY = translateY;
mStop = false;
mRunning = true;
mHeader.postDelayed(this, SNAP_DELAY);
return true;
}
/**
* Stops the animation in place. It does not snap the image to its final translation.
*/
public void stop() {
mRunning = false;
mStop = true;
}
@Override
public void run() {
// See if we were told to stop:
if (mStop) {
return;
}
// Translate according to current velocities and time delta:
long now = System.currentTimeMillis();
float delta = (mStartRunTime != NEVER) ? (now - mStartRunTime) : 0f;
if (mStartRunTime == NEVER) {
mStartRunTime = now;
}
float transX;
float transY;
if (delta >= SNAP_DURATION) {
transX = mTranslateX;
transY = mTranslateY;
} else {
transX = (mTranslateX / (SNAP_DURATION - delta)) * 10f;
transY = (mTranslateY / (SNAP_DURATION - delta)) * 10f;
if (Math.abs(transX) > Math.abs(mTranslateX) || Float.isNaN(transX)) {
transX = mTranslateX;
}
if (Math.abs(transY) > Math.abs(mTranslateY) || Float.isNaN(transY)) {
transY = mTranslateY;
}
}
mHeader.translate(transX, transY);
mTranslateX -= transX;
mTranslateY -= transY;
if (mTranslateX == 0 && mTranslateY == 0) {
stop();
}
// See if we need to continue flinging:
if (mStop) {
return;
}
mHeader.post(this);
}
}
/**
* Runnable that animates an image rotation operation.
*/
private static class RotateRunnable implements Runnable {
private static final long NEVER = -1L;
private final PhotoView mHeader;
private float mTargetRotation;
private float mAppliedRotation;
private float mVelocity;
private long mLastRuntime;
private boolean mRunning;
private boolean mStop;
public RotateRunnable(PhotoView header) {
mHeader = header;
}
/**
* Starts the animation.
*/
public void start(float rotation) {
if (mRunning) {
return;
}
mTargetRotation = rotation;
mVelocity = mTargetRotation / ROTATE_ANIMATION_DURATION;
mAppliedRotation = 0f;
mLastRuntime = NEVER;
mStop = false;
mRunning = true;
mHeader.post(this);
}
/**
* Stops the animation in place. It does not snap the image to its final rotation.
*/
public void stop() {
mRunning = false;
mStop = true;
}
@Override
public void run() {
if (mStop) {
return;
}
if (mAppliedRotation != mTargetRotation) {
long now = System.currentTimeMillis();
long delta = mLastRuntime != NEVER ? now - mLastRuntime : 0L;
float rotationAmount = mVelocity * delta;
if (mAppliedRotation < mTargetRotation
&& mAppliedRotation + rotationAmount > mTargetRotation
|| mAppliedRotation > mTargetRotation
&& mAppliedRotation + rotationAmount < mTargetRotation) {
rotationAmount = mTargetRotation - mAppliedRotation;
}
mHeader.rotate(rotationAmount, false);
mAppliedRotation += rotationAmount;
if (mAppliedRotation == mTargetRotation) {
stop();
}
mLastRuntime = now;
}
if (mStop) {
return;
}
mHeader.post(this);
}
}
public void setMaxInitialScale(float f) {
mMaxInitialScaleFactor = f;
}
}