• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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