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