// Copyright 2015 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.chrome.browser.widget.findinpage; 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.ApiCompatibilityUtils; import org.chromium.base.annotations.SuppressFBWarnings; import org.chromium.chrome.R; import org.chromium.chrome.browser.findinpage.FindInPageBridge; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.util.MathUtils; import org.chromium.ui.UiUtils; import org.chromium.ui.base.LocalizationUtils; import org.chromium.ui.interpolators.BakedBezierInterpolator; 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. */ 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 final Tab mTab; private FindInPageBridge mFindInPageBridge; 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; boolean mWaitingForActivateAck = false; 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}. * @param context The Context to create this {@link FindResultBar} under. * @param tab The Tab containing the ContentView this {@link FindResultBar} will be drawn in. */ public FindResultBar(Context context, Tab tab, FindInPageBridge findInPageBridge) { super(context); Resources res = context.getResources(); mBackgroundColor = ApiCompatibilityUtils.getColor(res, R.color.find_result_bar_background_color); mBackgroundBorderColor = ApiCompatibilityUtils.getColor(res, R.color.find_result_bar_background_border_color); mResultColor = ApiCompatibilityUtils.getColor(res, R.color.find_result_bar_result_color); mResultBorderColor = ApiCompatibilityUtils.getColor(res, R.color.find_result_bar_result_border_color); mActiveColor = ApiCompatibilityUtils.getColor(res, R.color.find_result_bar_active_color); mActiveBorderColor = ApiCompatibilityUtils.getColor(res, 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_in_page_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; mTab = tab; mTab.getContentViewCore().getContainerView().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(BakedBezierInterpolator.FADE_IN_CURVE); mTab.getWindowAndroid().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(BakedBezierInterpolator.FADE_OUT_CURVE); mTab.getWindowAndroid().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); } @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. UiUtils.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) { Tickmark tickmark; 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 = expandTickmarkToMinHeight(mTickmarks.get(i), true); } else { // How strange - mActiveMatch isn't in mMatches. Do our best to // draw it anyway (though it might not line up exactly). tickmark = tickmarkForRect(mActiveMatch, 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; } @SuppressFBWarnings("EQ_COMPARETO_USE_OBJECT_EQUAL") @Override public int compareTo(Tickmark other) { return Float.compare(centerY(), other.centerY()); } } }