Home | History | Annotate | Download | only in views
      1 /*
      2  * Copyright (C) 2011 Google Inc.
      3  * Licensed to The Android Open Source Project.
      4  *
      5  * Licensed under the Apache License, Version 2.0 (the "License");
      6  * you may not use this file except in compliance with the License.
      7  * You may obtain a copy of the License at
      8  *
      9  *      http://www.apache.org/licenses/LICENSE-2.0
     10  *
     11  * Unless required by applicable law or agreed to in writing, software
     12  * distributed under the License is distributed on an "AS IS" BASIS,
     13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14  * See the License for the specific language governing permissions and
     15  * limitations under the License.
     16  */
     17 
     18 package com.android.ex.photo.views;
     19 
     20 import android.content.Context;
     21 import android.content.res.Resources;
     22 import android.graphics.Bitmap;
     23 import android.graphics.Canvas;
     24 import android.graphics.Matrix;
     25 import android.graphics.Paint;
     26 import android.graphics.Paint.Style;
     27 import android.graphics.Rect;
     28 import android.graphics.RectF;
     29 import android.graphics.drawable.BitmapDrawable;
     30 import android.support.v4.view.GestureDetectorCompat;
     31 import android.util.AttributeSet;
     32 import android.view.GestureDetector.OnGestureListener;
     33 import android.view.GestureDetector.OnDoubleTapListener;
     34 import android.view.MotionEvent;
     35 import android.view.ScaleGestureDetector;
     36 import android.view.View;
     37 
     38 import com.android.ex.photo.R;
     39 import com.android.ex.photo.fragments.PhotoViewFragment.HorizontallyScrollable;
     40 
     41 /**
     42  * Layout for the photo list view header.
     43  */
     44 public class PhotoView extends View implements OnGestureListener,
     45         OnDoubleTapListener, ScaleGestureDetector.OnScaleGestureListener,
     46         HorizontallyScrollable {
     47     /** Zoom animation duration; in milliseconds */
     48     private final static long ZOOM_ANIMATION_DURATION = 300L;
     49     /** Rotate animation duration; in milliseconds */
     50     private final static long ROTATE_ANIMATION_DURATION = 500L;
     51     /** Snap animation duration; in milliseconds */
     52     private static final long SNAP_DURATION = 100L;
     53     /** Amount of time to wait before starting snap animation; in milliseconds */
     54     private static final long SNAP_DELAY = 250L;
     55     /** By how much to scale the image when double click occurs */
     56     private final static float DOUBLE_TAP_SCALE_FACTOR = 1.5f;
     57     /** Amount of translation needed before starting a snap animation */
     58     private final static float SNAP_THRESHOLD = 20.0f;
     59     /** The width & height of the bitmap returned by {@link #getCroppedPhoto()} */
     60     private final static float CROPPED_SIZE = 256.0f;
     61 
     62     /** If {@code true}, the static values have been initialized */
     63     private static boolean sInitialized;
     64 
     65     // Various dimensions
     66     /** Width & height of the crop region */
     67     private static int sCropSize;
     68 
     69     // Bitmaps
     70     /** Video icon */
     71     private static Bitmap sVideoImage;
     72     /** Video icon */
     73     private static Bitmap sVideoNotReadyImage;
     74 
     75     // Paints
     76     /** Paint to partially dim the photo during crop */
     77     private static Paint sCropDimPaint;
     78     /** Paint to highlight the cropped portion of the photo */
     79     private static Paint sCropPaint;
     80 
     81     /** The photo to display */
     82     private BitmapDrawable mDrawable;
     83     /** The matrix used for drawing; this may be {@code null} */
     84     private Matrix mDrawMatrix;
     85     /** A matrix to apply the scaling of the photo */
     86     private Matrix mMatrix = new Matrix();
     87     /** The original matrix for this image; used to reset any transformations applied by the user */
     88     private Matrix mOriginalMatrix = new Matrix();
     89 
     90     /** The fixed height of this view. If {@code -1}, calculate the height */
     91     private int mFixedHeight = -1;
     92     /** When {@code true}, the view has been laid out */
     93     private boolean mHaveLayout;
     94     /** Whether or not the photo is full-screen */
     95     private boolean mFullScreen;
     96     /** Whether or not this is a still image of a video */
     97     private byte[] mVideoBlob;
     98     /** Whether or not this is a still image of a video */
     99     private boolean mVideoReady;
    100 
    101     /** Whether or not crop is allowed */
    102     private boolean mAllowCrop;
    103     /** The crop region */
    104     private Rect mCropRect = new Rect();
    105     /** Actual crop size; may differ from {@link #sCropSize} if the screen is smaller */
    106     private int mCropSize;
    107     /** The maximum amount of scaling to apply to images */
    108     private float mMaxInitialScaleFactor;
    109 
    110     /** Gesture detector */
    111     private GestureDetectorCompat mGestureDetector;
    112     /** Gesture detector that detects pinch gestures */
    113     private ScaleGestureDetector mScaleGetureDetector;
    114     /** An external click listener */
    115     private OnClickListener mExternalClickListener;
    116     /** When {@code true}, allows gestures to scale / pan the image */
    117     private boolean mTransformsEnabled;
    118 
    119     // To support zooming
    120     /** When {@code true}, a double tap scales the image by {@link #DOUBLE_TAP_SCALE_FACTOR} */
    121     private boolean mDoubleTapToZoomEnabled = true;
    122     /** When {@code true}, prevents scale end gesture from falsely triggering a double click. */
    123     private boolean mDoubleTapDebounce;
    124     /** When {@code false}, event is a scale gesture. Otherwise, event is a double touch. */
    125     private boolean mIsDoubleTouch;
    126     /** Runnable that scales the image */
    127     private ScaleRunnable mScaleRunnable;
    128     /** Minimum scale the image can have. */
    129     private float mMinScale;
    130     /** Maximum scale to limit scaling to, 0 means no limit. */
    131     private float mMaxScale;
    132 
    133     // To support translation [i.e. panning]
    134     /** Runnable that can move the image */
    135     private TranslateRunnable mTranslateRunnable;
    136     private SnapRunnable mSnapRunnable;
    137 
    138     // To support rotation
    139     /** The rotate runnable used to animate rotations of the image */
    140     private RotateRunnable mRotateRunnable;
    141     /** The current rotation amount, in degrees */
    142     private float mRotation;
    143 
    144     // Convenience fields
    145     // These are declared here not because they are important properties of the view. Rather, we
    146     // declare them here to avoid object allocation during critical graphics operations; such as
    147     // layout or drawing.
    148     /** Source (i.e. the photo size) bounds */
    149     private RectF mTempSrc = new RectF();
    150     /** Destination (i.e. the display) bounds. The image is scaled to this size. */
    151     private RectF mTempDst = new RectF();
    152     /** Rectangle to handle translations */
    153     private RectF mTranslateRect = new RectF();
    154     /** Array to store a copy of the matrix values */
    155     private float[] mValues = new float[9];
    156 
    157     public PhotoView(Context context) {
    158         super(context);
    159         initialize();
    160     }
    161 
    162     public PhotoView(Context context, AttributeSet attrs) {
    163         super(context, attrs);
    164         initialize();
    165     }
    166 
    167     public PhotoView(Context context, AttributeSet attrs, int defStyle) {
    168         super(context, attrs, defStyle);
    169         initialize();
    170     }
    171 
    172     @Override
    173     public boolean onTouchEvent(MotionEvent event) {
    174         if (mScaleGetureDetector == null || mGestureDetector == null) {
    175             // We're being destroyed; ignore any touch events
    176             return true;
    177         }
    178 
    179         mScaleGetureDetector.onTouchEvent(event);
    180         mGestureDetector.onTouchEvent(event);
    181         final int action = event.getAction();
    182 
    183         switch (action) {
    184             case MotionEvent.ACTION_UP:
    185             case MotionEvent.ACTION_CANCEL:
    186                 if (!mTranslateRunnable.mRunning) {
    187                     snap();
    188                 }
    189                 break;
    190         }
    191 
    192         return true;
    193     }
    194 
    195     @Override
    196     public boolean onDoubleTap(MotionEvent e) {
    197         if (mDoubleTapToZoomEnabled && mTransformsEnabled) {
    198             if (!mDoubleTapDebounce) {
    199                 float currentScale = getScale();
    200                 float targetScale = currentScale * DOUBLE_TAP_SCALE_FACTOR;
    201 
    202                 // Ensure the target scale is within our bounds
    203                 targetScale = Math.max(mMinScale, targetScale);
    204                 targetScale = Math.min(mMaxScale, targetScale);
    205 
    206                 mScaleRunnable.start(currentScale, targetScale, e.getX(), e.getY());
    207             }
    208             mDoubleTapDebounce = false;
    209         }
    210         return true;
    211     }
    212 
    213     @Override
    214     public boolean onDoubleTapEvent(MotionEvent e) {
    215         return true;
    216     }
    217 
    218     @Override
    219     public boolean onSingleTapConfirmed(MotionEvent e) {
    220         if (mExternalClickListener != null && !mIsDoubleTouch) {
    221             mExternalClickListener.onClick(this);
    222         }
    223         mIsDoubleTouch = false;
    224         return true;
    225     }
    226 
    227     @Override
    228     public boolean onSingleTapUp(MotionEvent e) {
    229         return false;
    230     }
    231 
    232     @Override
    233     public void onLongPress(MotionEvent e) {
    234     }
    235 
    236     @Override
    237     public void onShowPress(MotionEvent e) {
    238     }
    239 
    240     @Override
    241     public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
    242         if (mTransformsEnabled) {
    243             translate(-distanceX, -distanceY);
    244         }
    245         return true;
    246     }
    247 
    248     @Override
    249     public boolean onDown(MotionEvent e) {
    250         if (mTransformsEnabled) {
    251             mTranslateRunnable.stop();
    252             mSnapRunnable.stop();
    253         }
    254         return true;
    255     }
    256 
    257     @Override
    258     public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
    259         if (mTransformsEnabled) {
    260             mTranslateRunnable.start(velocityX, velocityY);
    261         }
    262         return true;
    263     }
    264 
    265     @Override
    266     public boolean onScale(ScaleGestureDetector detector) {
    267         if (mTransformsEnabled) {
    268             mIsDoubleTouch = false;
    269             float currentScale = getScale();
    270             float newScale = currentScale * detector.getScaleFactor();
    271             scale(newScale, detector.getFocusX(), detector.getFocusY());
    272         }
    273         return true;
    274     }
    275 
    276     @Override
    277     public boolean onScaleBegin(ScaleGestureDetector detector) {
    278         if (mTransformsEnabled) {
    279             mScaleRunnable.stop();
    280             mIsDoubleTouch = true;
    281         }
    282         return true;
    283     }
    284 
    285     @Override
    286     public void onScaleEnd(ScaleGestureDetector detector) {
    287         if (mTransformsEnabled && mIsDoubleTouch) {
    288             mDoubleTapDebounce = true;
    289             resetTransformations();
    290         }
    291     }
    292 
    293     @Override
    294     public void setOnClickListener(OnClickListener listener) {
    295         mExternalClickListener = listener;
    296     }
    297 
    298     @Override
    299     public boolean interceptMoveLeft(float origX, float origY) {
    300         if (!mTransformsEnabled) {
    301             // Allow intercept if we're not in transform mode
    302             return false;
    303         } else if (mTranslateRunnable.mRunning) {
    304             // Don't allow touch intercept until we've stopped flinging
    305             return true;
    306         } else {
    307             mMatrix.getValues(mValues);
    308             mTranslateRect.set(mTempSrc);
    309             mMatrix.mapRect(mTranslateRect);
    310 
    311             final float viewWidth = getWidth();
    312             final float transX = mValues[Matrix.MTRANS_X];
    313             final float drawWidth = mTranslateRect.right - mTranslateRect.left;
    314 
    315             if (!mTransformsEnabled || drawWidth <= viewWidth) {
    316                 // Allow intercept if not in transform mode or the image is smaller than the view
    317                 return false;
    318             } else if (transX == 0) {
    319                 // We're at the left-side of the image; allow intercepting movements to the right
    320                 return false;
    321             } else if (viewWidth >= drawWidth + transX) {
    322                 // We're at the right-side of the image; allow intercepting movements to the left
    323                 return true;
    324             } else {
    325                 // We're in the middle of the image; don't allow touch intercept
    326                 return true;
    327             }
    328         }
    329     }
    330 
    331     @Override
    332     public boolean interceptMoveRight(float origX, float origY) {
    333         if (!mTransformsEnabled) {
    334             // Allow intercept if we're not in transform mode
    335             return false;
    336         } else if (mTranslateRunnable.mRunning) {
    337             // Don't allow touch intercept until we've stopped flinging
    338             return true;
    339         } else {
    340             mMatrix.getValues(mValues);
    341             mTranslateRect.set(mTempSrc);
    342             mMatrix.mapRect(mTranslateRect);
    343 
    344             final float viewWidth = getWidth();
    345             final float transX = mValues[Matrix.MTRANS_X];
    346             final float drawWidth = mTranslateRect.right - mTranslateRect.left;
    347 
    348             if (!mTransformsEnabled || drawWidth <= viewWidth) {
    349                 // Allow intercept if not in transform mode or the image is smaller than the view
    350                 return false;
    351             } else if (transX == 0) {
    352                 // We're at the left-side of the image; allow intercepting movements to the right
    353                 return true;
    354             } else if (viewWidth >= drawWidth + transX) {
    355                 // We're at the right-side of the image; allow intercepting movements to the left
    356                 return false;
    357             } else {
    358                 // We're in the middle of the image; don't allow touch intercept
    359                 return true;
    360             }
    361         }
    362     }
    363 
    364     /**
    365      * Free all resources held by this view.
    366      * The view is on its way to be collected and will not be reused.
    367      */
    368     public void clear() {
    369         mGestureDetector = null;
    370         mScaleGetureDetector = null;
    371         mDrawable = null;
    372         mScaleRunnable.stop();
    373         mScaleRunnable = null;
    374         mTranslateRunnable.stop();
    375         mTranslateRunnable = null;
    376         mSnapRunnable.stop();
    377         mSnapRunnable = null;
    378         mRotateRunnable.stop();
    379         mRotateRunnable = null;
    380         setOnClickListener(null);
    381         mExternalClickListener = null;
    382     }
    383 
    384     /**
    385      * Binds a bitmap to the view.
    386      *
    387      * @param photoBitmap the bitmap to bind.
    388      */
    389     public void bindPhoto(Bitmap photoBitmap) {
    390         boolean changed = false;
    391         if (mDrawable != null) {
    392             final Bitmap drawableBitmap = mDrawable.getBitmap();
    393             if (photoBitmap == drawableBitmap) {
    394                 // setting the same bitmap; do nothing
    395                 return;
    396             }
    397 
    398             changed = photoBitmap != null &&
    399                     (mDrawable.getIntrinsicWidth() != photoBitmap.getWidth() ||
    400                     mDrawable.getIntrinsicHeight() != photoBitmap.getHeight());
    401 
    402             // Reset mMinScale to ensure the bounds / matrix are recalculated
    403             mMinScale = 0f;
    404             mDrawable = null;
    405         }
    406 
    407         if (mDrawable == null && photoBitmap != null) {
    408             mDrawable = new BitmapDrawable(getResources(), photoBitmap);
    409         }
    410 
    411         configureBounds(changed);
    412         invalidate();
    413     }
    414 
    415     /**
    416      * Returns the bound photo data if set. Otherwise, {@code null}.
    417      */
    418     public Bitmap getPhoto() {
    419         if (mDrawable != null) {
    420             return mDrawable.getBitmap();
    421         }
    422         return null;
    423     }
    424 
    425     /**
    426      * Gets video data associated with this item. Returns {@code null} if this is not a video.
    427      */
    428     public byte[] getVideoData() {
    429         return mVideoBlob;
    430     }
    431 
    432     /**
    433      * Returns {@code true} if the photo represents a video. Otherwise, {@code false}.
    434      */
    435     public boolean isVideo() {
    436         return mVideoBlob != null;
    437     }
    438 
    439     /**
    440      * Returns {@code true} if the video is ready to play. Otherwise, {@code false}.
    441      */
    442     public boolean isVideoReady() {
    443         return mVideoBlob != null && mVideoReady;
    444     }
    445 
    446     /**
    447      * Returns {@code true} if a photo has been bound. Otherwise, {@code false}.
    448      */
    449     public boolean isPhotoBound() {
    450         return mDrawable != null;
    451     }
    452 
    453     /**
    454      * Hides the photo info portion of the header. As a side effect, this automatically enables
    455      * or disables image transformations [eg zoom, pan, etc...] depending upon the value of
    456      * fullScreen. If this is not desirable, enable / disable image transformations manually.
    457      */
    458     public void setFullScreen(boolean fullScreen, boolean animate) {
    459         if (fullScreen != mFullScreen) {
    460             mFullScreen = fullScreen;
    461             requestLayout();
    462             invalidate();
    463         }
    464     }
    465 
    466     /**
    467      * Enable or disable cropping of the displayed image. Cropping can only be enabled
    468      * <em>before</em> the view has been laid out. Additionally, once cropping has been
    469      * enabled, it cannot be disabled.
    470      */
    471     public void enableAllowCrop(boolean allowCrop) {
    472         if (allowCrop && mHaveLayout) {
    473             throw new IllegalArgumentException("Cannot set crop after view has been laid out");
    474         }
    475         if (!allowCrop && mAllowCrop) {
    476             throw new IllegalArgumentException("Cannot unset crop mode");
    477         }
    478         mAllowCrop = allowCrop;
    479     }
    480 
    481     /**
    482      * Gets a bitmap of the cropped region. If cropping is not enabled, returns {@code null}.
    483      */
    484     public Bitmap getCroppedPhoto() {
    485         if (!mAllowCrop) {
    486             return null;
    487         }
    488 
    489         final Bitmap croppedBitmap = Bitmap.createBitmap(
    490                 (int) CROPPED_SIZE, (int) CROPPED_SIZE, Bitmap.Config.ARGB_8888);
    491         final Canvas croppedCanvas = new Canvas(croppedBitmap);
    492 
    493         // scale for the final dimensions
    494         final int cropWidth = mCropRect.right - mCropRect.left;
    495         final float scaleWidth = CROPPED_SIZE / cropWidth;
    496         final float scaleHeight = CROPPED_SIZE / cropWidth;
    497 
    498         // translate to the origin & scale
    499         final Matrix matrix = new Matrix(mDrawMatrix);
    500         matrix.postTranslate(-mCropRect.left, -mCropRect.top);
    501         matrix.postScale(scaleWidth, scaleHeight);
    502 
    503         // draw the photo
    504         if (mDrawable != null) {
    505             croppedCanvas.concat(matrix);
    506             mDrawable.draw(croppedCanvas);
    507         }
    508         return croppedBitmap;
    509     }
    510 
    511     /**
    512      * Resets the image transformation to its original value.
    513      */
    514     public void resetTransformations() {
    515         // snap transformations; we don't animate
    516         mMatrix.set(mOriginalMatrix);
    517 
    518         // Invalidate the view because if you move off this PhotoView
    519         // to another one and come back, you want it to draw from scratch
    520         // in case you were zoomed in or translated (since those settings
    521         // are not preserved and probably shouldn't be).
    522         invalidate();
    523     }
    524 
    525     /**
    526      * Rotates the image 90 degrees, clockwise.
    527      */
    528     public void rotateClockwise() {
    529         rotate(90, true);
    530     }
    531 
    532     /**
    533      * Rotates the image 90 degrees, counter clockwise.
    534      */
    535     public void rotateCounterClockwise() {
    536         rotate(-90, true);
    537     }
    538 
    539     @Override
    540     protected void onDraw(Canvas canvas) {
    541         super.onDraw(canvas);
    542 
    543         // draw the photo
    544         if (mDrawable != null) {
    545             int saveCount = canvas.getSaveCount();
    546             canvas.save();
    547 
    548             if (mDrawMatrix != null) {
    549                 canvas.concat(mDrawMatrix);
    550             }
    551             mDrawable.draw(canvas);
    552 
    553             canvas.restoreToCount(saveCount);
    554 
    555             if (mVideoBlob != null) {
    556                 final Bitmap videoImage = (mVideoReady ? sVideoImage : sVideoNotReadyImage);
    557                 final int drawLeft = (getWidth() - videoImage.getWidth()) / 2;
    558                 final int drawTop = (getHeight() - videoImage.getHeight()) / 2;
    559                 canvas.drawBitmap(videoImage, drawLeft, drawTop, null);
    560             }
    561 
    562             // Extract the drawable's bounds (in our own copy, to not alter the image)
    563             mTranslateRect.set(mDrawable.getBounds());
    564             if (mDrawMatrix != null) {
    565                 mDrawMatrix.mapRect(mTranslateRect);
    566             }
    567 
    568             if (mAllowCrop) {
    569                 int previousSaveCount = canvas.getSaveCount();
    570                 canvas.drawRect(0, 0, getWidth(), getHeight(), sCropDimPaint);
    571                 canvas.save();
    572                 canvas.clipRect(mCropRect);
    573 
    574                 if (mDrawMatrix != null) {
    575                     canvas.concat(mDrawMatrix);
    576                 }
    577 
    578                 mDrawable.draw(canvas);
    579                 canvas.restoreToCount(previousSaveCount);
    580                 canvas.drawRect(mCropRect, sCropPaint);
    581             }
    582         }
    583     }
    584 
    585     @Override
    586     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    587         super.onLayout(changed, left, top, right, bottom);
    588         mHaveLayout = true;
    589         final int layoutWidth = getWidth();
    590         final int layoutHeight = getHeight();
    591 
    592         if (mAllowCrop) {
    593             mCropSize = Math.min(sCropSize, Math.min(layoutWidth, layoutHeight));
    594             final int cropLeft = (layoutWidth - mCropSize) / 2;
    595             final int cropTop = (layoutHeight - mCropSize) / 2;
    596             final int cropRight = cropLeft + mCropSize;
    597             final int cropBottom =  cropTop + mCropSize;
    598 
    599             // Create a crop region overlay. We need a separate canvas to be able to "punch
    600             // a hole" through to the underlying image.
    601             mCropRect.set(cropLeft, cropTop, cropRight, cropBottom);
    602         }
    603         configureBounds(changed);
    604     }
    605 
    606     @Override
    607     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    608         if (mFixedHeight != -1) {
    609             super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(mFixedHeight,
    610                     MeasureSpec.AT_MOST));
    611             setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
    612         } else {
    613             super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    614         }
    615     }
    616 
    617     /**
    618      * Forces a fixed height for this view.
    619      *
    620      * @param fixedHeight The height. If {@code -1}, use the measured height.
    621      */
    622     public void setFixedHeight(int fixedHeight) {
    623         final boolean adjustBounds = (fixedHeight != mFixedHeight);
    624         mFixedHeight = fixedHeight;
    625         setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
    626         if (adjustBounds) {
    627             configureBounds(true);
    628             requestLayout();
    629         }
    630     }
    631 
    632     /**
    633      * Enable or disable image transformations. When transformations are enabled, this view
    634      * consumes all touch events.
    635      */
    636     public void enableImageTransforms(boolean enable) {
    637         mTransformsEnabled = enable;
    638         if (!mTransformsEnabled) {
    639             resetTransformations();
    640         }
    641     }
    642 
    643     /**
    644      * Configures the bounds of the photo. The photo will always be scaled to fit center.
    645      */
    646     private void configureBounds(boolean changed) {
    647         if (mDrawable == null || !mHaveLayout) {
    648             return;
    649         }
    650         final int dwidth = mDrawable.getIntrinsicWidth();
    651         final int dheight = mDrawable.getIntrinsicHeight();
    652 
    653         final int vwidth = getWidth();
    654         final int vheight = getHeight();
    655 
    656         final boolean fits = (dwidth < 0 || vwidth == dwidth) &&
    657                 (dheight < 0 || vheight == dheight);
    658 
    659         // We need to do the scaling ourself, so have the drawable use its native size.
    660         mDrawable.setBounds(0, 0, dwidth, dheight);
    661 
    662         // Create a matrix with the proper transforms
    663         if (changed || (mMinScale == 0 && mDrawable != null && mHaveLayout)) {
    664             generateMatrix();
    665             generateScale();
    666         }
    667 
    668         if (fits || mMatrix.isIdentity()) {
    669             // The bitmap fits exactly, no transform needed.
    670             mDrawMatrix = null;
    671         } else {
    672             mDrawMatrix = mMatrix;
    673         }
    674     }
    675 
    676     /**
    677      * Generates the initial transformation matrix for drawing. Additionally, it sets the
    678      * minimum and maximum scale values.
    679      */
    680     private void generateMatrix() {
    681         final int dwidth = mDrawable.getIntrinsicWidth();
    682         final int dheight = mDrawable.getIntrinsicHeight();
    683 
    684         final int vwidth = mAllowCrop ? sCropSize : getWidth();
    685         final int vheight = mAllowCrop ? sCropSize : getHeight();
    686 
    687         final boolean fits = (dwidth < 0 || vwidth == dwidth) &&
    688                 (dheight < 0 || vheight == dheight);
    689 
    690         if (fits && !mAllowCrop) {
    691             mMatrix.reset();
    692         } else {
    693             // Generate the required transforms for the photo
    694             mTempSrc.set(0, 0, dwidth, dheight);
    695             if (mAllowCrop) {
    696                 mTempDst.set(mCropRect);
    697             } else {
    698                 mTempDst.set(0, 0, vwidth, vheight);
    699             }
    700             RectF scaledDestination = new RectF(
    701                     (vwidth / 2) - (dwidth * mMaxInitialScaleFactor / 2),
    702                     (vheight / 2) - (dheight * mMaxInitialScaleFactor / 2),
    703                     (vwidth / 2) + (dwidth * mMaxInitialScaleFactor / 2),
    704                     (vheight / 2) + (dheight * mMaxInitialScaleFactor / 2));
    705             if(mTempDst.contains(scaledDestination)) {
    706                 mMatrix.setRectToRect(mTempSrc, scaledDestination, Matrix.ScaleToFit.CENTER);
    707             } else {
    708                 mMatrix.setRectToRect(mTempSrc, mTempDst, Matrix.ScaleToFit.CENTER);
    709             }
    710         }
    711         mOriginalMatrix.set(mMatrix);
    712     }
    713 
    714     private void generateScale() {
    715         final int dwidth = mDrawable.getIntrinsicWidth();
    716         final int dheight = mDrawable.getIntrinsicHeight();
    717 
    718         final int vwidth = mAllowCrop ? getCropSize() : getWidth();
    719         final int vheight = mAllowCrop ? getCropSize() : getHeight();
    720 
    721         if (dwidth < vwidth && dheight < vheight && !mAllowCrop) {
    722             mMinScale = 1.0f;
    723         } else {
    724             mMinScale = getScale();
    725         }
    726         mMaxScale = Math.max(mMinScale * 8, 8);
    727     }
    728 
    729     /**
    730      * @return the size of the crop regions
    731      */
    732     private int getCropSize() {
    733         return mCropSize > 0 ? mCropSize : sCropSize;
    734     }
    735 
    736     /**
    737      * Returns the currently applied scale factor for the image.
    738      * <p>
    739      * NOTE: This method overwrites any values stored in {@link #mValues}.
    740      */
    741     private float getScale() {
    742         mMatrix.getValues(mValues);
    743         return mValues[Matrix.MSCALE_X];
    744     }
    745 
    746     /**
    747      * Scales the image while keeping the aspect ratio.
    748      *
    749      * The given scale is capped so that the resulting scale of the image always remains
    750      * between {@link #mMinScale} and {@link #mMaxScale}.
    751      *
    752      * The scaled image is never allowed to be outside of the viewable area. If the image
    753      * is smaller than the viewable area, it will be centered.
    754      *
    755      * @param newScale the new scale
    756      * @param centerX the center horizontal point around which to scale
    757      * @param centerY the center vertical point around which to scale
    758      */
    759     private void scale(float newScale, float centerX, float centerY) {
    760         // rotate back to the original orientation
    761         mMatrix.postRotate(-mRotation, getWidth() / 2, getHeight() / 2);
    762 
    763         // ensure that mMixScale <= newScale <= mMaxScale
    764         newScale = Math.max(newScale, mMinScale);
    765         newScale = Math.min(newScale, mMaxScale);
    766 
    767         float currentScale = getScale();
    768         float factor = newScale / currentScale;
    769 
    770         // apply the scale factor
    771         mMatrix.postScale(factor, factor, centerX, centerY);
    772 
    773         // ensure the image is within the view bounds
    774         snap();
    775 
    776         // re-apply any rotation
    777         mMatrix.postRotate(mRotation, getWidth() / 2, getHeight() / 2);
    778 
    779         invalidate();
    780     }
    781 
    782     /**
    783      * Translates the image.
    784      *
    785      * This method will not allow the image to be translated outside of the visible area.
    786      *
    787      * @param tx how many pixels to translate horizontally
    788      * @param ty how many pixels to translate vertically
    789      * @return {@code true} if the translation was applied as specified. Otherwise, {@code false}
    790      *      if the translation was modified.
    791      */
    792     private boolean translate(float tx, float ty) {
    793         mTranslateRect.set(mTempSrc);
    794         mMatrix.mapRect(mTranslateRect);
    795 
    796         final float maxLeft = mAllowCrop ? mCropRect.left : 0.0f;
    797         final float maxRight = mAllowCrop ? mCropRect.right : getWidth();
    798         float l = mTranslateRect.left;
    799         float r = mTranslateRect.right;
    800 
    801         final float translateX;
    802         if (mAllowCrop) {
    803             // If we're cropping, allow the image to scroll off the edge of the screen
    804             translateX = Math.max(maxLeft - mTranslateRect.right,
    805                     Math.min(maxRight - mTranslateRect.left, tx));
    806         } else {
    807             // Otherwise, ensure the image never leaves the screen
    808             if (r - l < maxRight - maxLeft) {
    809                 translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2;
    810             } else {
    811                 translateX = Math.max(maxRight - r, Math.min(maxLeft - l, tx));
    812             }
    813         }
    814 
    815         float maxTop = mAllowCrop ? mCropRect.top: 0.0f;
    816         float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
    817         float t = mTranslateRect.top;
    818         float b = mTranslateRect.bottom;
    819 
    820         final float translateY;
    821 
    822         if (mAllowCrop) {
    823             // If we're cropping, allow the image to scroll off the edge of the screen
    824             translateY = Math.max(maxTop - mTranslateRect.bottom,
    825                     Math.min(maxBottom - mTranslateRect.top, ty));
    826         } else {
    827             // Otherwise, ensure the image never leaves the screen
    828             if (b - t < maxBottom - maxTop) {
    829                 translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2;
    830             } else {
    831                 translateY = Math.max(maxBottom - b, Math.min(maxTop - t, ty));
    832             }
    833         }
    834 
    835         // Do the translation
    836         mMatrix.postTranslate(translateX, translateY);
    837         invalidate();
    838 
    839         return (translateX == tx) && (translateY == ty);
    840     }
    841 
    842     /**
    843      * Snaps the image so it touches all edges of the view.
    844      */
    845     private void snap() {
    846         mTranslateRect.set(mTempSrc);
    847         mMatrix.mapRect(mTranslateRect);
    848 
    849         // Determine how much to snap in the horizontal direction [if any]
    850         float maxLeft = mAllowCrop ? mCropRect.left : 0.0f;
    851         float maxRight = mAllowCrop ? mCropRect.right : getWidth();
    852         float l = mTranslateRect.left;
    853         float r = mTranslateRect.right;
    854 
    855         final float translateX;
    856         if (r - l < maxRight - maxLeft) {
    857             // Image is narrower than view; translate to the center of the view
    858             translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2;
    859         } else if (l > maxLeft) {
    860             // Image is off right-edge of screen; bring it into view
    861             translateX = maxLeft - l;
    862         } else if (r < maxRight) {
    863             // Image is off left-edge of screen; bring it into view
    864             translateX = maxRight - r;
    865         } else {
    866             translateX = 0.0f;
    867         }
    868 
    869         // Determine how much to snap in the vertical direction [if any]
    870         float maxTop = mAllowCrop ? mCropRect.top : 0.0f;
    871         float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
    872         float t = mTranslateRect.top;
    873         float b = mTranslateRect.bottom;
    874 
    875         final float translateY;
    876         if (b - t < maxBottom - maxTop) {
    877             // Image is shorter than view; translate to the bottom edge of the view
    878             translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2;
    879         } else if (t > maxTop) {
    880             // Image is off bottom-edge of screen; bring it into view
    881             translateY = maxTop - t;
    882         } else if (b < maxBottom) {
    883             // Image is off top-edge of screen; bring it into view
    884             translateY = maxBottom - b;
    885         } else {
    886             translateY = 0.0f;
    887         }
    888 
    889         if (Math.abs(translateX) > SNAP_THRESHOLD || Math.abs(translateY) > SNAP_THRESHOLD) {
    890             mSnapRunnable.start(translateX, translateY);
    891         } else {
    892             mMatrix.postTranslate(translateX, translateY);
    893             invalidate();
    894         }
    895     }
    896 
    897     /**
    898      * Rotates the image, either instantly or gradually
    899      *
    900      * @param degrees how many degrees to rotate the image, positive rotates clockwise
    901      * @param animate if {@code true}, animate during the rotation. Otherwise, snap rotate.
    902      */
    903     private void rotate(float degrees, boolean animate) {
    904         if (animate) {
    905             mRotateRunnable.start(degrees);
    906         } else {
    907             mRotation += degrees;
    908             mMatrix.postRotate(degrees, getWidth() / 2, getHeight() / 2);
    909             invalidate();
    910         }
    911     }
    912 
    913     /**
    914      * Initializes the header and any static values
    915      */
    916     private void initialize() {
    917         Context context = getContext();
    918 
    919         if (!sInitialized) {
    920             sInitialized = true;
    921 
    922             Resources resources = context.getApplicationContext().getResources();
    923 
    924             sCropSize = resources.getDimensionPixelSize(R.dimen.photo_crop_width);
    925 
    926             sCropDimPaint = new Paint();
    927             sCropDimPaint.setAntiAlias(true);
    928             sCropDimPaint.setColor(resources.getColor(R.color.photo_crop_dim_color));
    929             sCropDimPaint.setStyle(Style.FILL);
    930 
    931             sCropPaint = new Paint();
    932             sCropPaint.setAntiAlias(true);
    933             sCropPaint.setColor(resources.getColor(R.color.photo_crop_highlight_color));
    934             sCropPaint.setStyle(Style.STROKE);
    935             sCropPaint.setStrokeWidth(resources.getDimension(R.dimen.photo_crop_stroke_width));
    936         }
    937 
    938         mGestureDetector = new GestureDetectorCompat(context, this, null);
    939         mScaleGetureDetector = new ScaleGestureDetector(context, this);
    940         mScaleRunnable = new ScaleRunnable(this);
    941         mTranslateRunnable = new TranslateRunnable(this);
    942         mSnapRunnable = new SnapRunnable(this);
    943         mRotateRunnable = new RotateRunnable(this);
    944     }
    945 
    946     /**
    947      * Runnable that animates an image scale operation.
    948      */
    949     private static class ScaleRunnable implements Runnable {
    950 
    951         private final PhotoView mHeader;
    952 
    953         private float mCenterX;
    954         private float mCenterY;
    955 
    956         private boolean mZoomingIn;
    957 
    958         private float mTargetScale;
    959         private float mStartScale;
    960         private float mVelocity;
    961         private long mStartTime;
    962 
    963         private boolean mRunning;
    964         private boolean mStop;
    965 
    966         public ScaleRunnable(PhotoView header) {
    967             mHeader = header;
    968         }
    969 
    970         /**
    971          * Starts the animation. There is no target scale bounds check.
    972          */
    973         public boolean start(float startScale, float targetScale, float centerX, float centerY) {
    974             if (mRunning) {
    975                 return false;
    976             }
    977 
    978             mCenterX = centerX;
    979             mCenterY = centerY;
    980 
    981             // Ensure the target scale is within the min/max bounds
    982             mTargetScale = targetScale;
    983             mStartTime = System.currentTimeMillis();
    984             mStartScale = startScale;
    985             mZoomingIn = mTargetScale > mStartScale;
    986             mVelocity = (mTargetScale - mStartScale) / ZOOM_ANIMATION_DURATION;
    987             mRunning = true;
    988             mStop = false;
    989             mHeader.post(this);
    990             return true;
    991         }
    992 
    993         /**
    994          * Stops the animation in place. It does not snap the image to its final zoom.
    995          */
    996         public void stop() {
    997             mRunning = false;
    998             mStop = true;
    999         }
   1000 
   1001         @Override
   1002         public void run() {
   1003             if (mStop) {
   1004                 return;
   1005             }
   1006 
   1007             // Scale
   1008             long now = System.currentTimeMillis();
   1009             long ellapsed = now - mStartTime;
   1010             float newScale = (mStartScale + mVelocity * ellapsed);
   1011             mHeader.scale(newScale, mCenterX, mCenterY);
   1012 
   1013             // Stop when done
   1014             if (newScale == mTargetScale || (mZoomingIn == (newScale > mTargetScale))) {
   1015                 mHeader.scale(mTargetScale, mCenterX, mCenterY);
   1016                 stop();
   1017             }
   1018 
   1019             if (!mStop) {
   1020                 mHeader.post(this);
   1021             }
   1022         }
   1023     }
   1024 
   1025     /**
   1026      * Runnable that animates an image translation operation.
   1027      */
   1028     private static class TranslateRunnable implements Runnable {
   1029 
   1030         private static final float DECELERATION_RATE = 1000f;
   1031         private static final long NEVER = -1L;
   1032 
   1033         private final PhotoView mHeader;
   1034 
   1035         private float mVelocityX;
   1036         private float mVelocityY;
   1037 
   1038         private long mLastRunTime;
   1039         private boolean mRunning;
   1040         private boolean mStop;
   1041 
   1042         public TranslateRunnable(PhotoView header) {
   1043             mLastRunTime = NEVER;
   1044             mHeader = header;
   1045         }
   1046 
   1047         /**
   1048          * Starts the animation.
   1049          */
   1050         public boolean start(float velocityX, float velocityY) {
   1051             if (mRunning) {
   1052                 return false;
   1053             }
   1054             mLastRunTime = NEVER;
   1055             mVelocityX = velocityX;
   1056             mVelocityY = velocityY;
   1057             mStop = false;
   1058             mRunning = true;
   1059             mHeader.post(this);
   1060             return true;
   1061         }
   1062 
   1063         /**
   1064          * Stops the animation in place. It does not snap the image to its final translation.
   1065          */
   1066         public void stop() {
   1067             mRunning = false;
   1068             mStop = true;
   1069         }
   1070 
   1071         @Override
   1072         public void run() {
   1073             // See if we were told to stop:
   1074             if (mStop) {
   1075                 return;
   1076             }
   1077 
   1078             // Translate according to current velocities and time delta:
   1079             long now = System.currentTimeMillis();
   1080             float delta = (mLastRunTime != NEVER) ? (now - mLastRunTime) / 1000f : 0f;
   1081             final boolean didTranslate = mHeader.translate(mVelocityX * delta, mVelocityY * delta);
   1082             mLastRunTime = now;
   1083             // Slow down:
   1084             float slowDown = DECELERATION_RATE * delta;
   1085             if (mVelocityX > 0f) {
   1086                 mVelocityX -= slowDown;
   1087                 if (mVelocityX < 0f) {
   1088                     mVelocityX = 0f;
   1089                 }
   1090             } else {
   1091                 mVelocityX += slowDown;
   1092                 if (mVelocityX > 0f) {
   1093                     mVelocityX = 0f;
   1094                 }
   1095             }
   1096             if (mVelocityY > 0f) {
   1097                 mVelocityY -= slowDown;
   1098                 if (mVelocityY < 0f) {
   1099                     mVelocityY = 0f;
   1100                 }
   1101             } else {
   1102                 mVelocityY += slowDown;
   1103                 if (mVelocityY > 0f) {
   1104                     mVelocityY = 0f;
   1105                 }
   1106             }
   1107 
   1108             // Stop when done
   1109             if ((mVelocityX == 0f && mVelocityY == 0f) || !didTranslate) {
   1110                 stop();
   1111                 mHeader.snap();
   1112             }
   1113 
   1114             // See if we need to continue flinging:
   1115             if (mStop) {
   1116                 return;
   1117             }
   1118             mHeader.post(this);
   1119         }
   1120     }
   1121 
   1122     /**
   1123      * Runnable that animates an image translation operation.
   1124      */
   1125     private static class SnapRunnable implements Runnable {
   1126 
   1127         private static final long NEVER = -1L;
   1128 
   1129         private final PhotoView mHeader;
   1130 
   1131         private float mTranslateX;
   1132         private float mTranslateY;
   1133 
   1134         private long mStartRunTime;
   1135         private boolean mRunning;
   1136         private boolean mStop;
   1137 
   1138         public SnapRunnable(PhotoView header) {
   1139             mStartRunTime = NEVER;
   1140             mHeader = header;
   1141         }
   1142 
   1143         /**
   1144          * Starts the animation.
   1145          */
   1146         public boolean start(float translateX, float translateY) {
   1147             if (mRunning) {
   1148                 return false;
   1149             }
   1150             mStartRunTime = NEVER;
   1151             mTranslateX = translateX;
   1152             mTranslateY = translateY;
   1153             mStop = false;
   1154             mRunning = true;
   1155             mHeader.postDelayed(this, SNAP_DELAY);
   1156             return true;
   1157         }
   1158 
   1159         /**
   1160          * Stops the animation in place. It does not snap the image to its final translation.
   1161          */
   1162         public void stop() {
   1163             mRunning = false;
   1164             mStop = true;
   1165         }
   1166 
   1167         @Override
   1168         public void run() {
   1169             // See if we were told to stop:
   1170             if (mStop) {
   1171                 return;
   1172             }
   1173 
   1174             // Translate according to current velocities and time delta:
   1175             long now = System.currentTimeMillis();
   1176             float delta = (mStartRunTime != NEVER) ? (now - mStartRunTime) : 0f;
   1177 
   1178             if (mStartRunTime == NEVER) {
   1179                 mStartRunTime = now;
   1180             }
   1181 
   1182             float transX;
   1183             float transY;
   1184             if (delta >= SNAP_DURATION) {
   1185                 transX = mTranslateX;
   1186                 transY = mTranslateY;
   1187             } else {
   1188                 transX = (mTranslateX / (SNAP_DURATION - delta)) * 10f;
   1189                 transY = (mTranslateY / (SNAP_DURATION - delta)) * 10f;
   1190                 if (Math.abs(transX) > Math.abs(mTranslateX) || transX == Float.NaN) {
   1191                     transX = mTranslateX;
   1192                 }
   1193                 if (Math.abs(transY) > Math.abs(mTranslateY) || transY == Float.NaN) {
   1194                     transY = mTranslateY;
   1195                 }
   1196             }
   1197 
   1198             mHeader.translate(transX, transY);
   1199             mTranslateX -= transX;
   1200             mTranslateY -= transY;
   1201 
   1202             if (mTranslateX == 0 && mTranslateY == 0) {
   1203                 stop();
   1204             }
   1205 
   1206             // See if we need to continue flinging:
   1207             if (mStop) {
   1208                 return;
   1209             }
   1210             mHeader.post(this);
   1211         }
   1212     }
   1213 
   1214     /**
   1215      * Runnable that animates an image rotation operation.
   1216      */
   1217     private static class RotateRunnable implements Runnable {
   1218 
   1219         private static final long NEVER = -1L;
   1220 
   1221         private final PhotoView mHeader;
   1222 
   1223         private float mTargetRotation;
   1224         private float mAppliedRotation;
   1225         private float mVelocity;
   1226         private long mLastRuntime;
   1227 
   1228         private boolean mRunning;
   1229         private boolean mStop;
   1230 
   1231         public RotateRunnable(PhotoView header) {
   1232             mHeader = header;
   1233         }
   1234 
   1235         /**
   1236          * Starts the animation.
   1237          */
   1238         public void start(float rotation) {
   1239             if (mRunning) {
   1240                 return;
   1241             }
   1242 
   1243             mTargetRotation = rotation;
   1244             mVelocity = mTargetRotation / ROTATE_ANIMATION_DURATION;
   1245             mAppliedRotation = 0f;
   1246             mLastRuntime = NEVER;
   1247             mStop = false;
   1248             mRunning = true;
   1249             mHeader.post(this);
   1250         }
   1251 
   1252         /**
   1253          * Stops the animation in place. It does not snap the image to its final rotation.
   1254          */
   1255         public void stop() {
   1256             mRunning = false;
   1257             mStop = true;
   1258         }
   1259 
   1260         @Override
   1261         public void run() {
   1262             if (mStop) {
   1263                 return;
   1264             }
   1265 
   1266             if (mAppliedRotation != mTargetRotation) {
   1267                 long now = System.currentTimeMillis();
   1268                 long delta = mLastRuntime != NEVER ? now - mLastRuntime : 0L;
   1269                 float rotationAmount = mVelocity * delta;
   1270                 if (mAppliedRotation < mTargetRotation
   1271                         && mAppliedRotation + rotationAmount > mTargetRotation
   1272                         || mAppliedRotation > mTargetRotation
   1273                         && mAppliedRotation + rotationAmount < mTargetRotation) {
   1274                     rotationAmount = mTargetRotation - mAppliedRotation;
   1275                 }
   1276                 mHeader.rotate(rotationAmount, false);
   1277                 mAppliedRotation += rotationAmount;
   1278                 if (mAppliedRotation == mTargetRotation) {
   1279                     stop();
   1280                 }
   1281                 mLastRuntime = now;
   1282             }
   1283 
   1284             if (mStop) {
   1285                 return;
   1286             }
   1287             mHeader.post(this);
   1288         }
   1289     }
   1290 
   1291     public void setMaxInitialScale(float f) {
   1292         mMaxInitialScaleFactor = f;
   1293     }
   1294 }
   1295