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 { 52 public boolean onSingleTap(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 { 62 public void onPopupZoomerShown(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 125 // These bounds are computed and valid for one execution of onDraw. 126 // Extracted to a member variable to save unnecessary allocations on each invocation. 127 private RectF mDrawRect; 128 129 private static float getOverlayCornerRadius(Context context) { 130 if (sOverlayCornerRadius == 0) { 131 try { 132 sOverlayCornerRadius = context.getResources().getDimension( 133 R.dimen.link_preview_overlay_radius); 134 } catch (Resources.NotFoundException e) { 135 Log.w(LOGTAG, "No corner radius resource for PopupZoomer overlay found."); 136 sOverlayCornerRadius = 1.0f; 137 } 138 } 139 return sOverlayCornerRadius; 140 } 141 142 /** 143 * Gets the drawable that should be used to frame the zooming popup, loading 144 * it from the resource bundle if not already cached. 145 */ 146 private static Drawable getOverlayDrawable(Context context) { 147 if (sOverlayDrawable == null) { 148 try { 149 sOverlayDrawable = context.getResources().getDrawable( 150 R.drawable.ondemand_overlay); 151 } catch (Resources.NotFoundException e) { 152 Log.w(LOGTAG, "No drawable resource for PopupZoomer overlay found."); 153 sOverlayDrawable = new ColorDrawable(); 154 } 155 sOverlayPadding = new Rect(); 156 sOverlayDrawable.getPadding(sOverlayPadding); 157 } 158 return sOverlayDrawable; 159 } 160 161 private static float constrain(float amount, float low, float high) { 162 return amount < low ? low : (amount > high ? high : amount); 163 } 164 165 private static int constrain(int amount, int low, int high) { 166 return amount < low ? low : (amount > high ? high : amount); 167 } 168 169 /** 170 * Creates Popupzoomer. 171 * @param context Context to be used. 172 * @param overlayRadiusDimensoinResId Resource to be used to get overlay corner radius. 173 */ 174 public PopupZoomer(Context context) { 175 super(context); 176 177 setVisibility(INVISIBLE); 178 setFocusable(true); 179 setFocusableInTouchMode(true); 180 181 GestureDetector.SimpleOnGestureListener listener = 182 new GestureDetector.SimpleOnGestureListener() { 183 @Override 184 public boolean onScroll(MotionEvent e1, MotionEvent e2, 185 float distanceX, float distanceY) { 186 if (mAnimating) return true; 187 188 if (isTouchOutsideArea(e1.getX(), e1.getY())) { 189 hide(true); 190 } else { 191 scroll(distanceX, distanceY); 192 } 193 return true; 194 } 195 196 @Override 197 public boolean onSingleTapUp(MotionEvent e) { 198 return handleTapOrPress(e, false); 199 } 200 201 @Override 202 public void onLongPress(MotionEvent e) { 203 handleTapOrPress(e, true); 204 } 205 206 private boolean handleTapOrPress(MotionEvent e, boolean isLongPress) { 207 if (mAnimating) return true; 208 209 float x = e.getX(); 210 float y = e.getY(); 211 if (isTouchOutsideArea(x, y)) { 212 // User clicked on area outside the popup. 213 hide(true); 214 } else if (mOnTapListener != null) { 215 PointF converted = convertTouchPoint(x, y); 216 MotionEvent event = MotionEvent.obtainNoHistory(e); 217 event.setLocation(converted.x, converted.y); 218 if (isLongPress) { 219 mOnTapListener.onLongPress(PopupZoomer.this, event); 220 } else { 221 mOnTapListener.onSingleTap(PopupZoomer.this, event); 222 } 223 hide(true); 224 } 225 return true; 226 } 227 }; 228 mGestureDetector = new GestureDetector(context, listener); 229 } 230 231 /** 232 * Sets the OnTapListener. 233 */ 234 public void setOnTapListener(OnTapListener listener) { 235 mOnTapListener = listener; 236 } 237 238 /** 239 * Sets the OnVisibilityChangedListener. 240 */ 241 public void setOnVisibilityChangedListener(OnVisibilityChangedListener listener) { 242 mOnVisibilityChangedListener = listener; 243 } 244 245 /** 246 * Sets the bitmap to be used for the zoomed view. 247 */ 248 public void setBitmap(Bitmap bitmap) { 249 if (mZoomedBitmap != null) { 250 mZoomedBitmap.recycle(); 251 mZoomedBitmap = null; 252 } 253 mZoomedBitmap = bitmap; 254 255 // Round the corners of the bitmap so it doesn't stick out around the overlay. 256 Canvas canvas = new Canvas(mZoomedBitmap); 257 Path path = new Path(); 258 RectF canvasRect = new RectF(0, 0, canvas.getWidth(), canvas.getHeight()); 259 float overlayCornerRadius = getOverlayCornerRadius(getContext()); 260 path.addRoundRect(canvasRect, overlayCornerRadius, overlayCornerRadius, Direction.CCW); 261 canvas.clipPath(path, Op.XOR); 262 Paint clearPaint = new Paint(); 263 clearPaint.setXfermode(new PorterDuffXfermode(Mode.SRC)); 264 clearPaint.setColor(Color.TRANSPARENT); 265 canvas.drawPaint(clearPaint); 266 } 267 268 private void scroll(float x, float y) { 269 mPopupScrollX = constrain(mPopupScrollX - x, mMinScrollX, mMaxScrollX); 270 mPopupScrollY = constrain(mPopupScrollY - y, mMinScrollY, mMaxScrollY); 271 invalidate(); 272 } 273 274 private void startAnimation(boolean show) { 275 mAnimating = true; 276 mShowing = show; 277 mTimeLeft = 0; 278 if (show) { 279 setVisibility(VISIBLE); 280 mNeedsToInitDimensions = true; 281 if (mOnVisibilityChangedListener != null) { 282 mOnVisibilityChangedListener.onPopupZoomerShown(this); 283 } 284 } else { 285 long endTime = mAnimationStartTime + ANIMATION_DURATION; 286 mTimeLeft = endTime - SystemClock.uptimeMillis(); 287 if (mTimeLeft < 0) mTimeLeft = 0; 288 } 289 mAnimationStartTime = SystemClock.uptimeMillis(); 290 invalidate(); 291 } 292 293 private void hideImmediately() { 294 mAnimating = false; 295 mShowing = false; 296 mTimeLeft = 0; 297 if (mOnVisibilityChangedListener != null) { 298 mOnVisibilityChangedListener.onPopupZoomerHidden(this); 299 } 300 setVisibility(INVISIBLE); 301 mZoomedBitmap.recycle(); 302 mZoomedBitmap = null; 303 } 304 305 /** 306 * Returns true if the view is currently being shown (or is animating). 307 */ 308 public boolean isShowing() { 309 return mShowing || mAnimating; 310 } 311 312 /** 313 * Sets the last touch point (on the unzoomed view). 314 */ 315 public void setLastTouch(float x, float y) { 316 mTouch.x = x; 317 mTouch.y = y; 318 } 319 320 private void setTargetBounds(Rect rect) { 321 mTargetBounds = rect; 322 } 323 324 private void initDimensions() { 325 if (mTargetBounds == null || mTouch == null) return; 326 327 // Compute the final zoom scale. 328 mScale = (float) mZoomedBitmap.getWidth() / mTargetBounds.width(); 329 330 float l = mTouch.x - mScale * (mTouch.x - mTargetBounds.left); 331 float t = mTouch.y - mScale * (mTouch.y - mTargetBounds.top); 332 float r = l + mZoomedBitmap.getWidth(); 333 float b = t + mZoomedBitmap.getHeight(); 334 mClipRect = new RectF(l, t, r, b); 335 int width = getWidth(); 336 int height = getHeight(); 337 338 mViewClipRect = new RectF(ZOOM_BOUNDS_MARGIN, 339 ZOOM_BOUNDS_MARGIN, 340 width - ZOOM_BOUNDS_MARGIN, 341 height - ZOOM_BOUNDS_MARGIN); 342 343 // Ensure it stays inside the bounds of the view. First shift it around to see if it 344 // can fully fit in the view, then clip it to the padding section of the view to 345 // ensure no overflow. 346 mShiftX = 0; 347 mShiftY = 0; 348 349 // Right now this has the happy coincidence of showing the leftmost portion 350 // of a scaled up bitmap, which usually has the text in it. When we want to support 351 // RTL languages, we can conditionally switch the order of this check to push it 352 // to the left instead of right. 353 if (mClipRect.left < ZOOM_BOUNDS_MARGIN) { 354 mShiftX = ZOOM_BOUNDS_MARGIN - mClipRect.left; 355 mClipRect.left += mShiftX; 356 mClipRect.right += mShiftX; 357 } else if (mClipRect.right > width - ZOOM_BOUNDS_MARGIN) { 358 mShiftX = (width - ZOOM_BOUNDS_MARGIN - mClipRect.right); 359 mClipRect.right += mShiftX; 360 mClipRect.left += mShiftX; 361 } 362 if (mClipRect.top < ZOOM_BOUNDS_MARGIN) { 363 mShiftY = ZOOM_BOUNDS_MARGIN - mClipRect.top; 364 mClipRect.top += mShiftY; 365 mClipRect.bottom += mShiftY; 366 } else if (mClipRect.bottom > height - ZOOM_BOUNDS_MARGIN) { 367 mShiftY = height - ZOOM_BOUNDS_MARGIN - mClipRect.bottom; 368 mClipRect.bottom += mShiftY; 369 mClipRect.top += mShiftY; 370 } 371 372 // Allow enough scrolling to get to the entire bitmap that may be clipped inside the 373 // bounds of the view. 374 mMinScrollX = mMaxScrollX = mMinScrollY = mMaxScrollY = 0; 375 if (mViewClipRect.right + mShiftX < mClipRect.right) { 376 mMinScrollX = mViewClipRect.right - mClipRect.right; 377 } 378 if (mViewClipRect.left + mShiftX > mClipRect.left) { 379 mMaxScrollX = mViewClipRect.left - mClipRect.left; 380 } 381 if (mViewClipRect.top + mShiftY > mClipRect.top) { 382 mMaxScrollY = mViewClipRect.top - mClipRect.top; 383 } 384 if (mViewClipRect.bottom + mShiftY < mClipRect.bottom) { 385 mMinScrollY = mViewClipRect.bottom - mClipRect.bottom; 386 } 387 // Now that we know how much we need to scroll, we can intersect with mViewClipRect. 388 mClipRect.intersect(mViewClipRect); 389 390 mLeftExtrusion = mTouch.x - mClipRect.left; 391 mRightExtrusion = mClipRect.right - mTouch.x; 392 mTopExtrusion = mTouch.y - mClipRect.top; 393 mBottomExtrusion = mClipRect.bottom - mTouch.y; 394 395 // Set an initial scroll position to take touch point into account. 396 float percentX = 397 (mTouch.x - mTargetBounds.centerX()) / (mTargetBounds.width() / 2.f) + .5f; 398 float percentY = 399 (mTouch.y - mTargetBounds.centerY()) / (mTargetBounds.height() / 2.f) + .5f; 400 401 float scrollWidth = mMaxScrollX - mMinScrollX; 402 float scrollHeight = mMaxScrollY - mMinScrollY; 403 mPopupScrollX = scrollWidth * percentX * -1f; 404 mPopupScrollY = scrollHeight * percentY * -1f; 405 // Constrain initial scroll position within allowed bounds. 406 mPopupScrollX = constrain(mPopupScrollX, mMinScrollX, mMaxScrollX); 407 mPopupScrollY = constrain(mPopupScrollY, mMinScrollY, mMaxScrollY); 408 409 // Compute the bounds in onDraw() 410 mDrawRect = new RectF(); 411 } 412 413 /* 414 * Tests override it as the PopupZoomer is never attached to the view hierarchy. 415 */ 416 protected boolean acceptZeroSizeView() { 417 return false; 418 } 419 420 @Override 421 protected void onDraw(Canvas canvas) { 422 if (!isShowing() || mZoomedBitmap == null) return; 423 if (!acceptZeroSizeView() && (getWidth() == 0 || getHeight() == 0)) return; 424 425 if (mNeedsToInitDimensions) { 426 mNeedsToInitDimensions = false; 427 initDimensions(); 428 } 429 430 canvas.save(); 431 // Calculate the elapsed fraction of animation. 432 float time = (SystemClock.uptimeMillis() - mAnimationStartTime + mTimeLeft) / 433 ((float) ANIMATION_DURATION); 434 time = constrain(time, 0, 1); 435 if (time >= 1) { 436 mAnimating = false; 437 if (!isShowing()) { 438 hideImmediately(); 439 return; 440 } 441 } else { 442 invalidate(); 443 } 444 445 // Fraction of the animation to actally show. 446 float fractionAnimation; 447 if (mShowing) { 448 fractionAnimation = mShowInterpolator.getInterpolation(time); 449 } else { 450 fractionAnimation = mHideInterpolator.getInterpolation(time); 451 } 452 453 // Draw a faded color over the entire view to fade out the original content, increasing 454 // the alpha value as fractionAnimation increases. 455 // TODO(nileshagrawal): We should use time here instead of fractionAnimation 456 // as fractionAnimaton is interpolated and can go over 1. 457 canvas.drawARGB((int) (80 * fractionAnimation), 0, 0, 0); 458 canvas.save(); 459 460 // Since we want the content to appear directly above its counterpart we need to make 461 // sure that it starts out at exactly the same size as it appears in the page, 462 // i.e. scale grows from 1/mScale to 1. Note that extrusion values are already zoomed 463 // with mScale. 464 float scale = fractionAnimation * (mScale - 1.0f) / mScale + 1.0f / mScale; 465 466 // Since we want the content to appear directly above its counterpart on the 467 // page, we need to remove the mShiftX/Y effect at the beginning of the animation. 468 // The unshifting decreases with the animation. 469 float unshiftX = -mShiftX * (1.0f - fractionAnimation) / mScale; 470 float unshiftY = -mShiftY * (1.0f - fractionAnimation) / mScale; 471 472 // Compute the |mDrawRect| to show. 473 mDrawRect.left = mTouch.x - mLeftExtrusion * scale + unshiftX; 474 mDrawRect.top = mTouch.y - mTopExtrusion * scale + unshiftY; 475 mDrawRect.right = mTouch.x + mRightExtrusion * scale + unshiftX; 476 mDrawRect.bottom = mTouch.y + mBottomExtrusion * scale + unshiftY; 477 canvas.clipRect(mDrawRect); 478 479 // Since the canvas transform APIs all pre-concat the transformations, this is done in 480 // reverse order. The canvas is first scaled up, then shifted the appropriate amount of 481 // pixels. 482 canvas.scale(scale, scale, mDrawRect.left, mDrawRect.top); 483 canvas.translate(mPopupScrollX, mPopupScrollY); 484 canvas.drawBitmap(mZoomedBitmap, mDrawRect.left, mDrawRect.top, null); 485 canvas.restore(); 486 Drawable overlayNineTile = getOverlayDrawable(getContext()); 487 overlayNineTile.setBounds((int) mDrawRect.left - sOverlayPadding.left, 488 (int) mDrawRect.top - sOverlayPadding.top, 489 (int) mDrawRect.right + sOverlayPadding.right, 490 (int) mDrawRect.bottom + sOverlayPadding.bottom); 491 // TODO(nileshagrawal): We should use time here instead of fractionAnimation 492 // as fractionAnimaton is interpolated and can go over 1. 493 int alpha = constrain((int) (fractionAnimation * 255), 0, 255); 494 overlayNineTile.setAlpha(alpha); 495 overlayNineTile.draw(canvas); 496 canvas.restore(); 497 } 498 499 /** 500 * Show the PopupZoomer view with given target bounds. 501 */ 502 public void show(Rect rect) { 503 if (mShowing || mZoomedBitmap == null) return; 504 505 setTargetBounds(rect); 506 startAnimation(true); 507 } 508 509 /** 510 * Hide the PopupZoomer view. 511 * @param animation true if hide with animation. 512 */ 513 public void hide(boolean animation) { 514 if (!mShowing) return; 515 516 if (animation) { 517 startAnimation(false); 518 } else { 519 hideImmediately(); 520 } 521 } 522 523 /** 524 * Converts the coordinates to a point on the original un-zoomed view. 525 */ 526 private PointF convertTouchPoint(float x, float y) { 527 x -= mShiftX; 528 y -= mShiftY; 529 x = mTouch.x + (x - mTouch.x - mPopupScrollX) / mScale; 530 y = mTouch.y + (y - mTouch.y - mPopupScrollY) / mScale; 531 return new PointF(x, y); 532 } 533 534 /** 535 * Returns true if the point is inside the final drawable area for this popup zoomer. 536 */ 537 private boolean isTouchOutsideArea(float x, float y) { 538 return !mClipRect.contains(x, y); 539 } 540 541 @Override 542 public boolean onTouchEvent(MotionEvent event) { 543 mGestureDetector.onTouchEvent(event); 544 return true; 545 } 546 547 private static class ReverseInterpolator implements Interpolator { 548 private final Interpolator mInterpolator; 549 550 public ReverseInterpolator(Interpolator i) { 551 mInterpolator = i; 552 } 553 554 @Override 555 public float getInterpolation(float input) { 556 input = 1.0f - input; 557 if (mInterpolator == null) return input; 558 return mInterpolator.getInterpolation(input); 559 } 560 } 561 } 562