1 // Copyright 2012 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.content.browser; 6 7 import android.content.Context; 8 import android.content.res.Resources; 9 import android.graphics.Bitmap; 10 import android.graphics.Canvas; 11 import android.graphics.Color; 12 import android.graphics.Paint; 13 import android.graphics.Path; 14 import android.graphics.Path.Direction; 15 import android.graphics.PointF; 16 import android.graphics.PorterDuff.Mode; 17 import android.graphics.PorterDuffXfermode; 18 import android.graphics.Rect; 19 import android.graphics.RectF; 20 import android.graphics.Region.Op; 21 import android.graphics.drawable.ColorDrawable; 22 import android.graphics.drawable.Drawable; 23 import android.os.SystemClock; 24 import android.util.Log; 25 import android.view.GestureDetector; 26 import android.view.MotionEvent; 27 import android.view.View; 28 import android.view.animation.Interpolator; 29 import android.view.animation.OvershootInterpolator; 30 31 import org.chromium.content.R; 32 33 /** 34 * PopupZoomer is used to show the on-demand link zooming popup. It handles manipulation of the 35 * canvas and touch events to display the on-demand zoom magnifier. 36 */ 37 class PopupZoomer extends View { 38 private static final String LOGTAG = "PopupZoomer"; 39 40 // The padding between the edges of the view and the popup. Note that there is a mirror 41 // constant in content/renderer/render_view_impl.cc which should be kept in sync if 42 // this is changed. 43 private static final int ZOOM_BOUNDS_MARGIN = 25; 44 // Time it takes for the animation to finish in ms. 45 private static final long ANIMATION_DURATION = 300; 46 47 /** 48 * Interface to be implemented to listen for touch events inside the zoomed area. 49 * The MotionEvent coordinates correspond to original unzoomed view. 50 */ 51 public static interface OnTapListener { onSingleTap(View v, MotionEvent event)52 public boolean onSingleTap(View v, MotionEvent event); onLongPress(View v, MotionEvent event)53 public boolean onLongPress(View v, MotionEvent event); 54 } 55 56 private OnTapListener mOnTapListener = null; 57 58 /** 59 * Interface to be implemented to add and remove PopupZoomer to/from the view hierarchy. 60 */ 61 public static interface OnVisibilityChangedListener { onPopupZoomerShown(PopupZoomer zoomer)62 public void onPopupZoomerShown(PopupZoomer zoomer); onPopupZoomerHidden(PopupZoomer zoomer)63 public void onPopupZoomerHidden(PopupZoomer zoomer); 64 } 65 66 private OnVisibilityChangedListener mOnVisibilityChangedListener = null; 67 68 // Cached drawable used to frame the zooming popup. 69 // TODO(tonyg): This should be marked purgeable so that if the system wants to recover this 70 // memory, we can just reload it from the resource ID next time it is needed. 71 // See android.graphics.BitmapFactory.Options#inPurgeable 72 private static Drawable sOverlayDrawable; 73 // The padding used for drawing the overlay around the content, instead of directly above it. 74 private static Rect sOverlayPadding; 75 // The radius of the overlay bubble, used for rounding the bitmap to draw underneath it. 76 private static float sOverlayCornerRadius; 77 78 private final Interpolator mShowInterpolator = new OvershootInterpolator(); 79 private final Interpolator mHideInterpolator = new ReverseInterpolator(mShowInterpolator); 80 81 private boolean mAnimating = false; 82 private boolean mShowing = false; 83 private long mAnimationStartTime = 0; 84 85 // The time that was left for the outwards animation to finish. 86 // This is used in the case that the zoomer is cancelled while it is still animating outwards, 87 // to avoid having it jump to full size then animate closed. 88 private long mTimeLeft = 0; 89 90 // initDimensions() needs to be called in onDraw(). 91 private boolean mNeedsToInitDimensions; 92 93 // Available view area after accounting for ZOOM_BOUNDS_MARGIN. 94 private RectF mViewClipRect; 95 96 // The target rect to be zoomed. 97 private Rect mTargetBounds; 98 99 // The bitmap to hold the zoomed view. 100 private Bitmap mZoomedBitmap; 101 102 // How far to shift the canvas after all zooming is done, to keep it inside the bounds of the 103 // view (including margin). 104 private float mShiftX = 0, mShiftY = 0; 105 // The magnification factor of the popup. It is recomputed once we have mTargetBounds and 106 // mZoomedBitmap. 107 private float mScale = 1.0f; 108 // The bounds representing the actual zoomed popup. 109 private RectF mClipRect; 110 // The extrusion values are how far the zoomed area (mClipRect) extends from the touch point. 111 // These values to used to animate the popup. 112 private float mLeftExtrusion, mTopExtrusion, mRightExtrusion, mBottomExtrusion; 113 // The last touch point, where the animation will start from. 114 private final PointF mTouch = new PointF(); 115 116 // Since we sometimes overflow the bounds of the mViewClipRect, we need to allow scrolling. 117 // Current scroll position. 118 private float mPopupScrollX, mPopupScrollY; 119 // Scroll bounds. 120 private float mMinScrollX, mMaxScrollX; 121 private float mMinScrollY, mMaxScrollY; 122 123 private GestureDetector mGestureDetector; 124 getOverlayCornerRadius(Context context)125 private static float getOverlayCornerRadius(Context context) { 126 if (sOverlayCornerRadius == 0) { 127 try { 128 sOverlayCornerRadius = context.getResources().getDimension( 129 R.dimen.link_preview_overlay_radius); 130 } catch (Resources.NotFoundException e) { 131 Log.w(LOGTAG, "No corner radius resource for PopupZoomer overlay found."); 132 sOverlayCornerRadius = 1.0f; 133 } 134 } 135 return sOverlayCornerRadius; 136 } 137 138 /** 139 * Gets the drawable that should be used to frame the zooming popup, loading 140 * it from the resource bundle if not already cached. 141 */ getOverlayDrawable(Context context)142 private static Drawable getOverlayDrawable(Context context) { 143 if (sOverlayDrawable == null) { 144 try { 145 sOverlayDrawable = context.getResources().getDrawable( 146 R.drawable.ondemand_overlay); 147 } catch (Resources.NotFoundException e) { 148 Log.w(LOGTAG, "No drawable resource for PopupZoomer overlay found."); 149 sOverlayDrawable = new ColorDrawable(); 150 } 151 sOverlayPadding = new Rect(); 152 sOverlayDrawable.getPadding(sOverlayPadding); 153 } 154 return sOverlayDrawable; 155 } 156 constrain(float amount, float low, float high)157 private static float constrain(float amount, float low, float high) { 158 return amount < low ? low : (amount > high ? high : amount); 159 } 160 constrain(int amount, int low, int high)161 private static int constrain(int amount, int low, int high) { 162 return amount < low ? low : (amount > high ? high : amount); 163 } 164 165 /** 166 * Creates Popupzoomer. 167 * @param context Context to be used. 168 * @param overlayRadiusDimensoinResId Resource to be used to get overlay corner radius. 169 */ PopupZoomer(Context context)170 public PopupZoomer(Context context) { 171 super(context); 172 173 setVisibility(INVISIBLE); 174 setFocusable(true); 175 setFocusableInTouchMode(true); 176 177 GestureDetector.SimpleOnGestureListener listener = 178 new GestureDetector.SimpleOnGestureListener() { 179 @Override 180 public boolean onScroll(MotionEvent e1, MotionEvent e2, 181 float distanceX, float distanceY) { 182 if (mAnimating) return true; 183 184 if (isTouchOutsideArea(e1.getX(), e1.getY())) { 185 hide(true); 186 } else { 187 scroll(distanceX, distanceY); 188 } 189 return true; 190 } 191 192 @Override 193 public boolean onSingleTapUp(MotionEvent e) { 194 return handleTapOrPress(e, false); 195 } 196 197 @Override 198 public void onLongPress(MotionEvent e) { 199 handleTapOrPress(e, true); 200 } 201 202 private boolean handleTapOrPress(MotionEvent e, boolean isLongPress) { 203 if (mAnimating) return true; 204 205 float x = e.getX(); 206 float y = e.getY(); 207 if (isTouchOutsideArea(x, y)) { 208 // User clicked on area outside the popup. 209 hide(true); 210 } else if (mOnTapListener != null) { 211 PointF converted = convertTouchPoint(x, y); 212 MotionEvent event = MotionEvent.obtainNoHistory(e); 213 event.setLocation(converted.x, converted.y); 214 if (isLongPress) { 215 mOnTapListener.onLongPress(PopupZoomer.this, event); 216 } else { 217 mOnTapListener.onSingleTap(PopupZoomer.this, event); 218 } 219 hide(true); 220 } 221 return true; 222 } 223 }; 224 mGestureDetector = new GestureDetector(context, listener); 225 } 226 227 /** 228 * Sets the OnTapListener. 229 */ setOnTapListener(OnTapListener listener)230 public void setOnTapListener(OnTapListener listener) { 231 mOnTapListener = listener; 232 } 233 234 /** 235 * Sets the OnVisibilityChangedListener. 236 */ setOnVisibilityChangedListener(OnVisibilityChangedListener listener)237 public void setOnVisibilityChangedListener(OnVisibilityChangedListener listener) { 238 mOnVisibilityChangedListener = listener; 239 } 240 241 /** 242 * Sets the bitmap to be used for the zoomed view. 243 */ setBitmap(Bitmap bitmap)244 public void setBitmap(Bitmap bitmap) { 245 if (mZoomedBitmap != null) { 246 mZoomedBitmap.recycle(); 247 mZoomedBitmap = null; 248 } 249 mZoomedBitmap = bitmap; 250 251 // Round the corners of the bitmap so it doesn't stick out around the overlay. 252 Canvas canvas = new Canvas(mZoomedBitmap); 253 Path path = new Path(); 254 RectF canvasRect = new RectF(0, 0, canvas.getWidth(), canvas.getHeight()); 255 float overlayCornerRadius = getOverlayCornerRadius(getContext()); 256 path.addRoundRect(canvasRect, overlayCornerRadius, overlayCornerRadius, Direction.CCW); 257 canvas.clipPath(path, Op.XOR); 258 Paint clearPaint = new Paint(); 259 clearPaint.setXfermode(new PorterDuffXfermode(Mode.SRC)); 260 clearPaint.setColor(Color.TRANSPARENT); 261 canvas.drawPaint(clearPaint); 262 } 263 scroll(float x, float y)264 private void scroll(float x, float y) { 265 mPopupScrollX = constrain(mPopupScrollX - x, mMinScrollX, mMaxScrollX); 266 mPopupScrollY = constrain(mPopupScrollY - y, mMinScrollY, mMaxScrollY); 267 invalidate(); 268 } 269 startAnimation(boolean show)270 private void startAnimation(boolean show) { 271 mAnimating = true; 272 mShowing = show; 273 mTimeLeft = 0; 274 if (show) { 275 setVisibility(VISIBLE); 276 mNeedsToInitDimensions = true; 277 if (mOnVisibilityChangedListener != null) { 278 mOnVisibilityChangedListener.onPopupZoomerShown(this); 279 } 280 } else { 281 long endTime = mAnimationStartTime + ANIMATION_DURATION; 282 mTimeLeft = endTime - SystemClock.uptimeMillis(); 283 if (mTimeLeft < 0) mTimeLeft = 0; 284 } 285 mAnimationStartTime = SystemClock.uptimeMillis(); 286 invalidate(); 287 } 288 hideImmediately()289 private void hideImmediately() { 290 mAnimating = false; 291 mShowing = false; 292 mTimeLeft = 0; 293 if (mOnVisibilityChangedListener != null) { 294 mOnVisibilityChangedListener.onPopupZoomerHidden(this); 295 } 296 setVisibility(INVISIBLE); 297 mZoomedBitmap.recycle(); 298 mZoomedBitmap = null; 299 } 300 301 /** 302 * Returns true if the view is currently being shown (or is animating). 303 */ isShowing()304 public boolean isShowing() { 305 return mShowing || mAnimating; 306 } 307 308 /** 309 * Sets the last touch point (on the unzoomed view). 310 */ setLastTouch(float x, float y)311 public void setLastTouch(float x, float y) { 312 mTouch.x = x; 313 mTouch.y = y; 314 } 315 setTargetBounds(Rect rect)316 private void setTargetBounds(Rect rect) { 317 mTargetBounds = rect; 318 } 319 initDimensions()320 private void initDimensions() { 321 if (mTargetBounds == null || mTouch == null) return; 322 323 // Compute the final zoom scale. 324 mScale = (float) mZoomedBitmap.getWidth() / mTargetBounds.width(); 325 326 float l = mTouch.x - mScale * (mTouch.x - mTargetBounds.left); 327 float t = mTouch.y - mScale * (mTouch.y - mTargetBounds.top); 328 float r = l + mZoomedBitmap.getWidth(); 329 float b = t + mZoomedBitmap.getHeight(); 330 mClipRect = new RectF(l, t, r, b); 331 int width = getWidth(); 332 int height = getHeight(); 333 334 mViewClipRect = new RectF(ZOOM_BOUNDS_MARGIN, 335 ZOOM_BOUNDS_MARGIN, 336 width - ZOOM_BOUNDS_MARGIN, 337 height - ZOOM_BOUNDS_MARGIN); 338 339 // Ensure it stays inside the bounds of the view. First shift it around to see if it 340 // can fully fit in the view, then clip it to the padding section of the view to 341 // ensure no overflow. 342 mShiftX = 0; 343 mShiftY = 0; 344 345 // Right now this has the happy coincidence of showing the leftmost portion 346 // of a scaled up bitmap, which usually has the text in it. When we want to support 347 // RTL languages, we can conditionally switch the order of this check to push it 348 // to the left instead of right. 349 if (mClipRect.left < ZOOM_BOUNDS_MARGIN) { 350 mShiftX = ZOOM_BOUNDS_MARGIN - mClipRect.left; 351 mClipRect.left += mShiftX; 352 mClipRect.right += mShiftX; 353 } else if (mClipRect.right > width - ZOOM_BOUNDS_MARGIN) { 354 mShiftX = (width - ZOOM_BOUNDS_MARGIN - mClipRect.right); 355 mClipRect.right += mShiftX; 356 mClipRect.left += mShiftX; 357 } 358 if (mClipRect.top < ZOOM_BOUNDS_MARGIN) { 359 mShiftY = ZOOM_BOUNDS_MARGIN - mClipRect.top; 360 mClipRect.top += mShiftY; 361 mClipRect.bottom += mShiftY; 362 } else if (mClipRect.bottom > height - ZOOM_BOUNDS_MARGIN) { 363 mShiftY = height - ZOOM_BOUNDS_MARGIN - mClipRect.bottom; 364 mClipRect.bottom += mShiftY; 365 mClipRect.top += mShiftY; 366 } 367 368 // Allow enough scrolling to get to the entire bitmap that may be clipped inside the 369 // bounds of the view. 370 mMinScrollX = mMaxScrollX = mMinScrollY = mMaxScrollY = 0; 371 if (mViewClipRect.right + mShiftX < mClipRect.right) { 372 mMinScrollX = mViewClipRect.right - mClipRect.right; 373 } 374 if (mViewClipRect.left + mShiftX > mClipRect.left) { 375 mMaxScrollX = mViewClipRect.left - mClipRect.left; 376 } 377 if (mViewClipRect.top + mShiftY > mClipRect.top) { 378 mMaxScrollY = mViewClipRect.top - mClipRect.top; 379 } 380 if (mViewClipRect.bottom + mShiftY < mClipRect.bottom) { 381 mMinScrollY = mViewClipRect.bottom - mClipRect.bottom; 382 } 383 // Now that we know how much we need to scroll, we can intersect with mViewClipRect. 384 mClipRect.intersect(mViewClipRect); 385 386 mLeftExtrusion = mTouch.x - mClipRect.left; 387 mRightExtrusion = mClipRect.right - mTouch.x; 388 mTopExtrusion = mTouch.y - mClipRect.top; 389 mBottomExtrusion = mClipRect.bottom - mTouch.y; 390 391 // Set an initial scroll position to take touch point into account. 392 float percentX = 393 (mTouch.x - mTargetBounds.centerX()) / (mTargetBounds.width() / 2.f) + .5f; 394 float percentY = 395 (mTouch.y - mTargetBounds.centerY()) / (mTargetBounds.height() / 2.f) + .5f; 396 397 float scrollWidth = mMaxScrollX - mMinScrollX; 398 float scrollHeight = mMaxScrollY - mMinScrollY; 399 mPopupScrollX = scrollWidth * percentX * -1f; 400 mPopupScrollY = scrollHeight * percentY * -1f; 401 // Constrain initial scroll position within allowed bounds. 402 mPopupScrollX = constrain(mPopupScrollX, mMinScrollX, mMaxScrollX); 403 mPopupScrollY = constrain(mPopupScrollY, mMinScrollY, mMaxScrollY); 404 } 405 406 /* 407 * Tests override it as the PopupZoomer is never attached to the view hierarchy. 408 */ acceptZeroSizeView()409 protected boolean acceptZeroSizeView() { 410 return false; 411 } 412 413 @Override onDraw(Canvas canvas)414 protected void onDraw(Canvas canvas) { 415 if (!isShowing() || mZoomedBitmap == null) return; 416 if (!acceptZeroSizeView() && (getWidth() == 0 || getHeight() == 0)) return; 417 418 if (mNeedsToInitDimensions) { 419 mNeedsToInitDimensions = false; 420 initDimensions(); 421 } 422 423 canvas.save(); 424 // Calculate the elapsed fraction of animation. 425 float time = (SystemClock.uptimeMillis() - mAnimationStartTime + mTimeLeft) / 426 ((float) ANIMATION_DURATION); 427 time = constrain(time, 0, 1); 428 if (time >= 1) { 429 mAnimating = false; 430 if (!isShowing()) { 431 hideImmediately(); 432 return; 433 } 434 } else { 435 invalidate(); 436 } 437 438 // Fraction of the animation to actally show. 439 float fractionAnimation; 440 if (mShowing) { 441 fractionAnimation = mShowInterpolator.getInterpolation(time); 442 } else { 443 fractionAnimation = mHideInterpolator.getInterpolation(time); 444 } 445 446 // Draw a faded color over the entire view to fade out the original content, increasing 447 // the alpha value as fractionAnimation increases. 448 // TODO(nileshagrawal): We should use time here instead of fractionAnimation 449 // as fractionAnimaton is interpolated and can go over 1. 450 canvas.drawARGB((int) (80 * fractionAnimation), 0, 0, 0); 451 canvas.save(); 452 453 // Since we want the content to appear directly above its counterpart we need to make 454 // sure that it starts out at exactly the same size as it appears in the page, 455 // i.e. scale grows from 1/mScale to 1. Note that extrusion values are already zoomed 456 // with mScale. 457 float scale = fractionAnimation * (mScale - 1.0f) / mScale + 1.0f / mScale; 458 459 // Since we want the content to appear directly above its counterpart on the 460 // page, we need to remove the mShiftX/Y effect at the beginning of the animation. 461 // The unshifting decreases with the animation. 462 float unshiftX = -mShiftX * (1.0f - fractionAnimation) / mScale; 463 float unshiftY = -mShiftY * (1.0f - fractionAnimation) / mScale; 464 465 // Compute the rect to show. 466 RectF rect = new RectF(); 467 rect.left = mTouch.x - mLeftExtrusion * scale + unshiftX; 468 rect.top = mTouch.y - mTopExtrusion * scale + unshiftY; 469 rect.right = mTouch.x + mRightExtrusion * scale + unshiftX; 470 rect.bottom = mTouch.y + mBottomExtrusion * scale + unshiftY; 471 canvas.clipRect(rect); 472 473 // Since the canvas transform APIs all pre-concat the transformations, this is done in 474 // reverse order. The canvas is first scaled up, then shifted the appropriate amount of 475 // pixels. 476 canvas.scale(scale, scale, rect.left, rect.top); 477 canvas.translate(mPopupScrollX, mPopupScrollY); 478 canvas.drawBitmap(mZoomedBitmap, rect.left, rect.top, null); 479 canvas.restore(); 480 Drawable overlayNineTile = getOverlayDrawable(getContext()); 481 overlayNineTile.setBounds((int) rect.left - sOverlayPadding.left, 482 (int) rect.top - sOverlayPadding.top, 483 (int) rect.right + sOverlayPadding.right, 484 (int) rect.bottom + sOverlayPadding.bottom); 485 // TODO(nileshagrawal): We should use time here instead of fractionAnimation 486 // as fractionAnimaton is interpolated and can go over 1. 487 int alpha = constrain((int) (fractionAnimation * 255), 0, 255); 488 overlayNineTile.setAlpha(alpha); 489 overlayNineTile.draw(canvas); 490 canvas.restore(); 491 } 492 493 /** 494 * Show the PopupZoomer view with given target bounds. 495 */ show(Rect rect)496 public void show(Rect rect) { 497 if (mShowing || mZoomedBitmap == null) return; 498 499 setTargetBounds(rect); 500 startAnimation(true); 501 } 502 503 /** 504 * Hide the PopupZoomer view. 505 * @param animation true if hide with animation. 506 */ hide(boolean animation)507 public void hide(boolean animation) { 508 if (!mShowing) return; 509 510 if (animation) { 511 startAnimation(false); 512 } else { 513 hideImmediately(); 514 } 515 } 516 517 /** 518 * Converts the coordinates to a point on the original un-zoomed view. 519 */ convertTouchPoint(float x, float y)520 private PointF convertTouchPoint(float x, float y) { 521 x -= mShiftX; 522 y -= mShiftY; 523 x = mTouch.x + (x - mTouch.x - mPopupScrollX) / mScale; 524 y = mTouch.y + (y - mTouch.y - mPopupScrollY) / mScale; 525 return new PointF(x, y); 526 } 527 528 /** 529 * Returns true if the point is inside the final drawable area for this popup zoomer. 530 */ isTouchOutsideArea(float x, float y)531 private boolean isTouchOutsideArea(float x, float y) { 532 return !mClipRect.contains(x, y); 533 } 534 535 @Override onTouchEvent(MotionEvent event)536 public boolean onTouchEvent(MotionEvent event) { 537 mGestureDetector.onTouchEvent(event); 538 return true; 539 } 540 541 private static class ReverseInterpolator implements Interpolator { 542 private final Interpolator mInterpolator; 543 ReverseInterpolator(Interpolator i)544 public ReverseInterpolator(Interpolator i) { 545 mInterpolator = i; 546 } 547 548 @Override getInterpolation(float input)549 public float getInterpolation(float input) { 550 input = 1.0f - input; 551 if (mInterpolator == null) return input; 552 return mInterpolator.getInterpolation(input); 553 } 554 } 555 } 556