| // Copyright 2012 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.content.browser; |
| |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.graphics.Bitmap; |
| import android.graphics.Canvas; |
| import android.graphics.Color; |
| import android.graphics.Paint; |
| import android.graphics.Path; |
| import android.graphics.Path.Direction; |
| import android.graphics.PointF; |
| import android.graphics.PorterDuff.Mode; |
| import android.graphics.PorterDuffXfermode; |
| import android.graphics.Rect; |
| import android.graphics.RectF; |
| import android.graphics.Region.Op; |
| import android.graphics.drawable.ColorDrawable; |
| import android.graphics.drawable.Drawable; |
| import android.os.SystemClock; |
| import android.util.Log; |
| import android.view.GestureDetector; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.animation.Interpolator; |
| import android.view.animation.OvershootInterpolator; |
| |
| import org.chromium.content.R; |
| |
| /** |
| * PopupZoomer is used to show the on-demand link zooming popup. It handles manipulation of the |
| * canvas and touch events to display the on-demand zoom magnifier. |
| */ |
| class PopupZoomer extends View { |
| private static final String LOGTAG = "PopupZoomer"; |
| |
| // The padding between the edges of the view and the popup. Note that there is a mirror |
| // constant in content/renderer/render_view_impl.cc which should be kept in sync if |
| // this is changed. |
| private static final int ZOOM_BOUNDS_MARGIN = 25; |
| // Time it takes for the animation to finish in ms. |
| private static final long ANIMATION_DURATION = 300; |
| |
| /** |
| * Interface to be implemented to listen for touch events inside the zoomed area. |
| * The MotionEvent coordinates correspond to original unzoomed view. |
| */ |
| public static interface OnTapListener { |
| public boolean onSingleTap(View v, MotionEvent event); |
| public boolean onLongPress(View v, MotionEvent event); |
| } |
| |
| private OnTapListener mOnTapListener = null; |
| |
| /** |
| * Interface to be implemented to add and remove PopupZoomer to/from the view hierarchy. |
| */ |
| public static interface OnVisibilityChangedListener { |
| public void onPopupZoomerShown(PopupZoomer zoomer); |
| public void onPopupZoomerHidden(PopupZoomer zoomer); |
| } |
| |
| private OnVisibilityChangedListener mOnVisibilityChangedListener = null; |
| |
| // Cached drawable used to frame the zooming popup. |
| // TODO(tonyg): This should be marked purgeable so that if the system wants to recover this |
| // memory, we can just reload it from the resource ID next time it is needed. |
| // See android.graphics.BitmapFactory.Options#inPurgeable |
| private static Drawable sOverlayDrawable; |
| // The padding used for drawing the overlay around the content, instead of directly above it. |
| private static Rect sOverlayPadding; |
| // The radius of the overlay bubble, used for rounding the bitmap to draw underneath it. |
| private static float sOverlayCornerRadius; |
| |
| private final Interpolator mShowInterpolator = new OvershootInterpolator(); |
| private final Interpolator mHideInterpolator = new ReverseInterpolator(mShowInterpolator); |
| |
| private boolean mAnimating = false; |
| private boolean mShowing = false; |
| private long mAnimationStartTime = 0; |
| |
| // The time that was left for the outwards animation to finish. |
| // This is used in the case that the zoomer is cancelled while it is still animating outwards, |
| // to avoid having it jump to full size then animate closed. |
| private long mTimeLeft = 0; |
| |
| // initDimensions() needs to be called in onDraw(). |
| private boolean mNeedsToInitDimensions; |
| |
| // Available view area after accounting for ZOOM_BOUNDS_MARGIN. |
| private RectF mViewClipRect; |
| |
| // The target rect to be zoomed. |
| private Rect mTargetBounds; |
| |
| // The bitmap to hold the zoomed view. |
| private Bitmap mZoomedBitmap; |
| |
| // How far to shift the canvas after all zooming is done, to keep it inside the bounds of the |
| // view (including margin). |
| private float mShiftX = 0, mShiftY = 0; |
| // The magnification factor of the popup. It is recomputed once we have mTargetBounds and |
| // mZoomedBitmap. |
| private float mScale = 1.0f; |
| // The bounds representing the actual zoomed popup. |
| private RectF mClipRect; |
| // The extrusion values are how far the zoomed area (mClipRect) extends from the touch point. |
| // These values to used to animate the popup. |
| private float mLeftExtrusion, mTopExtrusion, mRightExtrusion, mBottomExtrusion; |
| // The last touch point, where the animation will start from. |
| private final PointF mTouch = new PointF(); |
| |
| // Since we sometimes overflow the bounds of the mViewClipRect, we need to allow scrolling. |
| // Current scroll position. |
| private float mPopupScrollX, mPopupScrollY; |
| // Scroll bounds. |
| private float mMinScrollX, mMaxScrollX; |
| private float mMinScrollY, mMaxScrollY; |
| |
| private GestureDetector mGestureDetector; |
| |
| private static float getOverlayCornerRadius(Context context) { |
| if (sOverlayCornerRadius == 0) { |
| try { |
| sOverlayCornerRadius = context.getResources().getDimension( |
| R.dimen.link_preview_overlay_radius); |
| } catch (Resources.NotFoundException e) { |
| Log.w(LOGTAG, "No corner radius resource for PopupZoomer overlay found."); |
| sOverlayCornerRadius = 1.0f; |
| } |
| } |
| return sOverlayCornerRadius; |
| } |
| |
| /** |
| * Gets the drawable that should be used to frame the zooming popup, loading |
| * it from the resource bundle if not already cached. |
| */ |
| private static Drawable getOverlayDrawable(Context context) { |
| if (sOverlayDrawable == null) { |
| try { |
| sOverlayDrawable = context.getResources().getDrawable( |
| R.drawable.ondemand_overlay); |
| } catch (Resources.NotFoundException e) { |
| Log.w(LOGTAG, "No drawable resource for PopupZoomer overlay found."); |
| sOverlayDrawable = new ColorDrawable(); |
| } |
| sOverlayPadding = new Rect(); |
| sOverlayDrawable.getPadding(sOverlayPadding); |
| } |
| return sOverlayDrawable; |
| } |
| |
| private static float constrain(float amount, float low, float high) { |
| return amount < low ? low : (amount > high ? high : amount); |
| } |
| |
| private static int constrain(int amount, int low, int high) { |
| return amount < low ? low : (amount > high ? high : amount); |
| } |
| |
| /** |
| * Creates Popupzoomer. |
| * @param context Context to be used. |
| * @param overlayRadiusDimensoinResId Resource to be used to get overlay corner radius. |
| */ |
| public PopupZoomer(Context context) { |
| super(context); |
| |
| setVisibility(INVISIBLE); |
| setFocusable(true); |
| setFocusableInTouchMode(true); |
| |
| GestureDetector.SimpleOnGestureListener listener = |
| new GestureDetector.SimpleOnGestureListener() { |
| @Override |
| public boolean onScroll(MotionEvent e1, MotionEvent e2, |
| float distanceX, float distanceY) { |
| if (mAnimating) return true; |
| |
| if (isTouchOutsideArea(e1.getX(), e1.getY())) { |
| hide(true); |
| } else { |
| scroll(distanceX, distanceY); |
| } |
| return true; |
| } |
| |
| @Override |
| public boolean onSingleTapUp(MotionEvent e) { |
| return handleTapOrPress(e, false); |
| } |
| |
| @Override |
| public void onLongPress(MotionEvent e) { |
| handleTapOrPress(e, true); |
| } |
| |
| private boolean handleTapOrPress(MotionEvent e, boolean isLongPress) { |
| if (mAnimating) return true; |
| |
| float x = e.getX(); |
| float y = e.getY(); |
| if (isTouchOutsideArea(x, y)) { |
| // User clicked on area outside the popup. |
| hide(true); |
| } else if (mOnTapListener != null) { |
| PointF converted = convertTouchPoint(x, y); |
| MotionEvent event = MotionEvent.obtainNoHistory(e); |
| event.setLocation(converted.x, converted.y); |
| if (isLongPress) { |
| mOnTapListener.onLongPress(PopupZoomer.this, event); |
| } else { |
| mOnTapListener.onSingleTap(PopupZoomer.this, event); |
| } |
| hide(true); |
| } |
| return true; |
| } |
| }; |
| mGestureDetector = new GestureDetector(context, listener); |
| } |
| |
| /** |
| * Sets the OnTapListener. |
| */ |
| public void setOnTapListener(OnTapListener listener) { |
| mOnTapListener = listener; |
| } |
| |
| /** |
| * Sets the OnVisibilityChangedListener. |
| */ |
| public void setOnVisibilityChangedListener(OnVisibilityChangedListener listener) { |
| mOnVisibilityChangedListener = listener; |
| } |
| |
| /** |
| * Sets the bitmap to be used for the zoomed view. |
| */ |
| public void setBitmap(Bitmap bitmap) { |
| if (mZoomedBitmap != null) { |
| mZoomedBitmap.recycle(); |
| mZoomedBitmap = null; |
| } |
| mZoomedBitmap = bitmap; |
| |
| // Round the corners of the bitmap so it doesn't stick out around the overlay. |
| Canvas canvas = new Canvas(mZoomedBitmap); |
| Path path = new Path(); |
| RectF canvasRect = new RectF(0, 0, canvas.getWidth(), canvas.getHeight()); |
| float overlayCornerRadius = getOverlayCornerRadius(getContext()); |
| path.addRoundRect(canvasRect, overlayCornerRadius, overlayCornerRadius, Direction.CCW); |
| canvas.clipPath(path, Op.XOR); |
| Paint clearPaint = new Paint(); |
| clearPaint.setXfermode(new PorterDuffXfermode(Mode.SRC)); |
| clearPaint.setColor(Color.TRANSPARENT); |
| canvas.drawPaint(clearPaint); |
| } |
| |
| private void scroll(float x, float y) { |
| mPopupScrollX = constrain(mPopupScrollX - x, mMinScrollX, mMaxScrollX); |
| mPopupScrollY = constrain(mPopupScrollY - y, mMinScrollY, mMaxScrollY); |
| invalidate(); |
| } |
| |
| private void startAnimation(boolean show) { |
| mAnimating = true; |
| mShowing = show; |
| mTimeLeft = 0; |
| if (show) { |
| setVisibility(VISIBLE); |
| mNeedsToInitDimensions = true; |
| if (mOnVisibilityChangedListener != null) { |
| mOnVisibilityChangedListener.onPopupZoomerShown(this); |
| } |
| } else { |
| long endTime = mAnimationStartTime + ANIMATION_DURATION; |
| mTimeLeft = endTime - SystemClock.uptimeMillis(); |
| if (mTimeLeft < 0) mTimeLeft = 0; |
| } |
| mAnimationStartTime = SystemClock.uptimeMillis(); |
| invalidate(); |
| } |
| |
| private void hideImmediately() { |
| mAnimating = false; |
| mShowing = false; |
| mTimeLeft = 0; |
| if (mOnVisibilityChangedListener != null) { |
| mOnVisibilityChangedListener.onPopupZoomerHidden(this); |
| } |
| setVisibility(INVISIBLE); |
| mZoomedBitmap.recycle(); |
| mZoomedBitmap = null; |
| } |
| |
| /** |
| * Returns true if the view is currently being shown (or is animating). |
| */ |
| public boolean isShowing() { |
| return mShowing || mAnimating; |
| } |
| |
| /** |
| * Sets the last touch point (on the unzoomed view). |
| */ |
| public void setLastTouch(float x, float y) { |
| mTouch.x = x; |
| mTouch.y = y; |
| } |
| |
| private void setTargetBounds(Rect rect) { |
| mTargetBounds = rect; |
| } |
| |
| private void initDimensions() { |
| if (mTargetBounds == null || mTouch == null) return; |
| |
| // Compute the final zoom scale. |
| mScale = (float) mZoomedBitmap.getWidth() / mTargetBounds.width(); |
| |
| float l = mTouch.x - mScale * (mTouch.x - mTargetBounds.left); |
| float t = mTouch.y - mScale * (mTouch.y - mTargetBounds.top); |
| float r = l + mZoomedBitmap.getWidth(); |
| float b = t + mZoomedBitmap.getHeight(); |
| mClipRect = new RectF(l, t, r, b); |
| int width = getWidth(); |
| int height = getHeight(); |
| |
| mViewClipRect = new RectF(ZOOM_BOUNDS_MARGIN, |
| ZOOM_BOUNDS_MARGIN, |
| width - ZOOM_BOUNDS_MARGIN, |
| height - ZOOM_BOUNDS_MARGIN); |
| |
| // Ensure it stays inside the bounds of the view. First shift it around to see if it |
| // can fully fit in the view, then clip it to the padding section of the view to |
| // ensure no overflow. |
| mShiftX = 0; |
| mShiftY = 0; |
| |
| // Right now this has the happy coincidence of showing the leftmost portion |
| // of a scaled up bitmap, which usually has the text in it. When we want to support |
| // RTL languages, we can conditionally switch the order of this check to push it |
| // to the left instead of right. |
| if (mClipRect.left < ZOOM_BOUNDS_MARGIN) { |
| mShiftX = ZOOM_BOUNDS_MARGIN - mClipRect.left; |
| mClipRect.left += mShiftX; |
| mClipRect.right += mShiftX; |
| } else if (mClipRect.right > width - ZOOM_BOUNDS_MARGIN) { |
| mShiftX = (width - ZOOM_BOUNDS_MARGIN - mClipRect.right); |
| mClipRect.right += mShiftX; |
| mClipRect.left += mShiftX; |
| } |
| if (mClipRect.top < ZOOM_BOUNDS_MARGIN) { |
| mShiftY = ZOOM_BOUNDS_MARGIN - mClipRect.top; |
| mClipRect.top += mShiftY; |
| mClipRect.bottom += mShiftY; |
| } else if (mClipRect.bottom > height - ZOOM_BOUNDS_MARGIN) { |
| mShiftY = height - ZOOM_BOUNDS_MARGIN - mClipRect.bottom; |
| mClipRect.bottom += mShiftY; |
| mClipRect.top += mShiftY; |
| } |
| |
| // Allow enough scrolling to get to the entire bitmap that may be clipped inside the |
| // bounds of the view. |
| mMinScrollX = mMaxScrollX = mMinScrollY = mMaxScrollY = 0; |
| if (mViewClipRect.right + mShiftX < mClipRect.right) { |
| mMinScrollX = mViewClipRect.right - mClipRect.right; |
| } |
| if (mViewClipRect.left + mShiftX > mClipRect.left) { |
| mMaxScrollX = mViewClipRect.left - mClipRect.left; |
| } |
| if (mViewClipRect.top + mShiftY > mClipRect.top) { |
| mMaxScrollY = mViewClipRect.top - mClipRect.top; |
| } |
| if (mViewClipRect.bottom + mShiftY < mClipRect.bottom) { |
| mMinScrollY = mViewClipRect.bottom - mClipRect.bottom; |
| } |
| // Now that we know how much we need to scroll, we can intersect with mViewClipRect. |
| mClipRect.intersect(mViewClipRect); |
| |
| mLeftExtrusion = mTouch.x - mClipRect.left; |
| mRightExtrusion = mClipRect.right - mTouch.x; |
| mTopExtrusion = mTouch.y - mClipRect.top; |
| mBottomExtrusion = mClipRect.bottom - mTouch.y; |
| |
| // Set an initial scroll position to take touch point into account. |
| float percentX = |
| (mTouch.x - mTargetBounds.centerX()) / (mTargetBounds.width() / 2.f) + .5f; |
| float percentY = |
| (mTouch.y - mTargetBounds.centerY()) / (mTargetBounds.height() / 2.f) + .5f; |
| |
| float scrollWidth = mMaxScrollX - mMinScrollX; |
| float scrollHeight = mMaxScrollY - mMinScrollY; |
| mPopupScrollX = scrollWidth * percentX * -1f; |
| mPopupScrollY = scrollHeight * percentY * -1f; |
| // Constrain initial scroll position within allowed bounds. |
| mPopupScrollX = constrain(mPopupScrollX, mMinScrollX, mMaxScrollX); |
| mPopupScrollY = constrain(mPopupScrollY, mMinScrollY, mMaxScrollY); |
| } |
| |
| /* |
| * Tests override it as the PopupZoomer is never attached to the view hierarchy. |
| */ |
| protected boolean acceptZeroSizeView() { |
| return false; |
| } |
| |
| @Override |
| protected void onDraw(Canvas canvas) { |
| if (!isShowing() || mZoomedBitmap == null) return; |
| if (!acceptZeroSizeView() && (getWidth() == 0 || getHeight() == 0)) return; |
| |
| if (mNeedsToInitDimensions) { |
| mNeedsToInitDimensions = false; |
| initDimensions(); |
| } |
| |
| canvas.save(); |
| // Calculate the elapsed fraction of animation. |
| float time = (SystemClock.uptimeMillis() - mAnimationStartTime + mTimeLeft) / |
| ((float) ANIMATION_DURATION); |
| time = constrain(time, 0, 1); |
| if (time >= 1) { |
| mAnimating = false; |
| if (!isShowing()) { |
| hideImmediately(); |
| return; |
| } |
| } else { |
| invalidate(); |
| } |
| |
| // Fraction of the animation to actally show. |
| float fractionAnimation; |
| if (mShowing) { |
| fractionAnimation = mShowInterpolator.getInterpolation(time); |
| } else { |
| fractionAnimation = mHideInterpolator.getInterpolation(time); |
| } |
| |
| // Draw a faded color over the entire view to fade out the original content, increasing |
| // the alpha value as fractionAnimation increases. |
| // TODO(nileshagrawal): We should use time here instead of fractionAnimation |
| // as fractionAnimaton is interpolated and can go over 1. |
| canvas.drawARGB((int) (80 * fractionAnimation), 0, 0, 0); |
| canvas.save(); |
| |
| // Since we want the content to appear directly above its counterpart we need to make |
| // sure that it starts out at exactly the same size as it appears in the page, |
| // i.e. scale grows from 1/mScale to 1. Note that extrusion values are already zoomed |
| // with mScale. |
| float scale = fractionAnimation * (mScale - 1.0f) / mScale + 1.0f / mScale; |
| |
| // Since we want the content to appear directly above its counterpart on the |
| // page, we need to remove the mShiftX/Y effect at the beginning of the animation. |
| // The unshifting decreases with the animation. |
| float unshiftX = -mShiftX * (1.0f - fractionAnimation) / mScale; |
| float unshiftY = -mShiftY * (1.0f - fractionAnimation) / mScale; |
| |
| // Compute the rect to show. |
| RectF rect = new RectF(); |
| rect.left = mTouch.x - mLeftExtrusion * scale + unshiftX; |
| rect.top = mTouch.y - mTopExtrusion * scale + unshiftY; |
| rect.right = mTouch.x + mRightExtrusion * scale + unshiftX; |
| rect.bottom = mTouch.y + mBottomExtrusion * scale + unshiftY; |
| canvas.clipRect(rect); |
| |
| // Since the canvas transform APIs all pre-concat the transformations, this is done in |
| // reverse order. The canvas is first scaled up, then shifted the appropriate amount of |
| // pixels. |
| canvas.scale(scale, scale, rect.left, rect.top); |
| canvas.translate(mPopupScrollX, mPopupScrollY); |
| canvas.drawBitmap(mZoomedBitmap, rect.left, rect.top, null); |
| canvas.restore(); |
| Drawable overlayNineTile = getOverlayDrawable(getContext()); |
| overlayNineTile.setBounds((int) rect.left - sOverlayPadding.left, |
| (int) rect.top - sOverlayPadding.top, |
| (int) rect.right + sOverlayPadding.right, |
| (int) rect.bottom + sOverlayPadding.bottom); |
| // TODO(nileshagrawal): We should use time here instead of fractionAnimation |
| // as fractionAnimaton is interpolated and can go over 1. |
| int alpha = constrain((int) (fractionAnimation * 255), 0, 255); |
| overlayNineTile.setAlpha(alpha); |
| overlayNineTile.draw(canvas); |
| canvas.restore(); |
| } |
| |
| /** |
| * Show the PopupZoomer view with given target bounds. |
| */ |
| public void show(Rect rect) { |
| if (mShowing || mZoomedBitmap == null) return; |
| |
| setTargetBounds(rect); |
| startAnimation(true); |
| } |
| |
| /** |
| * Hide the PopupZoomer view. |
| * @param animation true if hide with animation. |
| */ |
| public void hide(boolean animation) { |
| if (!mShowing) return; |
| |
| if (animation) { |
| startAnimation(false); |
| } else { |
| hideImmediately(); |
| } |
| } |
| |
| /** |
| * Converts the coordinates to a point on the original un-zoomed view. |
| */ |
| private PointF convertTouchPoint(float x, float y) { |
| x -= mShiftX; |
| y -= mShiftY; |
| x = mTouch.x + (x - mTouch.x - mPopupScrollX) / mScale; |
| y = mTouch.y + (y - mTouch.y - mPopupScrollY) / mScale; |
| return new PointF(x, y); |
| } |
| |
| /** |
| * Returns true if the point is inside the final drawable area for this popup zoomer. |
| */ |
| private boolean isTouchOutsideArea(float x, float y) { |
| return !mClipRect.contains(x, y); |
| } |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent event) { |
| mGestureDetector.onTouchEvent(event); |
| return true; |
| } |
| |
| private static class ReverseInterpolator implements Interpolator { |
| private final Interpolator mInterpolator; |
| |
| public ReverseInterpolator(Interpolator i) { |
| mInterpolator = i; |
| } |
| |
| @Override |
| public float getInterpolation(float input) { |
| input = 1.0f - input; |
| if (mInterpolator == null) return input; |
| return mInterpolator.getInterpolation(input); |
| } |
| } |
| } |