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