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