• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2011 Google Inc.
3  * Licensed to The Android Open Source Project.
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  * Unless required by applicable law or agreed to in writing, software
12  * distributed under the License is distributed on an "AS IS" BASIS,
13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  * See the License for the specific language governing permissions and
15  * limitations under the License.
16  */
17 
18 package com.android.ex.photo.views;
19 
20 import android.content.Context;
21 import android.content.res.Resources;
22 import android.graphics.Bitmap;
23 import android.graphics.Canvas;
24 import android.graphics.Matrix;
25 import android.graphics.Paint;
26 import android.graphics.Paint.Style;
27 import android.graphics.Rect;
28 import android.graphics.RectF;
29 import android.graphics.drawable.BitmapDrawable;
30 import android.graphics.drawable.Drawable;
31 import android.support.v4.view.GestureDetectorCompat;
32 import android.support.v4.view.ScaleGestureDetectorCompat;
33 import android.util.AttributeSet;
34 import android.view.GestureDetector.OnDoubleTapListener;
35 import android.view.GestureDetector.OnGestureListener;
36 import android.view.MotionEvent;
37 import android.view.ScaleGestureDetector;
38 import android.view.View;
39 import android.view.ViewConfiguration;
40 
41 import com.android.ex.photo.R;
42 import com.android.ex.photo.fragments.PhotoViewFragment.HorizontallyScrollable;
43 
44 /**
45  * Layout for the photo list view header.
46  */
47 public 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 
PhotoView(Context context)194     public PhotoView(Context context) {
195         super(context);
196         initialize();
197     }
198 
PhotoView(Context context, AttributeSet attrs)199     public PhotoView(Context context, AttributeSet attrs) {
200         super(context, attrs);
201         initialize();
202     }
203 
PhotoView(Context context, AttributeSet attrs, int defStyle)204     public PhotoView(Context context, AttributeSet attrs, int defStyle) {
205         super(context, attrs, defStyle);
206         initialize();
207     }
208 
209     @Override
onTouchEvent(MotionEvent event)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
onDoubleTap(MotionEvent e)233     public boolean onDoubleTap(MotionEvent e) {
234         mDoubleTapOccurred = true;
235         if (!mQuickScaleEnabled) {
236             return scale(e);
237         }
238         return false;
239     }
240 
241     @Override
onDoubleTapEvent(MotionEvent e)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 
scale(MotionEvent e)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
onSingleTapConfirmed(MotionEvent e)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
onSingleTapUp(MotionEvent e)332     public boolean onSingleTapUp(MotionEvent e) {
333         return false;
334     }
335 
336     @Override
onLongPress(MotionEvent e)337     public void onLongPress(MotionEvent e) {
338     }
339 
340     @Override
onShowPress(MotionEvent e)341     public void onShowPress(MotionEvent e) {
342     }
343 
344     @Override
onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)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
onDown(MotionEvent e)353     public boolean onDown(MotionEvent e) {
354         if (mTransformsEnabled) {
355             mTranslateRunnable.stop();
356             mSnapRunnable.stop();
357         }
358         return true;
359     }
360 
361     @Override
onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)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
onScale(ScaleGestureDetector detector)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
onScaleBegin(ScaleGestureDetector detector)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
onScaleEnd(ScaleGestureDetector detector)390     public void onScaleEnd(ScaleGestureDetector detector) {
391         if (mTransformsEnabled && mIsDoubleTouch) {
392             mDoubleTapDebounce = true;
393             resetTransformations();
394         }
395     }
396 
397     @Override
setOnClickListener(OnClickListener listener)398     public void setOnClickListener(OnClickListener listener) {
399         mExternalClickListener = listener;
400     }
401 
402     @Override
interceptMoveLeft(float origX, float origY)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
interceptMoveRight(float origX, float origY)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      */
clear()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 
bindDrawable(Drawable drawable)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      */
bindPhoto(Bitmap photoBitmap)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      */
getPhoto()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      */
getDrawable()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      */
getVideoData()564     public byte[] getVideoData() {
565         return mVideoBlob;
566     }
567 
568     /**
569      * Returns {@code true} if the photo represents a video. Otherwise, {@code false}.
570      */
isVideo()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      */
isVideoReady()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      */
isPhotoBound()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      */
setFullScreen(boolean fullScreen, boolean animate)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      */
enableAllowCrop(boolean allowCrop)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      */
getCroppedPhoto()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      */
resetTransformations()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      */
rotateClockwise()664     public void rotateClockwise() {
665         rotate(90, true);
666     }
667 
668     /**
669      * Rotates the image 90 degrees, counter clockwise.
670      */
rotateCounterClockwise()671     public void rotateCounterClockwise() {
672         rotate(-90, true);
673     }
674 
675     @Override
onDraw(Canvas canvas)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
onLayout(boolean changed, int left, int top, int right, int bottom)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
onMeasure(int widthMeasureSpec, int heightMeasureSpec)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
verifyDrawable(Drawable drawable)754     public boolean verifyDrawable(Drawable drawable) {
755         return mDrawable == drawable || super.verifyDrawable(drawable);
756     }
757 
758     @Override
759     /**
760      * {@inheritDoc}
761      */
invalidateDrawable(Drawable drawable)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      */
setFixedHeight(int fixedHeight)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      */
enableImageTransforms(boolean enable)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      */
configureBounds(boolean changed)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      */
generateMatrix()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 
generateScale()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      */
getCropSize()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      */
getScale()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      */
scale(float newScale, float centerX, float centerY)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      */
translate(float tx, float ty)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      */
snap()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      */
rotate(float degrees, boolean animate)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      */
initialize()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 
ScaleRunnable(PhotoView header)1177         public ScaleRunnable(PhotoView header) {
1178             mHeader = header;
1179         }
1180 
1181         /**
1182          * Starts the animation. There is no target scale bounds check.
1183          */
start(float startScale, float targetScale, float centerX, float centerY)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          */
stop()1207         public void stop() {
1208             mRunning = false;
1209             mStop = true;
1210         }
1211 
1212         @Override
run()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 
TranslateRunnable(PhotoView header)1256         public TranslateRunnable(PhotoView header) {
1257             mLastRunTime = NEVER;
1258             mHeader = header;
1259         }
1260 
1261         /**
1262          * Starts the animation.
1263          */
start(float velocityX, float velocityY)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          */
stop()1285         public void stop() {
1286             mRunning = false;
1287             mStop = true;
1288         }
1289 
1290         @Override
run()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 
SnapRunnable(PhotoView header)1355         public SnapRunnable(PhotoView header) {
1356             mStartRunTime = NEVER;
1357             mHeader = header;
1358         }
1359 
1360         /**
1361          * Starts the animation.
1362          */
start(float translateX, float translateY)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          */
stop()1379         public void stop() {
1380             mRunning = false;
1381             mStop = true;
1382         }
1383 
1384         @Override
run()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 
RotateRunnable(PhotoView header)1448         public RotateRunnable(PhotoView header) {
1449             mHeader = header;
1450         }
1451 
1452         /**
1453          * Starts the animation.
1454          */
start(float rotation)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          */
stop()1472         public void stop() {
1473             mRunning = false;
1474             mStop = true;
1475         }
1476 
1477         @Override
run()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 
setMaxInitialScale(float f)1508     public void setMaxInitialScale(float f) {
1509         mMaxInitialScaleFactor = f;
1510     }
1511 }
1512