/*
* 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 cl.monsoon.s1next.widget;
import android.annotation.TargetApi;
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.os.Build;
import android.support.annotation.NonNull;
import android.support.v4.content.ContextCompat;
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 cl.monsoon.s1next.R;
/**
* Layout for the photo list view header.
* <p>
* Forked from https://android.googlesource.com/platform/frameworks/opt/photoviewer/+/master/src/com/android/ex/photo/views/PhotoView.java
* blob: b87f6a14e80c61140af3e8619c8626831191cbc0
*/
public final class PhotoView extends View
implements OnGestureListener, OnDoubleTapListener,
ScaleGestureDetector.OnScaleGestureListener {
private static final int TRANSLATE_NONE = 0;
private static final int TRANSLATE_X_ONLY = 1;
private static final int TRANSLATE_Y_ONLY = 2;
private static final int TRANSLATE_BOTH = 3;
/**
* Zoom animation duration; in milliseconds
*/
private static final long ZOOM_ANIMATION_DURATION = 200L;
/**
* Rotate animation duration; in milliseconds
*/
private static final 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 static final float DOUBLE_TAP_SCALE_FACTOR = 2.0f;
/**
* Amount which can be zoomed in past the maximum scale, and then scaled back
*/
private static final float SCALE_OVERZOOM_FACTOR = 1.5f;
/**
* Amount of translation needed before starting a snap animation
*/
private static final float SNAP_THRESHOLD = 20.0f;
/**
* The width & height of the bitmap returned by {@link #getCroppedPhoto()}
*/
private static final 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 final Matrix mMatrix = new Matrix();
/**
* The original matrix for this image; used to reset any transformations applied by the user
*/
private final 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 final 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 scaleGestureDetector;
/**
* 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}, 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 final RectF mTempSrc = new RectF();
/**
* Destination (i.e. the display) bounds. The image is scaled to this size.
*/
private final RectF mTempDst = new RectF();
/**
* Rectangle to handle translations
*/
private final RectF mTranslateRect = new RectF();
/**
* Array to store a copy of the matrix values
*/
private final 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();
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public PhotoView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize();
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
if (scaleGestureDetector == null || mGestureDetector == null) {
// We're being destroyed; ignore any touch events
return true;
}
scaleGestureDetector.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;
return !mQuickScaleEnabled && scale(e);
}
@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 (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 l) {
mExternalClickListener = l;
}
/**
* 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();
}
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();
}
/**
* 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;
scaleGestureDetector = 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;
}
/**
* 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);
}
/**
* {@inheritDoc}
*/
@Override
public void invalidateDrawable(@NonNull 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();
}
}
public void setMaxInitialScaleFactor(float scaleFactor) {
mMaxInitialScaleFactor = scaleFactor;
}
/**
* 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.
* <p>
* The given scale is capped so that the resulting scale of the image always remains
* between {@link #mMinScale} and {@link #mMaxScale}.
* <p>
* 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.
* <p>
* 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 left = mTranslateRect.left;
float right = 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 (right - left < maxRight - maxLeft) {
translateX = maxLeft + ((maxRight - maxLeft) - (right + left)) / 2;
} else {
translateX = Math.max(maxRight - right, Math.min(maxLeft - left, tx));
}
}
float maxTop = mAllowCrop ? mCropRect.top : 0.0f;
float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
float top = mTranslateRect.top;
float bottom = 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 (bottom - top < maxBottom - maxTop) {
translateY = maxTop + ((maxBottom - maxTop) - (bottom + top)) / 2;
} else {
translateY = Math.max(maxBottom - bottom, Math.min(maxTop - top, 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.getResources();
sCropSize = resources.getDimensionPixelSize(R.dimen.photo_view_crop_width);
sCropDimPaint = new Paint();
sCropDimPaint.setAntiAlias(true);
sCropDimPaint.setColor(ContextCompat.getColor(context, R.color.photo_view_crop_dim_color));
sCropDimPaint.setStyle(Style.FILL);
sCropPaint = new Paint();
sCropPaint.setAntiAlias(true);
sCropPaint.setColor(ContextCompat.getColor(context, R.color.photo_view_crop_dim_color));
sCropPaint.setStyle(Style.STROKE);
sCropPaint.setStrokeWidth(resources.getDimension(R.dimen.photo_view_crop_stroke_width));
final ViewConfiguration configuration = ViewConfiguration.get(context);
final int touchSlop = configuration.getScaledTouchSlop();
sTouchSlopSquare = touchSlop * touchSlop;
}
mGestureDetector = new GestureDetectorCompat(context, this, null);
scaleGestureDetector = new ScaleGestureDetector(context, this);
mQuickScaleEnabled = ScaleGestureDetectorCompat.isQuickScaleEnabled(scaleGestureDetector);
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 final 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 elapsed = now - mStartTime;
float newScale = (mStartScale + mVelocity * elapsed);
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 final 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 final 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 final 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);
}
}
}