root/content/public/android/java/src/org/chromium/content/browser/PopupZoomer.java

/* [<][>][^][v][top][bottom][index][help] */

DEFINITIONS

This source file includes following definitions.
  1. onSingleTap
  2. onLongPress
  3. onPopupZoomerShown
  4. onPopupZoomerHidden
  5. getOverlayCornerRadius
  6. getOverlayDrawable
  7. constrain
  8. constrain
  9. setOnTapListener
  10. setOnVisibilityChangedListener
  11. setBitmap
  12. scroll
  13. startAnimation
  14. hideImmediately
  15. isShowing
  16. setLastTouch
  17. setTargetBounds
  18. initDimensions
  19. acceptZeroSizeView
  20. onDraw
  21. show
  22. hide
  23. convertTouchPoint
  24. isTouchOutsideArea
  25. onTouchEvent
  26. getInterpolation

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

/* [<][>][^][v][top][bottom][index][help] */