PopupZoomer.java revision 2a99a7e74a7f215066514fe81d2bfa6639d9eddd
1// Copyright (c) 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 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    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     */
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
157    private static float constrain(float amount, float low, float high) {
158        return amount < low ? low : (amount > high ? high : amount);
159    }
160
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     */
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     */
230    public void setOnTapListener(OnTapListener listener) {
231        mOnTapListener = listener;
232    }
233
234    /**
235     * Sets the OnVisibilityChangedListener.
236     */
237    public void setOnVisibilityChangedListener(OnVisibilityChangedListener listener) {
238        mOnVisibilityChangedListener = listener;
239    }
240
241    /**
242     * Sets the bitmap to be used for the zoomed view.
243     */
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
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
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
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     */
304    public boolean isShowing() {
305        return mShowing || mAnimating;
306    }
307
308    /**
309     * Sets the last touch point (on the unzoomed view).
310     */
311    public void setLastTouch(float x, float y) {
312        mTouch.x = x;
313        mTouch.y = y;
314    }
315
316    private void setTargetBounds(Rect rect) {
317        mTargetBounds = rect;
318    }
319
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     */
409    protected boolean acceptZeroSizeView() {
410        return false;
411    }
412
413    @Override
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     */
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     */
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     */
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     */
531    private boolean isTouchOutsideArea(float x, float y) {
532        return !mClipRect.contains(x, y);
533    }
534
535    @Override
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
544        public ReverseInterpolator(Interpolator i) {
545            mInterpolator = i;
546        }
547
548        @Override
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