Home | History | Annotate | Download | only in browser
      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