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