blob: ccd17a5c328a7dc3dc51bbc86a96ccfcfd8922f4 [file] [log] [blame]
// Copyright 2020 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.chrome.browser.omnibox.suggestions;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.chromium.base.Callback;
import org.chromium.base.TraceEvent;
import org.chromium.base.metrics.TimingMetric;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.omnibox.OmniboxFeatures;
import org.chromium.chrome.browser.omnibox.R;
import org.chromium.chrome.browser.omnibox.suggestions.OmniboxSuggestionsDropdownEmbedder.OmniboxAlignment;
import org.chromium.chrome.browser.ui.theme.BrandedColorScheme;
import org.chromium.chrome.browser.util.KeyNavigationUtil;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.ui.base.ViewUtils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** A widget for showing a list of omnibox suggestions. */
public class OmniboxSuggestionsDropdown extends RecyclerView {
private static final long DEFERRED_INITIAL_SHRINKING_LAYOUT_FROM_IME_DURATION_MS = 300;
/**
* Used to defer the accessibility announcement for list content.
* This makes core difference when the list is first shown up, when the interaction with the
* Omnibox and presence of virtual keyboard may actually cause throttling of the Accessibility
* events.
*/
private static final long LIST_COMPOSITION_ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS = 300;
private final int mStandardBgColor;
private final int mIncognitoBgColor;
private final Rect mTempRect = new Rect();
private final SuggestionLayoutScrollListener mLayoutScrollListener;
private @Nullable OmniboxSuggestionsDropdownAdapter mAdapter;
private @Nullable OmniboxSuggestionsDropdownEmbedder mEmbedder;
private @Nullable GestureObserver mGestureObserver;
private @Nullable Callback<Integer> mHeightChangeListener;
private @Nullable Runnable mSuggestionDropdownScrollListener;
private @Nullable Runnable mSuggestionDropdownOverscrolledToTopListener;
private @NonNull OmniboxAlignment mOmniboxAlignment = OmniboxAlignment.UNSPECIFIED;
private int mListViewMaxHeight;
private int mLastBroadcastedListViewMaxHeight;
private @Nullable Callback<OmniboxAlignment> mOmniboxAlignmentObserver;
@IntDef({InitialResizeState.WAITING_FOR_FIRST_MEASURE, InitialResizeState.WAITING_FOR_SHRINKING,
InitialResizeState.IGNORING_SHRINKING, InitialResizeState.HANDLED_INITIAL_SIZING})
@Retention(RetentionPolicy.SOURCE)
public @interface InitialResizeState {
int WAITING_FOR_FIRST_MEASURE = 0;
int WAITING_FOR_SHRINKING = 1;
int IGNORING_SHRINKING = 2;
int HANDLED_INITIAL_SIZING = 3;
}
@InitialResizeState
private int mInitialResizeState = InitialResizeState.WAITING_FOR_FIRST_MEASURE;
private int mWidthMeasureSpec;
private int mHeightMeasureSpec;
/**
* Interface that will receive notifications when the user is interacting with an item on the
* Suggestions list.
*/
public interface GestureObserver {
/**
* Notify that the user is interacting with an item on the Suggestions list.
*
* @param isGestureUp Whether user pressed (false) or depressed (true) the element on the
* list.
* @param timestamp The timestamp associated with the event.
*/
void onGesture(boolean isGestureUp, long timestamp);
}
/** Scroll manager that propagates scroll event notification to registered observers. */
@VisibleForTesting
/* package */ class SuggestionLayoutScrollListener extends LinearLayoutManager {
private boolean mLastKeyboardShownState;
public SuggestionLayoutScrollListener(Context context) {
super(context);
mLastKeyboardShownState = true;
}
@Override
public int scrollVerticallyBy(
int requestedDeltaY, RecyclerView.Recycler recycler, RecyclerView.State state) {
int resultingDeltaY = super.scrollVerticallyBy(requestedDeltaY, recycler, state);
return updateKeyboardVisibilityAndScroll(resultingDeltaY, requestedDeltaY);
}
/**
* Respond to scroll event.
* - Upon scroll down from the top, if the distance scrolled is same as distance requested
* (= the list has enough content to respond to the request), hide the keyboard and
* suppress the scroll action by reporting 0 as the resulting scroll distance.
* - Upon scroll up to the top, if the distance scrolled is shorter than the distance
* requested (= the list has reached the top), show the keyboard.
* - In all other cases, take no action.
*
* The code reports 0 if and only if the keyboard state transitions from "shown" to
* "hidden".
*
* The logic remembers the last requested keyboard state, so that the keyboard is not
* repeatedly called up or requested to be hidden.
*
* @param resultingDeltaY The scroll distance by which the LayoutManager intends to scroll.
* Negative values indicate scroll up, positive values indicate scroll down.
* @param requestedDeltaY The scroll distance requested by the user via gesture.
* Negative values indicate scroll up, positive values indicate scroll down.
* @return Value of resultingDeltaY, if scroll is permitted, or 0 when it is suppressed.
*/
@VisibleForTesting
/* package */ int updateKeyboardVisibilityAndScroll(
int resultingDeltaY, int requestedDeltaY) {
// If the effective scroll distance is:
// - same as the desired one, we have enough content to scroll in a given direction
// (negative values = up, positive values = down).
// - if resultingDeltaY is smaller than requestedDeltaY, we have reached the bottom of
// the list. This can occur only if both values are greater than or equal to 0:
// having reached the bottom of the list, the scroll request cannot be satisfied and
// the resultingDeltaY is clamped.
// - if resultingDeltaY is greater than requestedDeltaY, we have reached the top of the
// list. This can occur only if both values are less than or equal to zero:
// having reached the top of the list, the scroll request cannot be satisfied and
// the resultingDeltaY is clamped.
//
// When resultingDeltaY is less than requestedDeltaY we know we have reached the bottom
// of the list and weren't able to satisfy the requested scroll distance.
// This could happen in one of two cases:
// 1. the list was previously scrolled down (and we have already toggled keyboard
// visibility), or
// 2. the list is too short, and almost entirely fits on the screen, leaving at most
// just a few pixels of content hiding under the keyboard.
// There's no need to dismiss the keyboard in any of these cases.
if (resultingDeltaY < requestedDeltaY) return resultingDeltaY;
// Otherwise decide whether keyboard should be shown or not.
// We want to call keyboard up only when we know we reached the top of the list.
// Note: the condition below evaluates `true` only if the scroll direction is "up",
// meaning values are <= 0, meaning all three conditions are true:
// - resultingDeltaY <= 0
// - requestedDeltaY <= 0
// - Math.abs(resultingDeltaY) < Math.abs(requestedDeltaY)
boolean keyboardShouldShow = (resultingDeltaY > requestedDeltaY);
if (mLastKeyboardShownState == keyboardShouldShow) return resultingDeltaY;
mLastKeyboardShownState = keyboardShouldShow;
if (keyboardShouldShow) {
if (mSuggestionDropdownOverscrolledToTopListener != null) {
mSuggestionDropdownOverscrolledToTopListener.run();
}
} else {
if (mSuggestionDropdownScrollListener != null) {
mSuggestionDropdownScrollListener.run();
}
return 0;
}
return resultingDeltaY;
}
@Override
public LayoutParams generateDefaultLayoutParams() {
RecyclerView.LayoutParams params = super.generateDefaultLayoutParams();
params.width = RecyclerView.LayoutParams.MATCH_PARENT;
return params;
}
/**
* Reset the internal keyboard state.
* This needs to be called either when the SuggestionsDropdown is hidden or shown again
* to reflect either the end of the current or beginning of the next interaction
* session.
*/
@VisibleForTesting
/* package */ void resetKeyboardShownState() {
mLastKeyboardShownState = true;
}
}
/**
* Constructs a new list designed for containing omnibox suggestions.
* @param context Context used for contained views.
*/
public OmniboxSuggestionsDropdown(@NonNull Context context, RecycledViewPool recycledViewPool) {
super(context, null, android.R.attr.dropDownListViewStyle);
setFocusable(true);
setFocusableInTouchMode(true);
setRecycledViewPool(recycledViewPool);
// By default RecyclerViews come with item animators.
setItemAnimator(null);
mLayoutScrollListener = new SuggestionLayoutScrollListener(context);
setLayoutManager(mLayoutScrollListener);
boolean shouldShowModernizeVisualUpdate =
OmniboxFeatures.shouldShowModernizeVisualUpdate(context);
final Resources resources = context.getResources();
int paddingBottom =
resources.getDimensionPixelOffset(R.dimen.omnibox_suggestion_list_padding_bottom);
ViewCompat.setPaddingRelative(this, 0, 0, 0, paddingBottom);
mStandardBgColor = shouldShowModernizeVisualUpdate
? ChromeColors.getSurfaceColor(
context, R.dimen.omnibox_suggestion_dropdown_bg_elevation)
: ChromeColors.getDefaultThemeColor(context, false);
int incognitoBgColorRes = ChromeFeatureList.sBaselineGm3SurfaceColors.isEnabled()
? R.color.default_bg_color_dark_elev_1_gm3_baseline
: R.color.omnibox_dropdown_bg_incognito;
mIncognitoBgColor = shouldShowModernizeVisualUpdate
? context.getColor(incognitoBgColorRes)
: ChromeColors.getDefaultThemeColor(context, true);
}
/** Get the Android View implementing suggestion list. */
public @NonNull ViewGroup getViewGroup() {
return this;
}
/** Clean up resources and remove observers installed by this class. */
public void destroy() {
getRecycledViewPool().clear();
mGestureObserver = null;
mHeightChangeListener = null;
mSuggestionDropdownScrollListener = null;
mSuggestionDropdownOverscrolledToTopListener = null;
}
/**
* Sets the observer for that the user is interacting with an item on the Suggestions list..
* @param observer an observer of this gesture.
*/
public void setGestureObserver(@NonNull OmniboxSuggestionsDropdown.GestureObserver observer) {
mGestureObserver = observer;
}
/**
* Sets the listener for changes of the suggestion list's height.
* The height may change as a result of eg. soft keyboard popping up.
*
* @param listener A listener will receive the new height of the suggestion list in pixels.
*/
public void setHeightChangeListener(@NonNull Callback<Integer> listener) {
mHeightChangeListener = listener;
}
/**
* @param listener A listener will be invoked whenever the User scrolls the list.
*/
public void setSuggestionDropdownScrollListener(@NonNull Runnable listener) {
mSuggestionDropdownScrollListener = listener;
}
/**
* @param listener A listener will be invoked whenever the User scrolls the list to the top.
*/
public void setSuggestionDropdownOverscrolledToTopListener(@NonNull Runnable listener) {
mSuggestionDropdownOverscrolledToTopListener = listener;
}
/** Resets selection typically in response to changes to the list. */
public void resetSelection() {
if (mAdapter == null) return;
mAdapter.resetSelection();
}
/** Resests the tracked keyboard shown state to properly respond to scroll events. */
void resetKeyboardShownState() {
mLayoutScrollListener.resetKeyboardShownState();
}
/** @return The number of items in the list. */
public int getDropdownItemViewCountForTest() {
if (mAdapter == null) return 0;
return mAdapter.getItemCount();
}
/** @return The Suggestion view at specific index. */
public @Nullable View getDropdownItemViewForTest(int index) {
final LayoutManager manager = getLayoutManager();
manager.scrollToPosition(index);
return manager.findViewByPosition(index);
}
/**
* Update the suggestion popup background to reflect the current state.
* @param brandedColorScheme The {@link @BrandedColorScheme}.
*/
public void refreshPopupBackground(@BrandedColorScheme int brandedColorScheme) {
int color = brandedColorScheme == BrandedColorScheme.INCOGNITO ? mIncognitoBgColor
: mStandardBgColor;
if (!isHardwareAccelerated()) {
// When HW acceleration is disabled, changing mSuggestionList' items somehow erases
// mOmniboxResultsContainer' background from the area not covered by
// mSuggestionList. To make sure mOmniboxResultsContainer is always redrawn, we make
// list background color slightly transparent. This makes mSuggestionList.isOpaque()
// to return false, and forces redraw of the parent view (mOmniboxResultsContainer).
if (Color.alpha(color) == 255) {
color = Color.argb(254, Color.red(color), Color.green(color), Color.blue(color));
}
}
setBackground(new ColorDrawable(color));
}
@Override
public void setAdapter(@NonNull Adapter adapter) {
mAdapter = (OmniboxSuggestionsDropdownAdapter) adapter;
super.setAdapter(mAdapter);
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
mEmbedder.onAttachedToWindow();
mInitialResizeState = InitialResizeState.WAITING_FOR_FIRST_MEASURE;
mOmniboxAlignmentObserver = this::onOmniboxAlignmentChanged;
mOmniboxAlignment = mEmbedder.addAlignmentObserver(mOmniboxAlignmentObserver);
resetSelection();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mEmbedder.onDetachedFromWindow();
mOmniboxAlignment = OmniboxAlignment.UNSPECIFIED;
if (!OmniboxFeatures.shouldPreWarmRecyclerViewPool()) {
getRecycledViewPool().clear();
}
mAdapter.recordSessionMetrics();
mEmbedder.removeAlignmentObserver(mOmniboxAlignmentObserver);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
try (TraceEvent tracing = TraceEvent.scoped("OmniboxSuggestionsList.Measure");
TimingMetric metric = SuggestionsMetrics.recordSuggestionListMeasureTime()) {
OmniboxAlignment omniboxAlignment = mEmbedder.getCurrentAlignment();
maybeUpdateLayoutParams(omniboxAlignment.top);
boolean useAlignmentSpecifiedHeight = OmniboxFeatures.omniboxConsumesImeInsets();
int availableViewportHeight = useAlignmentSpecifiedHeight
? omniboxAlignment.height
: calculateAvailableViewportHeight() - omniboxAlignment.top;
int desiredWidth = omniboxAlignment.width;
adjustHorizontalPosition();
// Suppress the initial requests to shrink the viewport of the omnibox suggestion
// dropdown. The viewport will decrease when the keyboard is triggered, but the request
// to resize happens when the keyboard starts showing before it has had the chance to
// animate in. Because the resizing is triggered early, the dropdown shrinks earlier
// then the keyboard is fully visible, which leaves a hole in the UI showing the content
// where the keyboard will eventually go.
//
// The work around is to suppress these initial shrinking layout requests and defer them
// for enough time for the keyboard to hopefully be visible.
//
// This does not use getMeasuredHeight() as a means of comparison against the available
// viewport because on tablets the measured height can be smaller than the viewport as
// tablets use AT_MOST for the measure spec vs EXACTLY on phones.
// This logic is moot when we use alignment-specified height; the deferral of keyboard
// height changes is handled for us in that case.
if (!useAlignmentSpecifiedHeight) {
if ((mInitialResizeState == InitialResizeState.WAITING_FOR_SHRINKING
|| mInitialResizeState == InitialResizeState.IGNORING_SHRINKING)
&& availableViewportHeight < mListViewMaxHeight
&& getMeasuredWidth() == desiredWidth) {
super.onMeasure(mWidthMeasureSpec, mHeightMeasureSpec);
if (mInitialResizeState == InitialResizeState.IGNORING_SHRINKING) return;
mInitialResizeState = InitialResizeState.IGNORING_SHRINKING;
PostTask.postDelayedTask(TaskTraits.UI_USER_BLOCKING, () -> {
if (mInitialResizeState != InitialResizeState.IGNORING_SHRINKING) return;
ViewUtils.requestLayout(this, "OmniboxSuggestionsDropdown.onMeasure");
mInitialResizeState = InitialResizeState.HANDLED_INITIAL_SIZING;
}, DEFERRED_INITIAL_SHRINKING_LAYOUT_FROM_IME_DURATION_MS);
return;
} else if (mInitialResizeState == InitialResizeState.IGNORING_SHRINKING) {
// The dimensions changed in an unexpected way (either by increasing height or
// a change in width), so just mark the initial sizing as completed and accept
// the new measurements and suppress the pending posted layout request.
mInitialResizeState = InitialResizeState.HANDLED_INITIAL_SIZING;
}
}
notifyObserversIfViewportHeightChanged(availableViewportHeight);
mWidthMeasureSpec = MeasureSpec.makeMeasureSpec(desiredWidth, MeasureSpec.EXACTLY);
mHeightMeasureSpec = MeasureSpec.makeMeasureSpec(availableViewportHeight,
mEmbedder.isTablet() ? MeasureSpec.AT_MOST : MeasureSpec.EXACTLY);
super.onMeasure(mWidthMeasureSpec, mHeightMeasureSpec);
if (mInitialResizeState == InitialResizeState.WAITING_FOR_FIRST_MEASURE) {
mInitialResizeState = InitialResizeState.WAITING_FOR_SHRINKING;
}
}
}
private void maybeUpdateLayoutParams(int topMargin) {
// Update the layout params to ensure the parent correctly positions the suggestions
// under the anchor view.
ViewGroup.LayoutParams layoutParams = getLayoutParams();
if (layoutParams != null && layoutParams instanceof ViewGroup.MarginLayoutParams) {
((ViewGroup.MarginLayoutParams) layoutParams).topMargin = topMargin;
}
}
private int calculateAvailableViewportHeight() {
mEmbedder.getWindowDelegate().getWindowVisibleDisplayFrame(mTempRect);
return mTempRect.height();
}
private void notifyObserversIfViewportHeightChanged(int availableViewportHeight) {
if (availableViewportHeight == mListViewMaxHeight) return;
mListViewMaxHeight = availableViewportHeight;
if (mHeightChangeListener != null) {
PostTask.postTask(TaskTraits.UI_DEFAULT, () -> {
// Detect if there was another change since this task posted.
// This indicates a subsequent task being posted too.
if (mListViewMaxHeight != availableViewportHeight) return;
// Detect if the new height is the same as previously broadcasted.
// The two checks (one above and one below) allow us to detect quick
// A->B->A transitions and suppress the broadcasts.
if (mLastBroadcastedListViewMaxHeight == availableViewportHeight) return;
if (mHeightChangeListener == null) return;
mHeightChangeListener.onResult(availableViewportHeight);
mLastBroadcastedListViewMaxHeight = availableViewportHeight;
});
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
try (TraceEvent tracing = TraceEvent.scoped("OmniboxSuggestionsList.Layout");
TimingMetric metric = SuggestionsMetrics.recordSuggestionListLayoutTime()) {
super.onLayout(changed, l, t, r, b);
}
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (!isShown()) return false;
View selectedView = mAdapter.getSelectedView();
if (selectedView != null && selectedView.onKeyDown(keyCode, event)) {
return true;
}
int selectedPosition = mAdapter.getSelectedViewIndex();
if (KeyNavigationUtil.isGoDown(event)) {
return mAdapter.setSelectedViewIndex(selectedPosition + 1);
} else if (KeyNavigationUtil.isGoUp(event)) {
return mAdapter.setSelectedViewIndex(selectedPosition - 1);
} else if (KeyNavigationUtil.isEnter(event)) {
if (selectedView != null) return selectedView.performClick();
}
return super.onKeyDown(keyCode, event);
}
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
// Consume mouse events to ensure clicks do not bleed through to sibling views that
// are obscured by the list. crbug.com/968414
int action = event.getActionMasked();
boolean shouldIgnoreGenericMotionEvent =
(event.getSource() & InputDevice.SOURCE_CLASS_POINTER) != 0
&& event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
&& (action == MotionEvent.ACTION_BUTTON_PRESS
|| action == MotionEvent.ACTION_BUTTON_RELEASE);
return shouldIgnoreGenericMotionEvent || super.onGenericMotionEvent(event);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
final int eventType = ev.getActionMasked();
if ((eventType == MotionEvent.ACTION_UP || eventType == MotionEvent.ACTION_DOWN)
&& mGestureObserver != null) {
mGestureObserver.onGesture(eventType == MotionEvent.ACTION_UP, ev.getEventTime());
}
return super.dispatchTouchEvent(ev);
}
/**
* Sets the embedder for the list view.
* @param embedder the embedder of this list.
*/
public void setEmbedder(@NonNull OmniboxSuggestionsDropdownEmbedder embedder) {
assert mEmbedder == null;
mEmbedder = embedder;
mOmniboxAlignment = mEmbedder.getCurrentAlignment();
}
private void onOmniboxAlignmentChanged(@NonNull OmniboxAlignment omniboxAlignment) {
boolean isOnlyHorizontalDifference =
omniboxAlignment.isOnlyHorizontalDifference(mOmniboxAlignment);
boolean isWidthDifference = omniboxAlignment.doesWidthDiffer(mOmniboxAlignment);
mOmniboxAlignment = omniboxAlignment;
if (isOnlyHorizontalDifference) {
adjustHorizontalPosition();
return;
} else if (isWidthDifference) {
// If our width has changed, we may have views in our pool that are now the wrong width.
// Recycle them by calling swapAdapter() to avoid showing views of the wrong size.
swapAdapter(mAdapter, true);
}
if (isInLayout()) {
// requestLayout doesn't behave predictably in the middle of a layout pass. Even if it
// does trigger a second layout pass, measurement caches aren't properly reset,
// resulting in stale sizing. Absent a way to abort the current pass and start over the
// simplest solution is to wait until the current pass is over to request relayout.
PostTask.postTask(TaskTraits.UI_USER_VISIBLE, () -> {
ViewUtils.requestLayout(OmniboxSuggestionsDropdown.this,
"OmniboxSuggestionsDropdown.onOmniboxAlignmentChanged");
});
} else {
ViewUtils.requestLayout((View) OmniboxSuggestionsDropdown.this,
"OmniboxSuggestionsDropdown.onOmniboxAlignmentChanged");
}
}
private void adjustHorizontalPosition() {
if (OmniboxFeatures.shouldShowModernizeVisualUpdate(getContext())) {
// Set our left edge using translation x. This avoids needing to relayout (like setting
// a left margin would) and is less risky than calling View#setLeft(), which is intended
// for use by the layout system.
setTranslationX(mOmniboxAlignment.left);
} else {
setPadding(mOmniboxAlignment.paddingLeft, getPaddingTop(),
mOmniboxAlignment.paddingRight, getPaddingBottom());
}
}
public void emitWindowContentChanged() {
PostTask.postDelayedTask(TaskTraits.UI_DEFAULT, () -> {
announceForAccessibility(getContext().getString(
R.string.accessibility_omnibox_suggested_items, mAdapter.getItemCount()));
}, LIST_COMPOSITION_ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS);
}
@VisibleForTesting
public int getStandardBgColor() {
return mStandardBgColor;
}
@VisibleForTesting
public int getIncognitoBgColor() {
return mIncognitoBgColor;
}
@VisibleForTesting
SuggestionLayoutScrollListener getLayoutScrollListener() {
return mLayoutScrollListener;
}
}