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