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