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