// 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.Configuration;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.util.AttributeSet;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
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.omnibox.OmniboxMetrics;
import org.chromium.chrome.browser.omnibox.R;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.omnibox.suggestions.OmniboxSuggestionsDropdownEmbedder.OmniboxAlignment;
import org.chromium.chrome.browser.omnibox.suggestions.base.BaseSuggestionViewBinder;
import org.chromium.chrome.browser.ui.theme.BrandedColorScheme;
import org.chromium.chrome.browser.util.KeyNavigationUtil;
import org.chromium.components.browser_ui.widget.RoundedCornerOutlineProvider;
import org.chromium.components.omnibox.OmniboxFeatures;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.ViewUtils;
import java.util.Optional;
/** A widget for showing a list of omnibox suggestions. */
public class OmniboxSuggestionsDropdown extends RecyclerView {
/**
* 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 SuggestionLayoutScrollListener mLayoutScrollListener;
private final RecyclerViewSelectionController mSelectionController;
private @Nullable OmniboxSuggestionsDropdownAdapter mAdapter;
private Optional<OmniboxSuggestionsDropdownEmbedder> mEmbedder = Optional.empty();
private @Nullable GestureObserver mGestureObserver;
private @Nullable Callback<Integer> mHeightChangeListener;
private @NonNull OmniboxAlignment mOmniboxAlignment = OmniboxAlignment.UNSPECIFIED;
private int mListViewMaxHeight;
private int mLastBroadcastedListViewMaxHeight;
private @Nullable Callback<OmniboxAlignment> mOmniboxAlignmentObserver =
this::onOmniboxAlignmentChanged;
private float mChildVerticalTranslation;
private float mChildAlpha = 1.0f;
/**
* 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 */ static class SuggestionLayoutScrollListener extends LinearLayoutManager {
private boolean mLastKeyboardShownState;
private boolean mCurrentGestureAffectedKeyboardState;
private @Nullable Runnable mSuggestionDropdownScrollListener;
private @Nullable Runnable mSuggestionDropdownOverscrolledToTopListener;
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.
*
* <ul>
* <li>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.
* <li>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.
* <li>In all other cases, take no action.
* </ul>
*
* <p>The code reports 0 if and only if the keyboard state transitions from "shown" to
* "hidden".
*
* <p>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) {
// Change keyboard visibility only once per gesture.
// This helps in situations where the user interacts with the horizontal caoursel (e.g.
// the Most Visited Sites), where a horizontal finger swipe could result in a series of
// keyboard show/hide events.
if (mCurrentGestureAffectedKeyboardState) return resultingDeltaY;
// 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.
// Note that the list may extend below the keyboard and still be non-scrollable:
// http://crbug/1479437
// 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;
mCurrentGestureAffectedKeyboardState = true;
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;
mCurrentGestureAffectedKeyboardState = false;
}
/**
* Reset internal state, preparing to handle a new gesture. Note: currently invoked both
* when a gesture begins and ends.
*/
/* package */ void onNewGesture() {
mCurrentGestureAffectedKeyboardState = false;
}
/**
* @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;
}
}
/**
* Constructs a new list designed for containing omnibox suggestions.
*
* @param context Context used for contained views.
*/
public OmniboxSuggestionsDropdown(@NonNull Context context, AttributeSet attrs) {
super(context, attrs, android.R.attr.dropDownListViewStyle);
setFocusable(true);
setFocusableInTouchMode(true);
setId(R.id.omnibox_suggestions_dropdown);
// By default RecyclerViews come with item animators.
setItemAnimator(null);
addItemDecoration(new SuggestionHorizontalDivider(context));
mLayoutScrollListener = new SuggestionLayoutScrollListener(context);
setLayoutManager(mLayoutScrollListener);
mSelectionController = new RecyclerViewSelectionController(mLayoutScrollListener);
addOnChildAttachStateChangeListener(mSelectionController);
final Resources resources = context.getResources();
int paddingBottom =
resources.getDimensionPixelOffset(R.dimen.omnibox_suggestion_list_padding_bottom);
int paddingTop =
resources.getDimensionPixelOffset(R.dimen.omnibox_suggestion_list_padding_top);
this.setPaddingRelative(0, paddingTop, 0, paddingBottom);
if (OmniboxFeatures.sAsyncViewInflation.isEnabled()) {
setRecycledViewPool(new PreWarmingRecycledViewPool(mAdapter, context));
}
}
/**
* Override the visuals of the Omnibox. This method is particularly relevant for SearchActivity,
* which presents Phone-style omnibox even when running on Tablets.
*
* @param shouldForce whether Omnibox should be forced to use Phone-style visuals
*/
public void forcePhoneStyleOmnibox(boolean shouldForce) {
if (!shouldForce
&& DeviceFormFactor.isNonMultiDisplayContextOnTablet(getContext())
&& getContext().getResources().getConfiguration().screenWidthDp
>= DeviceFormFactor.MINIMUM_TABLET_WIDTH_DP) {
setOutlineProvider(
new RoundedCornerOutlineProvider(
getContext()
.getResources()
.getDimensionPixelSize(
R.dimen
.omnibox_suggestion_dropdown_round_corner_radius)));
setClipToOutline(true);
} else {
setOutlineProvider(null);
setClipToOutline(false);
}
}
/** 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;
}
/**
* 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;
}
/** Resets selection typically in response to changes to the list. */
public void resetSelection() {
mSelectionController.resetSelection();
}
/**
* Translates all children by {@code translation}. This translation is applied to newly-added
* added children as well.
*/
public void translateChildrenVertical(float translation) {
mChildVerticalTranslation = translation;
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
getChildAt(i).setTranslationY(translation);
}
invalidateItemDecorations();
}
/**
* Sets the alpha of all child views. This alpha is applied to newly-added added children as
* well.
*/
public void setChildAlpha(float alpha) {
mChildAlpha = alpha;
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
getChildAt(i).setAlpha(alpha);
}
invalidateItemDecorations();
}
@Override
public void onChildAttachedToWindow(@NonNull View child) {
child.setAlpha(mChildAlpha);
if (mChildVerticalTranslation != 0.0f) {
child.setTranslationY(mChildVerticalTranslation);
}
}
@Override
public void onChildDetachedFromWindow(@NonNull View child) {
child.setTranslationY(0.0f);
child.setAlpha(1.0f);
}
/** 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 =
OmniboxResourceProvider.getSuggestionsDropdownBackgroundColorForColorScheme(
getContext(), brandedColorScheme);
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
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
boolean isTablet = mEmbedder.map(e -> e.isTablet()).orElse(false);
try (TraceEvent tracing = TraceEvent.scoped("OmniboxSuggestionsList.Measure");
TimingMetric metric = OmniboxMetrics.recordSuggestionListMeasureTime();
TimingMetric metric2 = OmniboxMetrics.recordSuggestionListMeasureWallTime()) {
maybeUpdateLayoutParams(mOmniboxAlignment.top);
int availableViewportHeight = mOmniboxAlignment.height;
int desiredWidth = mOmniboxAlignment.width;
adjustHorizontalPosition();
notifyObserversIfViewportHeightChanged(availableViewportHeight);
widthMeasureSpec = MeasureSpec.makeMeasureSpec(desiredWidth, MeasureSpec.EXACTLY);
heightMeasureSpec =
MeasureSpec.makeMeasureSpec(
availableViewportHeight,
isTablet ? MeasureSpec.AT_MOST : MeasureSpec.EXACTLY);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (isTablet) {
setRoundBottomCorners(
getMeasuredHeight() < availableViewportHeight
|| !KeyboardVisibilityDelegate.getInstance()
.isKeyboardShowing(getContext(), this));
}
}
}
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 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 = OmniboxMetrics.recordSuggestionListLayoutTime();
TimingMetric metric2 = OmniboxMetrics.recordSuggestionListLayoutWallTime()) {
super.onLayout(changed, l, t, r, b);
}
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (!isShown()) return false;
View selectedView = mSelectionController.getSelectedView();
if (selectedView != null && selectedView.onKeyDown(keyCode, event)) {
return true;
}
if (keyCode == KeyEvent.KEYCODE_TAB) {
boolean maybeProcessed = super.onKeyDown(keyCode, event);
if (maybeProcessed) return true;
if (event.isShiftPressed()) {
return mSelectionController.selectPreviousItem();
}
return mSelectionController.selectNextItem();
}
if (KeyNavigationUtil.isGoDown(event)) {
mSelectionController.selectNextItem();
return true;
} else if (KeyNavigationUtil.isGoUp(event)) {
mSelectionController.selectPreviousItem();
return true;
} 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) {
mLayoutScrollListener.onNewGesture();
if (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) {
// Don't reset the current value of `mOmniboxAlignment`, and don't read the value from newly
// installed embedder to ensure the `onOmniboxAlignmentChanged` does the right thing when we
// install our observers.
mEmbedder = Optional.of(embedder);
}
/**
* Respond to Omnibox session state change.
*
* @param urlHasFocus whether URL has focus (signaling the session is active)
*/
/* package */ void onOmniboxSessionStateChange(boolean urlHasFocus) {
if (urlHasFocus) {
installAlignmentObserver();
} else {
removeAlignmentObserver();
}
}
private void installAlignmentObserver() {
mEmbedder.ifPresent(
e -> {
e.onAttachedToWindow();
mOmniboxAlignment = e.addAlignmentObserver(mOmniboxAlignmentObserver);
});
}
private void removeAlignmentObserver() {
mEmbedder.ifPresent(
e -> {
e.onDetachedFromWindow();
e.removeAlignmentObserver(mOmniboxAlignmentObserver);
});
if (!OmniboxFeatures.shouldPreWarmRecyclerViewPool()) {
getRecycledViewPool().clear();
}
}
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);
Configuration configuration = getContext().getResources().getConfiguration();
setClipToOutline(
configuration.screenWidthDp >= DeviceFormFactor.MINIMUM_TABLET_WIDTH_DP);
BaseSuggestionViewBinder.resetCachedResources();
}
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() {
// 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);
}
private void setRoundBottomCorners(boolean roundBottomCorners) {
ViewOutlineProvider outlineProvider = getOutlineProvider();
if (!(outlineProvider instanceof RoundedCornerOutlineProvider)) return;
RoundedCornerOutlineProvider roundedCornerOutlineProvider =
(RoundedCornerOutlineProvider) outlineProvider;
roundedCornerOutlineProvider.setRoundingEdges(true, true, true, roundBottomCorners);
}
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
SuggestionLayoutScrollListener getLayoutScrollListener() {
return mLayoutScrollListener;
}
}