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