chromium/components/find_in_page/android/java/src/org/chromium/components/find_in_page/FindResultBar.java

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.find_in_page;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;

import org.chromium.base.MathUtils;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.interpolators.Interpolators;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

/**
 * The view that shows the positions of the find in page matches and allows scrubbing
 * between the entries.
 */
public class FindResultBar extends View {
    private static final int VISIBILITY_ANIMATION_DURATION_MS = 200;

    private final int mBackgroundColor;
    private final int mBackgroundBorderColor;
    private final int mResultColor;
    private final int mResultBorderColor;
    private final int mActiveColor;
    private final int mActiveBorderColor;

    private final int mBarTouchWidth;
    private final int mBarDrawWidth;
    private final int mResultMinHeight;
    private final int mActiveMinHeight;
    private final int mBarVerticalPadding;
    private final int mMinGapBetweenStacks;
    private final int mStackedResultHeight;

    private FindInPageBridge mFindInPageBridge;
    private final WindowAndroid mWindowAndroid;

    private int mRectsVersion = -1;
    private RectF[] mMatches = new RectF[0];
    private RectF mActiveMatch;

    private ArrayList<Tickmark> mTickmarks = new ArrayList<Tickmark>(0);
    private int mBarHeightForWhichTickmarksWereCached = -1;

    private Animator mVisibilityAnimation;
    private boolean mDismissing;

    private final Paint mFillPaint;
    private final Paint mStrokePaint;

    private boolean mWaitingForActivateAck;

    private static Comparator<RectF> sComparator =
            new Comparator<RectF>() {
                @Override
                public int compare(RectF a, RectF b) {
                    int top = Float.compare(a.top, b.top);
                    if (top != 0) return top;
                    return Float.compare(a.left, b.left);
                }
            };

    /**
     * Creates an instance of a {@link FindResultBar}. Also adds it to a parent {@link FrameLayout}
     * and animates itself into view.
     * @param context The Context to create this {@link FindResultBar} under.
     * @param contentView The FrameLayout that will hold this FindResultBar.
     * @param windowAndroid The WindowAndroid hosting the WebContents under search.
     * @param findInPageBridge Facilitator for user interactions.
     */
    public FindResultBar(
            Context context,
            FrameLayout parent,
            WindowAndroid windowAndroid,
            FindInPageBridge findInPageBridge) {
        super(context);

        Resources res = context.getResources();
        mBackgroundColor = context.getColor(R.color.find_result_bar_background_color);
        mBackgroundBorderColor = context.getColor(R.color.find_result_bar_background_border_color);
        mResultColor = context.getColor(R.color.find_result_bar_result_color);
        mResultBorderColor = context.getColor(R.color.find_result_bar_result_border_color);
        mActiveColor = context.getColor(R.color.find_result_bar_active_color);
        mActiveBorderColor = context.getColor(R.color.find_result_bar_active_border_color);
        mBarTouchWidth = res.getDimensionPixelSize(R.dimen.find_result_bar_touch_width);
        mBarDrawWidth =
                res.getDimensionPixelSize(R.dimen.find_result_bar_draw_width)
                        + res.getDimensionPixelSize(R.dimen.find_result_bar_separator_width);
        mResultMinHeight = res.getDimensionPixelSize(R.dimen.find_result_bar_result_min_height);
        mActiveMinHeight = res.getDimensionPixelSize(R.dimen.find_result_bar_active_min_height);
        mBarVerticalPadding = res.getDimensionPixelSize(R.dimen.find_result_bar_vertical_padding);
        mMinGapBetweenStacks =
                res.getDimensionPixelSize(R.dimen.find_result_bar_min_gap_between_stacks);
        mStackedResultHeight =
                res.getDimensionPixelSize(R.dimen.find_result_bar_stacked_result_height);

        mFillPaint = new Paint();
        mStrokePaint = new Paint();
        mFillPaint.setAntiAlias(true);
        mStrokePaint.setAntiAlias(true);
        mFillPaint.setStyle(Paint.Style.FILL);
        mStrokePaint.setStyle(Paint.Style.STROKE);
        mStrokePaint.setStrokeWidth(1.0f);

        mFindInPageBridge = findInPageBridge;

        parent.addView(
                this,
                new FrameLayout.LayoutParams(
                        mBarTouchWidth, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.END));
        setTranslationX(MathUtils.flipSignIf(mBarTouchWidth, LocalizationUtils.isLayoutRtl()));

        mVisibilityAnimation = ObjectAnimator.ofFloat(this, TRANSLATION_X, 0);
        mVisibilityAnimation.setDuration(VISIBILITY_ANIMATION_DURATION_MS);
        mVisibilityAnimation.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN_INTERPOLATOR);

