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