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