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