// Copyright 2023 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;
import android.content.ComponentCallbacks;
import android.content.Context;
import android.content.res.Configuration;
import android.view.View;
import android.view.View.OnLayoutChangeListener;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.view.WindowInsets;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import org.chromium.base.BuildInfo;
import org.chromium.base.Callback;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.omnibox.suggestions.OmniboxSuggestionsDropdownEmbedder;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.ViewUtils;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.display.DisplayUtil;
/**
* Implementation of {@link OmniboxSuggestionsDropdownEmbedder} that positions it using an "anchor"
* and "horizontal alignment" view.
*/
class OmniboxSuggestionsDropdownEmbedderImpl
implements OmniboxSuggestionsDropdownEmbedder,
OnLayoutChangeListener,
OnGlobalLayoutListener,
ComponentCallbacks {
private final ObservableSupplierImpl<OmniboxAlignment> mOmniboxAlignmentSupplier =
new ObservableSupplierImpl<>();
private final @NonNull WindowAndroid mWindowAndroid;
private final @NonNull View mAnchorView;
private final @NonNull View mAlignmentView;
private final boolean mForcePhoneStyleOmnibox;
private final Supplier<Integer> mKeyboardHeightSupplier;
private final @NonNull Context mContext;
// Reusable int array to pass to positioning methods that operate on a two element int array.
// Keeping it as a member lets us avoid allocating a temp array every time.
private final int[] mPositionArray = new int[2];
private int mVerticalOffsetInWindow;
private int mWindowWidthDp;
private int mWindowHeightDp;
private WindowInsetsCompat mWindowInsetsCompat;
private @Nullable View mBaseChromeLayout;
/**
* @param windowAndroid Window object in which the dropdown will be displayed.
* @param anchorView View to which the dropdown should be "anchored" i.e. vertically positioned
* next to and matching the width of. This must be a descendant of the top-level content
* (android.R.id.content) view.
* @param alignmentView View to which: 1. The dropdown should be horizontally aligned to when
* its width is smaller than the anchor view. 2. The dropdown should vertically align to
* during animations. This must be a descendant of the anchor view.
* @param baseChromeLayout The base view hosting Chrome that certain views (e.g. the omnibox
* suggestion list) will position themselves relative to. If null, the content view will be
* used.
*/
OmniboxSuggestionsDropdownEmbedderImpl(
@NonNull WindowAndroid windowAndroid,
@NonNull View anchorView,
@NonNull View alignmentView,
boolean forcePhoneStyleOmnibox,
@Nullable View baseChromeLayout,
Supplier<Integer> keyboardHeightSupplier) {
mWindowAndroid = windowAndroid;
mAnchorView = anchorView;
mAlignmentView = alignmentView;
mForcePhoneStyleOmnibox = forcePhoneStyleOmnibox;
mKeyboardHeightSupplier = keyboardHeightSupplier;
mContext = mAnchorView.getContext();
mContext.registerComponentCallbacks(this);
Configuration configuration = mContext.getResources().getConfiguration();
mWindowWidthDp = configuration.smallestScreenWidthDp;
mWindowHeightDp = configuration.screenHeightDp;
mBaseChromeLayout = baseChromeLayout;
recalculateOmniboxAlignment();
}
@Override
public OmniboxAlignment addAlignmentObserver(Callback<OmniboxAlignment> obs) {
return mOmniboxAlignmentSupplier.addObserver(obs);
}
@Override
public void removeAlignmentObserver(Callback<OmniboxAlignment> obs) {
mOmniboxAlignmentSupplier.removeObserver(obs);
}
@Nullable
@Override
public OmniboxAlignment getCurrentAlignment() {
return mOmniboxAlignmentSupplier.get();
}
@Override
public boolean isTablet() {
if (mForcePhoneStyleOmnibox) return false;
return mWindowWidthDp >= DeviceFormFactor.MINIMUM_TABLET_WIDTH_DP
&& DeviceFormFactor.isWindowOnTablet(mWindowAndroid);
}
@Override
public void onAttachedToWindow() {
mAnchorView.addOnLayoutChangeListener(this);
mAlignmentView.addOnLayoutChangeListener(this);
mAnchorView.getViewTreeObserver().addOnGlobalLayoutListener(this);
onConfigurationChanged(mContext.getResources().getConfiguration());
recalculateOmniboxAlignment();
}
@Override
public void onDetachedFromWindow() {
mAnchorView.removeOnLayoutChangeListener(this);
mAlignmentView.removeOnLayoutChangeListener(this);
mAnchorView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
// View.OnLayoutChangeListener
@Override
public void onLayoutChange(
View v,
int left,
int top,
int right,
int bottom,
int oldLeft,
int oldTop,
int oldRight,
int oldBottom) {
recalculateOmniboxAlignment();
}
// OnGlobalLayoutListener
@Override
public void onGlobalLayout() {
if (offsetInWindowChanged(mAnchorView) || insetsHaveChanged(mAnchorView)) {
recalculateOmniboxAlignment();
}
}
// ComponentCallbacks
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
int windowWidth = newConfig.screenWidthDp;
int windowHeight = newConfig.screenHeightDp;
if (windowWidth == mWindowWidthDp && mWindowHeightDp == windowHeight) return;
mWindowWidthDp = windowWidth;
mWindowHeightDp = windowHeight;
recalculateOmniboxAlignment();
}
@Override
public void onLowMemory() {}
@Override
public float getVerticalTranslationForAnimation() {
return mAlignmentView.getTranslationY();
}
/**
* Recalculates the desired alignment of the omnibox and sends the updated alignment data to any
* observers. Currently will send an update message unconditionally. This method is called
* during layout and should avoid memory allocations other than the necessary new
* OmniboxAlignment(). The method aligns the omnibox dropdown as follows:
*
* <p>Case 1: Omnibox revamp enabled on tablet window.
*
* <pre>
* | anchor [ alignment ] |
* | dropdown |
* </pre>
*
* <p>Case 2: Omnibox revamp disabled on tablet window.
*
* <pre>
* | anchor [alignment] |
* |{pad_left} dropdown {pad_right}|
* </pre>
*
* <p>Case 3: Phone window. Full width and no padding.
*
* <pre>
* | anchor [alignment] |
* | dropdown |
* </pre>
*/
void recalculateOmniboxAlignment() {
View contentView = mAnchorView.getRootView().findViewById(android.R.id.content);
int contentViewTopPadding = contentView == null ? 0 : contentView.getPaddingTop();
// If there is a base Chrome layout, calculate the relative position from it rather than
// the content view. Sometimes, Chrome will add an intermediate layout to host certain
// views above the toolbar, such as the top back button toolbar on automotive devices.
// Since the omnibox alignment top padding will position the omnibox relative to this base
// layout, rather than the content view, the base layout should be used here to avoid
// "double counting" and creating a gap between the browser controls and omnibox
// suggestions.
View baseRelativeLayout = mBaseChromeLayout != null ? mBaseChromeLayout : contentView;
ViewUtils.getRelativeLayoutPosition(baseRelativeLayout, mAnchorView, mPositionArray);
int top = mPositionArray[1] + mAnchorView.getMeasuredHeight() - contentViewTopPadding;
int left;
int width;
int paddingLeft;
int paddingRight;
if (isTablet()) {
ViewUtils.getRelativeLayoutPosition(mAnchorView, mAlignmentView, mPositionArray);
// Width equal to alignment view and left equivalent to left of alignment view. Top
// minus a small overlap.
top -=
mContext.getResources()
.getDimensionPixelSize(R.dimen.omnibox_suggestion_list_toolbar_overlap);
int sideSpacing = OmniboxResourceProvider.getDropdownSideSpacing(mContext);
width = mAlignmentView.getMeasuredWidth() + 2 * sideSpacing;
if (mAnchorView.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
// The view will be shifted to the left, so the adjustment needs to be negative.
left = -(mAnchorView.getMeasuredWidth() - width - mPositionArray[0] + sideSpacing);
} else {
left = mPositionArray[0] - sideSpacing;
}
paddingLeft = 0;
paddingRight = 0;
} else {
// Case 3: phones or phone-sized windows on tablets. Full bleed width with no padding or
// positioning adjustments.
left = 0;
width = mAnchorView.getMeasuredWidth();
paddingLeft = 0;
paddingRight = 0;
}
int keyboardHeight = mKeyboardHeightSupplier.get();
int windowHeight;
if (BuildInfo.getInstance().isAutomotive
&& contentView != null
&& contentView.getRootWindowInsets() != null) {
// Some automotive devices dismiss bottom system bars when bringing up the keyboard,
// preventing the height of those bottom bars from being subtracted from the keyboard.
// To avoid a bottom-bar-sized gap above the keyboard, Chrome needs to calculate a new
// window height from the display with the new system bar insets, rather than rely on
// the cached mWindowHeightDp (that implicitly assumes persistence of the now-dismissed
// bottom system bars).
WindowInsetsCompat windowInsets =
WindowInsetsCompat.toWindowInsetsCompat(contentView.getRootWindowInsets());
Insets systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
windowHeight =
mWindowAndroid.getDisplay().getDisplayHeight()
- systemBars.top
- systemBars.bottom;
} else {
windowHeight = DisplayUtil.dpToPx(mWindowAndroid.getDisplay(), mWindowHeightDp);
}
int minSpaceAboveWindowBottom =
mContext.getResources()
.getDimensionPixelSize(R.dimen.omnibox_min_space_above_window_bottom);
int windowSpace =
Math.min(windowHeight - keyboardHeight, windowHeight - minSpaceAboveWindowBottom);
// If content view is null, then omnibox might not be in the activity content.
int contentSpace =
contentView == null
? Integer.MAX_VALUE
: contentView.getMeasuredHeight() - keyboardHeight;
int height = Math.min(windowSpace, contentSpace) - top;
// TODO(pnoland@, https://crbug.com/1416985): avoid pushing changes that are identical to
// the previous alignment value.
OmniboxAlignment omniboxAlignment =
new OmniboxAlignment(left, top, width, height, paddingLeft, paddingRight);
mOmniboxAlignmentSupplier.set(omniboxAlignment);
}
/**
* Returns whether the given view's position in the window has changed since the last call to
* offsetInWindowChanged().
*/
private boolean offsetInWindowChanged(View view) {
view.getLocationInWindow(mPositionArray);
boolean result = mVerticalOffsetInWindow != mPositionArray[1];
mVerticalOffsetInWindow = mPositionArray[1];
return result;
}
/**
* Returns whether the window insets corresponding to the given view have changed since the last
* call to insetsHaveChanged().
*/
private boolean insetsHaveChanged(View view) {
WindowInsets rootWindowInsets = view.getRootWindowInsets();
if (rootWindowInsets == null) return false;
WindowInsetsCompat windowInsetsCompat =
WindowInsetsCompat.toWindowInsetsCompat(view.getRootWindowInsets(), view);
boolean result = !windowInsetsCompat.equals(mWindowInsetsCompat);
mWindowInsetsCompat = windowInsetsCompat;
return result;
}
}