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