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