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