| /* |
| * 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.support.v4.view.GestureDetectorCompat; |
| import android.util.AttributeSet; |
| import android.view.GestureDetector.OnGestureListener; |
| import android.view.GestureDetector.OnDoubleTapListener; |
| import android.view.MotionEvent; |
| import android.view.ScaleGestureDetector; |
| import android.view.View; |
| |
| 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 { |
| /** Zoom animation duration; in milliseconds */ |
| private final static long ZOOM_ANIMATION_DURATION = 300L; |
| /** 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 = 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; |
| |
| /** 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 BitmapDrawable 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]; |
| |
| 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) { |
| if (mDoubleTapToZoomEnabled && mTransformsEnabled) { |
| if (!mDoubleTapDebounce) { |
| float currentScale = getScale(); |
| float targetScale = currentScale * DOUBLE_TAP_SCALE_FACTOR; |
| |
| // Ensure the target scale is within our bounds |
| targetScale = Math.max(mMinScale, targetScale); |
| targetScale = Math.min(mMaxScale, targetScale); |
| |
| mScaleRunnable.start(currentScale, targetScale, e.getX(), e.getY()); |
| } |
| mDoubleTapDebounce = false; |
| } |
| return true; |
| } |
| |
| @Override |
| public boolean onDoubleTapEvent(MotionEvent e) { |
| return true; |
| } |
| |
| @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) { |
| 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) { |
| 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) { |
| 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; |
| } |
| |
| /** |
| * Binds a bitmap to the view. |
| * |
| * @param photoBitmap the bitmap to bind. |
| */ |
| public void bindPhoto(Bitmap photoBitmap) { |
| boolean changed = false; |
| if (mDrawable != null) { |
| final Bitmap drawableBitmap = 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) { |
| return mDrawable.getBitmap(); |
| } |
| return null; |
| } |
| |
| /** |
| * 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); |
| } |
| } |
| |
| /** |
| * 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 * 8, 8); |
| } |
| |
| /** |
| * @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}. |
| * |
| * The scaled image is never allowed to be outside of the viewable area. 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 mMixScale <= newScale <= mMaxScale |
| newScale = Math.max(newScale, mMinScale); |
| newScale = Math.min(newScale, mMaxScale); |
| |
| float currentScale = getScale(); |
| float factor = newScale / currentScale; |
| |
| // apply the scale factor |
| mMatrix.postScale(factor, factor, centerX, centerY); |
| |
| // ensure the image is within the view bounds |
| snap(); |
| |
| // 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 {@code true} if the translation was applied as specified. Otherwise, {@code false} |
| * if the translation was modified. |
| */ |
| private boolean 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(); |
| |
| return (translateX == tx) && (translateY == ty); |
| } |
| |
| /** |
| * 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)); |
| } |
| |
| mGestureDetector = new GestureDetectorCompat(context, this, null); |
| mScaleGetureDetector = new ScaleGestureDetector(context, this); |
| 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 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; |
| 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 boolean didTranslate = mHeader.translate(mVelocityX * delta, mVelocityY * delta); |
| mLastRunTime = now; |
| // Slow down: |
| float slowDown = DECELERATION_RATE * delta; |
| if (mVelocityX > 0f) { |
| mVelocityX -= slowDown; |
| if (mVelocityX < 0f) { |
| mVelocityX = 0f; |
| } |
| } else { |
| mVelocityX += slowDown; |
| if (mVelocityX > 0f) { |
| mVelocityX = 0f; |
| } |
| } |
| if (mVelocityY > 0f) { |
| mVelocityY -= slowDown; |
| if (mVelocityY < 0f) { |
| mVelocityY = 0f; |
| } |
| } else { |
| mVelocityY += slowDown; |
| if (mVelocityY > 0f) { |
| mVelocityY = 0f; |
| } |
| } |
| |
| // Stop when done |
| if ((mVelocityX == 0f && mVelocityY == 0f) || !didTranslate) { |
| stop(); |
| mHeader.snap(); |
| } |
| |
| // 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) || transX == Float.NaN) { |
| transX = mTranslateX; |
| } |
| if (Math.abs(transY) > Math.abs(mTranslateY) || transY == Float.NaN) { |
| 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; |
| } |
| } |