        mWindowAndroid = windowAndroid;
        if (windowAndroid == null) {
            throw new IllegalArgumentException("WindowAndroid must be non null.");
        }
        windowAndroid.startAnimationOverContent(mVisibilityAnimation);
    }

    /** Dismisses this results bar by removing it from the view hierarchy. */
    public void dismiss() {
        mDismissing = true;
        mFindInPageBridge = null;
        if (mVisibilityAnimation != null && mVisibilityAnimation.isRunning()) {
            mVisibilityAnimation.cancel();
        }

        mVisibilityAnimation =
                ObjectAnimator.ofFloat(
                        this,
                        TRANSLATION_X,
                        MathUtils.flipSignIf(mBarTouchWidth, LocalizationUtils.isLayoutRtl()));
        mVisibilityAnimation.setDuration(VISIBILITY_ANIMATION_DURATION_MS);
        mVisibilityAnimation.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN_INTERPOLATOR);
        mWindowAndroid.startAnimationOverContent(mVisibilityAnimation);
        mVisibilityAnimation.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        super.onAnimationEnd(animation);

                        if (getParent() != null) {
                            ((ViewGroup) getParent()).removeView(FindResultBar.this);
                        }
                    }
                });
    }

    /** Setup the tickmarks to draw using the rects of the find results. */
    public void setMatchRects(int version, RectF[] rects, RectF activeRect) {
        if (mRectsVersion != version) {
            mRectsVersion = version;
            assert rects != null;
            mMatches = rects;
            mTickmarks.clear();
            Arrays.sort(mMatches, sComparator);
            mBarHeightForWhichTickmarksWereCached = -1;
        }
        mActiveMatch = activeRect; // Can be null.
        invalidate();
    }

    /** Clears the tickmarks. */
    public void clearMatchRects() {
        setMatchRects(-1, new RectF[0], null);
    }

    /** To be called after a find result has come back. */
    public void onFindResult() {
        mWaitingForActivateAck = false;
    }

    /** Returns the last version passed to setMatchRects. */
    public int getRectsVersion() {
        return mRectsVersion;
    }

    @Override
    @SuppressLint("ClickableViewAccessibility")
    public boolean onTouchEvent(MotionEvent event) {
        if (!mDismissing
                && mTickmarks.size() > 0
                && mTickmarks.size() == mMatches.length
                && !mWaitingForActivateAck
                && event.getAction() != MotionEvent.ACTION_CANCEL) {
            // We decided it's more important to get the keyboard out of the
            // way asap; the user can compensate if their next MotionEvent
            // scrolls somewhere unintended.
            mWindowAndroid.getKeyboardDelegate().hideKeyboard(this);

            // Identify which drawn tickmark is closest to the user's finger.
            int closest =
                    Collections.binarySearch(mTickmarks, new Tickmark(event.getY(), event.getY()));
            if (closest < 0) {
                // No exact match, so must determine nearest.
                int insertionPoint = -1 - closest;
                if (insertionPoint == 0) {
                    closest = 0;
                } else if (insertionPoint == mTickmarks.size()) {
                    closest = mTickmarks.size() - 1;
                } else {
                    float distanceA =
                            Math.abs(event.getY() - mTickmarks.get(insertionPoint - 1).centerY());
                    float distanceB =
                            Math.abs(event.getY() - mTickmarks.get(insertionPoint).centerY());
                    closest = insertionPoint - (distanceA <= distanceB ? 1 : 0);
                }
            }

            // Now activate the find match corresponding to that tickmark.
            // Since mTickmarks may be outdated, we can't just pass the index.
            // Instead we send the renderer the coordinates of the center of the
            // find match's rect (as originally received in setMatchRects), and
            // it will activate whatever find result is currently closest to
            // that point (which will usually be the same one).
            mWaitingForActivateAck = true;
            mFindInPageBridge.activateNearestFindResult(
                    mMatches[closest].centerX(), mMatches[closest].centerY());
        }
        return true; // Consume the event, whether or not we acted upon it.
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // Check for new rects, as they may move if the document size changes.
        if (!mDismissing && mMatches.length > 0) {
            mFindInPageBridge.requestFindMatchRects(mRectsVersion);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int leftMargin = getLeftMargin();
        mFillPaint.setColor(mBackgroundColor);
        mStrokePaint.setColor(mBackgroundBorderColor);
        canvas.drawRect(leftMargin, 0, leftMargin + mBarDrawWidth, getHeight(), mFillPaint);
        float lineX =
                LocalizationUtils.isLayoutRtl()
                        ? leftMargin + mBarDrawWidth - 0.5f
                        : leftMargin + 0.5f;
        canvas.drawLine(lineX, 0, lineX, getHeight(), mStrokePaint);

        if (mMatches.length == 0) {
            return;
        }

        if (mBarHeightForWhichTickmarksWereCached != getHeight()) {
            calculateTickmarks();
        }

        // Draw all matches (since they're sorted by increasing y-position
        // overlapping tickmarks will form nice stacks).
        mFillPaint.setColor(mResultColor);
        mStrokePaint.setColor(mResultBorderColor);
        for (Tickmark tickmark : mTickmarks) {
            RectF rect = tickmark.toRectF();
            canvas.drawRoundRect(rect, 2, 2, mFillPaint);
            canvas.drawRoundRect(rect, 2, 2, mStrokePaint);
        }

        // Draw the active tickmark on top (covering up the inactive tickmark
        // we probably already drew for it).
        if (mActiveMatch != null && !mActiveMatch.isEmpty()) {
            int i = Arrays.binarySearch(mMatches, mActiveMatch, sComparator);
            if (i >= 0) {
                // We've already generated a tickmark for all rects in mMatches,
                // so use the corresponding one. However it was generated
                // assuming the match would be inactive. Keep the position, but
                // re-expand it using mActiveMinHeight.
                Tickmark tickmark = expandTickmarkToMinHeight(mTickmarks.get(i), true);
                RectF rect = tickmark.toRectF();
                mFillPaint.setColor(mActiveColor);
                mStrokePaint.setColor(mActiveBorderColor);
                canvas.drawRoundRect(rect, 2, 2, mFillPaint);
                canvas.drawRoundRect(rect, 2, 2, mStrokePaint);
            }
        }
    }

    private int getLeftMargin() {
        return LocalizationUtils.isLayoutRtl() ? 0 : getWidth() - mBarDrawWidth;
    }

    private void calculateTickmarks() {
        // TODO(johnme): Simplify calculation, and switch to integer arithmetic
        // where possible (tickmarks within groups will still need fractional
        // y-positions for anti-aliasing, but the start and end positions of
        // groups can and should be integer-aligned [will give crisp borders],
        // and the intermediary logic uses more floats than necessary).
        // TODO(johnme): Consider adding unit tests for this.

        mBarHeightForWhichTickmarksWereCached = getHeight();

        // Generate tickmarks, neatly clustering any overlapping matches.
        mTickmarks = new ArrayList<Tickmark>(mMatches.length);
        int i = 0;
        Tickmark nextTickmark = tickmarkForRect(mMatches[i], false);
        float lastGroupEnd = -mMinGapBetweenStacks;
        while (i < mMatches.length) {
            // Find next cluster of overlapping tickmarks.
            List<Tickmark> cluster = new ArrayList<Tickmark>();
            cluster.add(nextTickmark);
            i++;
            while (i < mMatches.length) {
                nextTickmark = tickmarkForRect(mMatches[i], false);
                if (nextTickmark.mTop
                        <= cluster.get(cluster.size() - 1).mBottom + mMinGapBetweenStacks) {
                    cluster.add(nextTickmark);
                    i++;
                } else {
                    break;
                }
            }

            // Draw cluster.
            int cn = cluster.size();
            float minStart = lastGroupEnd + mMinGapBetweenStacks;
            lastGroupEnd = cluster.get(cn - 1).mBottom;
            float preferredStart =
                    lastGroupEnd - (cn - 1) * mStackedResultHeight - mResultMinHeight;
            float maxStart = cluster.get(0).mTop;
            float start = Math.round(MathUtils.clamp(preferredStart, minStart, maxStart));
            float scale =
                    start >= preferredStart
                            ? 1.0f
                            : (lastGroupEnd - start) / (lastGroupEnd - preferredStart);
            float spacing =
                    cn == 1 ? 0 : (lastGroupEnd - start - scale * mResultMinHeight) / (cn - 1);
            for (int j = 0; j < cn; j++) {
                Tickmark tickmark = cluster.get(j);
                tickmark.mTop = start + j * spacing;
                if (j != cn - 1) {
                    tickmark.mBottom = tickmark.mTop + scale * mResultMinHeight;
                }
                mTickmarks.add(tickmark);
            }
        }
    }

    private Tickmark tickmarkForRect(RectF r, boolean active) {
        // Ratio of results bar height to page height
        float vScale = mBarHeightForWhichTickmarksWereCached - 2 * mBarVerticalPadding;
        Tickmark tickmark =
                new Tickmark(
                        r.top * vScale + mBarVerticalPadding,
                        r.bottom * vScale + mBarVerticalPadding);
        return expandTickmarkToMinHeight(tickmark, active);
    }

    private Tickmark expandTickmarkToMinHeight(Tickmark tickmark, boolean active) {
        int minHeight = active ? mActiveMinHeight : mResultMinHeight;
        float missingHeight = minHeight - tickmark.height();
        if (missingHeight > 0) {
            return new Tickmark(
                    tickmark.mTop - missingHeight / 2.0f, tickmark.mBottom + missingHeight / 2.0f);
        }
        return tickmark;
    }

    /** Like android.graphics.RectF, but without a left or right. */
    private class Tickmark implements Comparable<Tickmark> {
        float mTop;
        float mBottom;

        Tickmark(float top, float bottom) {
            this.mTop = top;
            this.mBottom = bottom;
        }

        float height() {
            return mBottom - mTop;
        }

        float centerY() {
            return (mTop + mBottom) * 0.5f;
        }

        RectF toRectF() {
            int leftMargin = getLeftMargin();
            RectF rect = new RectF(leftMargin, mTop, leftMargin + mBarDrawWidth, mBottom);
            rect.inset(2.0f, 0.5f);
            rect.offset(LocalizationUtils.isLayoutRtl() ? -0.5f : 0.5f, 0);
            return rect;
        }

        @Override
        public int compareTo(Tickmark other) {
            return Float.compare(centerY(), other.centerY());
        }
    }
}