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.
5 package org.chromium.content.browser;
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;
31 import org.chromium.content.R;
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.
37 class PopupZoomer extends View {
38 private static final String LOGTAG = "PopupZoomer";
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
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;
48 * Interface to be implemented to listen for touch events inside the zoomed area.
49 * The MotionEvent coordinates correspond to original unzoomed view.
51 public static interface OnTapListener {
52 public boolean onSingleTap(View v, MotionEvent event);
53 public boolean onLongPress(View v, MotionEvent event);
56 private OnTapListener mOnTapListener = null;
59 * Interface to be implemented to add and remove PopupZoomer to/from the view hierarchy.
61 public static interface OnVisibilityChangedListener {
62 public void onPopupZoomerShown(PopupZoomer zoomer);
63 public void onPopupZoomerHidden(PopupZoomer zoomer);
66 private OnVisibilityChangedListener mOnVisibilityChangedListener = null;
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;
78 private final Interpolator mShowInterpolator = new OvershootInterpolator();
79 private final Interpolator mHideInterpolator = new ReverseInterpolator(mShowInterpolator);
81 private boolean mAnimating = false;
82 private boolean mShowing = false;
83 private long mAnimationStartTime = 0;
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;
90 // initDimensions() needs to be called in onDraw().
91 private boolean mNeedsToInitDimensions;
93 // Available view area after accounting for ZOOM_BOUNDS_MARGIN.
94 private RectF mViewClipRect;
96 // The target rect to be zoomed.
97 private Rect mTargetBounds;
99 // The bitmap to hold the zoomed view.
100 private Bitmap mZoomedBitmap;
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
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();
116 // Since we sometimes overflow the bounds of the mViewClipRect, we need to allow scrolling.
117 // Current scroll position.
118 private float mPopupScrollX, mPopupScrollY;
120 private float mMinScrollX, mMaxScrollX;
121 private float mMinScrollY, mMaxScrollY;
123 private GestureDetector mGestureDetector;
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;
129 private static float getOverlayCornerRadius(Context context) {
130 if (sOverlayCornerRadius == 0) {
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;
139 return sOverlayCornerRadius;
143 * Gets the drawable that should be used to frame the zooming popup, loading
144 * it from the resource bundle if not already cached.
146 private static Drawable getOverlayDrawable(Context context) {
147 if (sOverlayDrawable == null) {
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();
155 sOverlayPadding = new Rect();
156 sOverlayDrawable.getPadding(sOverlayPadding);
158 return sOverlayDrawable;
161 private static float constrain(float amount, float low, float high) {
162 return amount < low ? low : (amount > high ? high : amount);
165 private static int constrain(int amount, int low, int high) {
166 return amount < low ? low : (amount > high ? high : amount);
170 * Creates Popupzoomer.
171 * @param context Context to be used.
172 * @param overlayRadiusDimensoinResId Resource to be used to get overlay corner radius.
174 public PopupZoomer(Context context) {
177 setVisibility(INVISIBLE);
179 setFocusableInTouchMode(true);
181 GestureDetector.SimpleOnGestureListener listener =
182 new GestureDetector.SimpleOnGestureListener() {
184 public boolean onScroll(MotionEvent e1, MotionEvent e2,
185 float distanceX, float distanceY) {
186 if (mAnimating) return true;
188 if (isTouchOutsideArea(e1.getX(), e1.getY())) {
191 scroll(distanceX, distanceY);
197 public boolean onSingleTapUp(MotionEvent e) {
198 return handleTapOrPress(e, false);
202 public void onLongPress(MotionEvent e) {
203 handleTapOrPress(e, true);
206 private boolean handleTapOrPress(MotionEvent e, boolean isLongPress) {
207 if (mAnimating) return true;
211 if (isTouchOutsideArea(x, y)) {
212 // User clicked on area outside the popup.
214 } else if (mOnTapListener != null) {
215 PointF converted = convertTouchPoint(x, y);
216 MotionEvent event = MotionEvent.obtainNoHistory(e);
217 event.setLocation(converted.x, converted.y);
219 mOnTapListener.onLongPress(PopupZoomer.this, event);
221 mOnTapListener.onSingleTap(PopupZoomer.this, event);
228 mGestureDetector = new GestureDetector(context, listener);
232 * Sets the OnTapListener.
234 public void setOnTapListener(OnTapListener listener) {
235 mOnTapListener = listener;
239 * Sets the OnVisibilityChangedListener.
241 public void setOnVisibilityChangedListener(OnVisibilityChangedListener listener) {
242 mOnVisibilityChangedListener = listener;
246 * Sets the bitmap to be used for the zoomed view.
248 public void setBitmap(Bitmap bitmap) {
249 if (mZoomedBitmap != null) {
250 mZoomedBitmap.recycle();
251 mZoomedBitmap = null;
253 mZoomedBitmap = bitmap;
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);
268 private void scroll(float x, float y) {
269 mPopupScrollX = constrain(mPopupScrollX - x, mMinScrollX, mMaxScrollX);
270 mPopupScrollY = constrain(mPopupScrollY - y, mMinScrollY, mMaxScrollY);
274 private void startAnimation(boolean show) {
279 setVisibility(VISIBLE);
280 mNeedsToInitDimensions = true;
281 if (mOnVisibilityChangedListener != null) {
282 mOnVisibilityChangedListener.onPopupZoomerShown(this);
285 long endTime = mAnimationStartTime + ANIMATION_DURATION;
286 mTimeLeft = endTime - SystemClock.uptimeMillis();
287 if (mTimeLeft < 0) mTimeLeft = 0;
289 mAnimationStartTime = SystemClock.uptimeMillis();
293 private void hideImmediately() {
297 if (mOnVisibilityChangedListener != null) {
298 mOnVisibilityChangedListener.onPopupZoomerHidden(this);
300 setVisibility(INVISIBLE);
301 mZoomedBitmap.recycle();
302 mZoomedBitmap = null;
306 * Returns true if the view is currently being shown (or is animating).
308 public boolean isShowing() {
309 return mShowing || mAnimating;
313 * Sets the last touch point (on the unzoomed view).
315 public void setLastTouch(float x, float y) {
320 private void setTargetBounds(Rect rect) {
321 mTargetBounds = rect;
324 private void initDimensions() {
325 if (mTargetBounds == null || mTouch == null) return;
327 // Compute the final zoom scale.
328 mScale = (float) mZoomedBitmap.getWidth() / mTargetBounds.width();
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();
338 mViewClipRect = new RectF(ZOOM_BOUNDS_MARGIN,
340 width - ZOOM_BOUNDS_MARGIN,
341 height - ZOOM_BOUNDS_MARGIN);
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.
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;
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;
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;
378 if (mViewClipRect.left + mShiftX > mClipRect.left) {
379 mMaxScrollX = mViewClipRect.left - mClipRect.left;
381 if (mViewClipRect.top + mShiftY > mClipRect.top) {
382 mMaxScrollY = mViewClipRect.top - mClipRect.top;
384 if (mViewClipRect.bottom + mShiftY < mClipRect.bottom) {
385 mMinScrollY = mViewClipRect.bottom - mClipRect.bottom;
387 // Now that we know how much we need to scroll, we can intersect with mViewClipRect.
388 mClipRect.intersect(mViewClipRect);
390 mLeftExtrusion = mTouch.x - mClipRect.left;
391 mRightExtrusion = mClipRect.right - mTouch.x;
392 mTopExtrusion = mTouch.y - mClipRect.top;
393 mBottomExtrusion = mClipRect.bottom - mTouch.y;
395 // Set an initial scroll position to take touch point into account.
397 (mTouch.x - mTargetBounds.centerX()) / (mTargetBounds.width() / 2.f) + .5f;
399 (mTouch.y - mTargetBounds.centerY()) / (mTargetBounds.height() / 2.f) + .5f;
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);
409 // Compute the bounds in onDraw()
410 mDrawRect = new RectF();
414 * Tests override it as the PopupZoomer is never attached to the view hierarchy.
416 protected boolean acceptZeroSizeView() {
421 protected void onDraw(Canvas canvas) {
422 if (!isShowing() || mZoomedBitmap == null) return;
423 if (!acceptZeroSizeView() && (getWidth() == 0 || getHeight() == 0)) return;
425 if (mNeedsToInitDimensions) {
426 mNeedsToInitDimensions = false;
431 // Calculate the elapsed fraction of animation.
432 float time = (SystemClock.uptimeMillis() - mAnimationStartTime + mTimeLeft) /
433 ((float) ANIMATION_DURATION);
434 time = constrain(time, 0, 1);
445 // Fraction of the animation to actally show.
446 float fractionAnimation;
448 fractionAnimation = mShowInterpolator.getInterpolation(time);
450 fractionAnimation = mHideInterpolator.getInterpolation(time);
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);
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
464 float scale = fractionAnimation * (mScale - 1.0f) / mScale + 1.0f / mScale;
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;
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);
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
482 canvas.scale(scale, scale, mDrawRect.left, mDrawRect.top);
483 canvas.translate(mPopupScrollX, mPopupScrollY);
484 canvas.drawBitmap(mZoomedBitmap, mDrawRect.left, mDrawRect.top, null);
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);
500 * Show the PopupZoomer view with given target bounds.
502 public void show(Rect rect) {
503 if (mShowing || mZoomedBitmap == null) return;
505 setTargetBounds(rect);
506 startAnimation(true);
510 * Hide the PopupZoomer view.
511 * @param animation true if hide with animation.
513 public void hide(boolean animation) {
514 if (!mShowing) return;
517 startAnimation(false);
524 * Converts the coordinates to a point on the original un-zoomed view.
526 private PointF convertTouchPoint(float x, float y) {
529 x = mTouch.x + (x - mTouch.x - mPopupScrollX) / mScale;
530 y = mTouch.y + (y - mTouch.y - mPopupScrollY) / mScale;
531 return new PointF(x, y);
535 * Returns true if the point is inside the final drawable area for this popup zoomer.
537 private boolean isTouchOutsideArea(float x, float y) {
538 return !mClipRect.contains(x, y);
542 public boolean onTouchEvent(MotionEvent event) {
543 mGestureDetector.onTouchEvent(event);
547 private static class ReverseInterpolator implements Interpolator {
548 private final Interpolator mInterpolator;
550 public ReverseInterpolator(Interpolator i) {
555 public float getInterpolation(float input) {
556 input = 1.0f - input;
557 if (mInterpolator == null) return input;
558 return mInterpolator.getInterpolation(input);