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