/* * 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; /** Amount of time to wait after over-zooming before the zoom out animation; in milliseconds */ private static final long ZOOM_CORRECTION_DELAY = 600L; /** 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 && !mScaleRunnable.mRunning) { 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.mRunning) { mScaleRunnable.stop(); mIsDoubleTouch = true; } return true; } @Override public void onScaleEnd(ScaleGestureDetector detector) { 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 * before 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. *

* 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(); // Prepare to animate zoom out if over-zooming if (newScale > mMaxScale && currentScale <= mMaxScale) { Runnable zoomBackRunnable = new Runnable() { @Override public void run() { // 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 finalCenterX = getWidth() / 2; float finalCenterY = 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 finalCenterX = (minX + maxX) / 2; } else { finalCenterX = Math.min(Math.max(minX, finalCenterX), maxX); } if (minY > maxY) { // Border is inevitable due to small image size, so we split the crop finalCenterY = (minY + maxY) / 2; } else { finalCenterY = Math.min(Math.max(minY, finalCenterY), maxY); } mScaleRunnable.start(currentScale, mMaxScale, finalCenterX, finalCenterY); } } }; postDelayed(zoomBackRunnable, ZOOM_CORRECTION_DELAY); } 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 = 20000f; 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; } }