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