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