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