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