// 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); } } }