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