PopupZoomer.java revision 1320f92c476a1ad9d19dba2a48c72b75566198e9
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
5package org.chromium.content.browser;
6
7import android.content.Context;
8import android.content.res.Resources;
9import android.graphics.Bitmap;
10import android.graphics.Canvas;
11import android.graphics.Color;
12import android.graphics.Paint;
13import android.graphics.Path;
14import android.graphics.Path.Direction;
15import android.graphics.PointF;
16import android.graphics.PorterDuff.Mode;
17import android.graphics.PorterDuffXfermode;
18import android.graphics.Rect;
19import android.graphics.RectF;
20import android.graphics.Region.Op;
21import android.graphics.drawable.ColorDrawable;
22import android.graphics.drawable.Drawable;
23import android.os.SystemClock;
24import android.util.Log;
25import android.view.GestureDetector;
26import android.view.MotionEvent;
27import android.view.View;
28import android.view.animation.Interpolator;
29import android.view.animation.OvershootInterpolator;
30
31import 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 */
37class 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