// Copyright 2019 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.base;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Resources;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.AccessibilityDelegate;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewGroup.MarginLayoutParams;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import android.widget.ImageView;
import androidx.annotation.ColorInt;
import androidx.annotation.ColorRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.view.ViewCompat;
import androidx.core.widget.ImageViewCompat;
import org.chromium.chrome.browser.omnibox.R;
import org.chromium.chrome.browser.omnibox.styles.OmniboxDrawableState;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.omnibox.suggestions.DropdownCommonProperties;
import org.chromium.chrome.browser.omnibox.suggestions.SuggestionCommonProperties;
import org.chromium.chrome.browser.omnibox.suggestions.base.BaseSuggestionViewProperties.Action;
import org.chromium.chrome.browser.ui.theme.BrandedColorScheme;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor.ViewBinder;
import org.chromium.ui.util.ColorUtils;
import java.util.List;
/**
* Binds base suggestion view properties.
*
* <p>This binder should be used by all suggestions that also utilize BaseSuggestionView<T> to
* construct the view, and manages shared suggestion properties (such as decorations or theme).
*
* @param <T> The inner content view type being updated.
*/
public final class BaseSuggestionViewBinder<T extends View>
implements ViewBinder<PropertyModel, BaseSuggestionView<T>, PropertyKey> {
/**
* Holder of metadata about a view's current state w.r.t. a suggestion's visual properties. This
* allows us to avoid calling setters when the current state of the view is already correct.
*/
private static class BaseSuggestionViewMetadata {
@Nullable public Drawable.ConstantState backgroundConstantState;
}
/** Drawable ConstantState used to expedite creation of Focus ripples. */
private static Drawable.ConstantState sFocusableDrawableState;
private static @BrandedColorScheme int sFocusableDrawableStateTheme;
private static boolean sFocusableDrawableStateInNightMode;
private final ViewBinder<PropertyModel, T, PropertyKey> mContentBinder;
private static boolean sDimensionsInitialized;
private static int sEdgeSize;
private static int sEdgeSizeLargeIcon;
private static int sSideSpacing;
private static int sLargeIconRoundingRadius;
private static int sSmallIconRoundingRadius;
public BaseSuggestionViewBinder(ViewBinder<PropertyModel, T, PropertyKey> contentBinder) {
mContentBinder = contentBinder;
}
@Override
@SuppressLint("ClickableViewAccessibility")
public void bind(PropertyModel model, BaseSuggestionView<T> view, PropertyKey propertyKey) {
if (!sDimensionsInitialized) {
initializeDimensions(view.getContext());
sDimensionsInitialized = true;
}
mContentBinder.bind(model, view.contentView, propertyKey);
ActionChipsBinder.bind(model, view.actionChipsView, propertyKey);
if (BaseSuggestionViewProperties.ACTION_CHIP_LEAD_IN_SPACING == propertyKey) {
view.setActionChipLeadInSpacing(
model.get(BaseSuggestionViewProperties.ACTION_CHIP_LEAD_IN_SPACING));
} else if (BaseSuggestionViewProperties.ICON == propertyKey) {
updateSuggestionIcon(model, view);
} else if (SuggestionCommonProperties.LAYOUT_DIRECTION == propertyKey) {
ViewCompat.setLayoutDirection(
view, model.get(SuggestionCommonProperties.LAYOUT_DIRECTION));
// TODO(crbug.com/41487873): migrate this to SuggestionLayout.
updateMargin(model, view);
} else if (SuggestionCommonProperties.COLOR_SCHEME == propertyKey) {
updateColorScheme(model, view);
} else if (DropdownCommonProperties.BG_BOTTOM_CORNER_ROUNDED == propertyKey
|| DropdownCommonProperties.BG_TOP_CORNER_ROUNDED == propertyKey) {
view.setRoundingEdges(
model.get(DropdownCommonProperties.BG_TOP_CORNER_ROUNDED),
model.get(DropdownCommonProperties.BG_BOTTOM_CORNER_ROUNDED));
} else if (BaseSuggestionViewProperties.ACTION_BUTTONS == propertyKey) {
bindActionButtons(model, view, model.get(BaseSuggestionViewProperties.ACTION_BUTTONS));
} else if (BaseSuggestionViewProperties.ON_FOCUS_VIA_SELECTION == propertyKey) {
view.setOnFocusViaSelectionListener(
model.get(BaseSuggestionViewProperties.ON_FOCUS_VIA_SELECTION));
} else if (BaseSuggestionViewProperties.ON_CLICK == propertyKey) {
Runnable listener = model.get(BaseSuggestionViewProperties.ON_CLICK);
if (listener == null) {
view.setOnClickListener(null);
} else {
view.setOnClickListener(v -> listener.run());
}
} else if (BaseSuggestionViewProperties.ON_LONG_CLICK == propertyKey) {
Runnable listener = model.get(BaseSuggestionViewProperties.ON_LONG_CLICK);
if (listener == null) {
view.setOnLongClickListener(null);
} else {
view.setOnLongClickListener(
v -> {
listener.run();
return true;
});
}
} else if (BaseSuggestionViewProperties.ON_TOUCH_DOWN_EVENT == propertyKey) {
Runnable listener = model.get(BaseSuggestionViewProperties.ON_TOUCH_DOWN_EVENT);
if (listener == null) {
view.setOnTouchListener(null);
} else {
view.setOnTouchListener(
(v, event) -> {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
listener.run();
}
return false;
});
}
} else if (BaseSuggestionViewProperties.SHOW_DECORATION == propertyKey) {
view.setShowDecorationIcon(model.get(BaseSuggestionViewProperties.SHOW_DECORATION));
} else if (BaseSuggestionViewProperties.TOP_PADDING == propertyKey) {
view.setPadding(0, model.get(BaseSuggestionViewProperties.TOP_PADDING), 0, 0);
} else if (BaseSuggestionViewProperties.USE_LARGE_DECORATION == propertyKey) {
view.setUseLargeDecorationIcon(
model.get(BaseSuggestionViewProperties.USE_LARGE_DECORATION));
}
}
/** Bind Action Icons for the suggestion view. */
private static <T extends View> void bindActionButtons(
PropertyModel model, BaseSuggestionView<T> view, List<Action> actions) {
final int actionCount = actions != null ? actions.size() : 0;
view.setActionButtonsCount(actionCount);
// Drawable retrieved once here (expensive) and will be copied multiple times (cheap).
final List<ImageView> actionViews = view.getActionButtons();
for (int index = 0; index < actionCount; index++) {
final ImageView actionView = actionViews.get(index);
final Action action = actions.get(index);
actionView.setOnClickListener(v -> action.callback.run());
actionView.setContentDescription(action.accessibilityDescription);
applySelectableBackground(model, actionView);
updateIcon(
actionView,
action.icon,
ChromeColors.getPrimaryIconTintRes(isIncognito(model)));
actionView.setAccessibilityDelegate(
new AccessibilityDelegate() {
@Override
public void onInitializeAccessibilityNodeInfo(
View host, AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(host, info);
info.addAction(AccessibilityAction.ACTION_CLICK);
}
@Override
public boolean performAccessibilityAction(
View host, int accessibilityAction, Bundle arguments) {
if (accessibilityAction == AccessibilityNodeInfo.ACTION_CLICK
&& action.onClickAnnouncement != null) {
actionView.announceForAccessibility(action.onClickAnnouncement);
}
return super.performAccessibilityAction(
host, accessibilityAction, arguments);
}
});
}
}
/** Update visual theme to reflect dark mode UI theme update. */
private static <T extends View> void updateColorScheme(
PropertyModel model, BaseSuggestionView<T> view) {
maybeResetCachedFocusableDrawableState(model, view);
updateSuggestionIcon(model, view);
applySelectableBackground(model, view);
final List<Action> actions = model.get(BaseSuggestionViewProperties.ACTION_BUTTONS);
// Setting ACTION_BUTTONS and updating actionViews can happen later. Appropriate color
// scheme will be applied then.
if (actions == null) return;
final List<ImageView> actionViews = view.getActionButtons();
for (int index = 0; index < actionViews.size(); index++) {
ImageView actionView = actionViews.get(index);
applySelectableBackground(model, actionView);
updateIcon(
actionView,
actions.get(index).icon,
ChromeColors.getPrimaryIconTintRes(isIncognito(model)));
}
}
/**
* @return Whether the current {@link BrandedColorScheme} is INCOGNITO.
*/
private static boolean isIncognito(PropertyModel model) {
return model.get(SuggestionCommonProperties.COLOR_SCHEME) == BrandedColorScheme.INCOGNITO;
}
/** Update attributes of decorated suggestion icon. */
private static <T extends View> void updateSuggestionIcon(
PropertyModel model, BaseSuggestionView<T> baseView) {
final ImageView rciv = baseView.decorationIcon;
final OmniboxDrawableState sds = model.get(BaseSuggestionViewProperties.ICON);
if (sds != null) {
// Ensure the decoration icon size does not exceed the maximum edge size.
int edgeSize = sds.isLarge ? sEdgeSizeLargeIcon : sEdgeSize;
boolean isTall = sds.drawable.getIntrinsicHeight() > sds.drawable.getIntrinsicWidth();
rciv.getLayoutParams().width = isTall ? ViewGroup.LayoutParams.WRAP_CONTENT : edgeSize;
rciv.getLayoutParams().height = isTall ? edgeSize : ViewGroup.LayoutParams.WRAP_CONTENT;
// Note: ImageView, unlike other View types, includes logic to scale its bounds
// proportionally to its image aspect ratio. This guarantees behavior consistent with
// RoundedCornerImageView, dp-accurate rounding and hardware acceleration.
// The view bound adjustment is controlled by the following three lines.
rciv.setAdjustViewBounds(true);
rciv.setMaxWidth(edgeSize);
rciv.setMaxHeight(edgeSize);
rciv.setClipToOutline(sds.useRoundedCorners);
baseView.decorationIconOutline.setRadius(
sds.isLarge ? sLargeIconRoundingRadius : sSmallIconRoundingRadius);
}
updateIcon(rciv, sds, ChromeColors.getSecondaryIconTintRes(isIncognito(model)));
}
/**
* Access the BaseSuggestionViewMetadata for the given view, creating and attaching a new one if
* none is currently associated.
*/
private static @NonNull BaseSuggestionViewMetadata ensureViewMetadata(View view) {
BaseSuggestionViewMetadata metadata =
(BaseSuggestionViewMetadata) view.getTag(R.id.base_suggestion_view_metadata_key);
if (metadata == null) {
metadata = new BaseSuggestionViewMetadata();
view.setTag(R.id.base_suggestion_view_metadata_key, metadata);
}
return metadata;
}
/**
* Applies selectable drawable from cache (where possible) or resources (otherwise).
*
* <p>The method internally stores the ConstantState for the drawable to be returned to
* accelerate creation of subsequent objects.
*
* @param model A property model to look up relevant properties.
* @param view A view that receives background.
*/
public static void applySelectableBackground(PropertyModel model, View view) {
// Use a throwaway metadata object if caching is off to simplify branching; the performance
// difference will still manifest because it's not persisted.
BaseSuggestionViewMetadata metadata = ensureViewMetadata(view);
if (sFocusableDrawableState != null) {
if (sFocusableDrawableState == metadata.backgroundConstantState) return;
view.setBackground(sFocusableDrawableState.newDrawable());
metadata.backgroundConstantState = sFocusableDrawableState;
return;
}
// Background color to be used for suggestions
var ctx = view.getContext();
var background = new ColorDrawable(getSuggestionBackgroundColor(model, view.getContext()));
// Ripple effect to use when the user interacts with the suggestion.
var ripple =
OmniboxResourceProvider.resolveAttributeToDrawable(
ctx,
model.get(SuggestionCommonProperties.COLOR_SCHEME),
R.attr.selectableItemBackground);
var layer = new LayerDrawable(new Drawable[] {background, ripple});
// Cache the drawable state for faster retrieval.
// See go/omnibox:drawables for more details.
sFocusableDrawableState = layer.getConstantState();
metadata.backgroundConstantState = sFocusableDrawableState;
view.setBackground(layer);
}
/**
* Retrieve the background color to be applied to suggestion.
*
* @param model A property model to look up relevant properties.
* @param ctx Context used to retrieve appropriate color value.
* @return @ColorInt value representing the color to be applied.
*/
public static @ColorInt int getSuggestionBackgroundColor(PropertyModel model, Context ctx) {
return isIncognito(model)
? ctx.getColor(R.color.omnibox_suggestion_bg_incognito)
: OmniboxResourceProvider.getStandardSuggestionBackgroundColor(ctx);
}
/**
* Checks whether cached FocusableDrawableState should be reset.
*
* <p>TODO(ender): Relocate this to appropriate OmniboxResourceManager class.
*
* @param model The model to supply app-driven changes.
* @param view The view to supply additional information, such as UI configuration.
*/
@VisibleForTesting
public static void maybeResetCachedFocusableDrawableState(PropertyModel model, View view) {
// The color theme has changed, or the user opened Incognito window.
// Reset the cached drawable state to prevent using old colors.
var theme = model.get(SuggestionCommonProperties.COLOR_SCHEME);
// The theme change may also originate from the system.
// Be sure we respond to these changes as well.
// This aspect should only be relevant when the theme is APP_DEFAULT.
var isInNightMode = ColorUtils.inNightMode(view.getContext());
if (theme != sFocusableDrawableStateTheme
|| isInNightMode != sFocusableDrawableStateInNightMode) {
sFocusableDrawableState = null;
sFocusableDrawableStateTheme = theme;
sFocusableDrawableStateInNightMode = isInNightMode;
}
}
/** Update image view using supplied drawable state object. */
private static void updateIcon(
ImageView view, OmniboxDrawableState sds, @ColorRes int tintRes) {
view.setVisibility(sds == null ? View.GONE : View.VISIBLE);
if (sds == null) {
// Release any drawable that is still attached to this view to reclaim memory.
view.setImageDrawable(null);
return;
}
ColorStateList tint = null;
if (sds.allowTint) {
tint = AppCompatResources.getColorStateList(view.getContext(), tintRes);
}
view.setImageDrawable(sds.drawable);
ImageViewCompat.setImageTintList(view, tint);
}
/**
* Update the margin for the view.
*
* @param model A property model to look up relevant properties.
* @param view A view that need to be updated.
*/
public static void updateMargin(PropertyModel model, View view) {
ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
if (layoutParams == null) {
layoutParams =
new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
if (layoutParams instanceof MarginLayoutParams) {
((MarginLayoutParams) layoutParams).setMargins(sSideSpacing, 0, sSideSpacing, 0);
}
view.setLayoutParams(layoutParams);
}
public static void resetCachedResources() {
sDimensionsInitialized = false;
sFocusableDrawableState = null;
}
@VisibleForTesting
static void initializeDimensions(Context context) {
Resources resources = context.getResources();
sEdgeSize = resources.getDimensionPixelSize(R.dimen.omnibox_suggestion_24dp_icon_size);
sEdgeSizeLargeIcon =
resources.getDimensionPixelSize(R.dimen.omnibox_suggestion_36dp_icon_size);
sSideSpacing = OmniboxResourceProvider.getSideSpacing(context);
sLargeIconRoundingRadius =
resources.getDimensionPixelSize(R.dimen.omnibox_large_icon_rounding_radius);
sSmallIconRoundingRadius =
resources.getDimensionPixelSize(R.dimen.omnibox_small_icon_rounding_radius);
}
/**
* @return Cached ConstantState for testing.
*/
public static Drawable.ConstantState getFocusableDrawableStateForTesting() {
return sFocusableDrawableState;
}
}