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