blob: 95ed08d0ee4e4d256569d7e05f7b6e01c9100084 [file] [log] [blame]
// 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.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.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.OmniboxFeatures;
import org.chromium.chrome.browser.omnibox.R;
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.components.browser_ui.widget.RoundedCornerOutlineProvider;
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.
*
* 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 sIconWidthPx;
private static int sPaddingStart;
private static int sPaddingStartLargeIcon;
private static int sPaddingEnd;
private static int sPaddingEndLargeIcon;
private static int sEdgeSize;
private static int sEdgeSizeLargeIcon;
private static int sSideSpacing;
public BaseSuggestionViewBinder(ViewBinder<PropertyModel, T, PropertyKey> contentBinder) {
mContentBinder = contentBinder;
}
@Override
public void bind(PropertyModel model, BaseSuggestionView<T> view, PropertyKey propertyKey) {
if (!sDimensionsInitialized) {
initializeDimensions(view.getContext());
sDimensionsInitialized = true;
}
mContentBinder.bind(model, view.getContentView(), propertyKey);
ActionChipsBinder.bind(model, view.getActionChipsView(), propertyKey);
if (BaseSuggestionViewProperties.ICON == propertyKey) {
updateSuggestionIcon(model, view);
} else if (SuggestionCommonProperties.LAYOUT_DIRECTION == propertyKey) {
ViewCompat.setLayoutDirection(
view, model.get(SuggestionCommonProperties.LAYOUT_DIRECTION));
} else if (SuggestionCommonProperties.COLOR_SCHEME == propertyKey) {
updateColorScheme(model, view);
} else if (DropdownCommonProperties.BG_BOTTOM_CORNER_ROUNDED == propertyKey
|| DropdownCommonProperties.BG_TOP_CORNER_ROUNDED == propertyKey) {
roundSuggestionViewCorners(model, view);
} else if (DropdownCommonProperties.TOP_MARGIN == propertyKey
|| DropdownCommonProperties.BOTTOM_MARGIN == propertyKey) {
updateMargin(model, view);
} 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;
});
}
}
}
/** 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.getSuggestionImageView();
final SuggestionDrawableState sds = model.get(BaseSuggestionViewProperties.ICON);
if (sds != null) {
rciv.setLayoutParams(new SuggestionLayout.LayoutParams(sIconWidthPx,
ViewGroup.LayoutParams.WRAP_CONTENT,
SuggestionLayout.LayoutParams.SuggestionViewType.DECORATION));
int paddingStart = sds.isLarge ? sPaddingStartLargeIcon : sPaddingStart;
int paddingEnd = sds.isLarge ? sPaddingEndLargeIcon : sPaddingEnd;
int edgeSize = sds.isLarge ? sEdgeSizeLargeIcon : sEdgeSize;
rciv.setPadding(paddingStart, 0, paddingEnd, 0);
rciv.setMinimumHeight(edgeSize);
rciv.setClipToOutline(sds.useRoundedCorners);
}
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. Returns an unattached metadata if {@link
* OmniboxFeatures#shouldCacheSuggestionResources} returns false.
*/
private static @NonNull BaseSuggestionViewMetadata ensureViewMetadata(View view) {
if (!OmniboxFeatures.shouldCacheSuggestionResources()) {
return new BaseSuggestionViewMetadata();
}
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).
*
* 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)
: ChromeColors.getSurfaceColor(ctx, R.dimen.omnibox_suggestion_bg_elevation);
}
/**
* Checks whether cached FocusableDrawableState should be reset.
*
* 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, SuggestionDrawableState 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.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}
if (layoutParams instanceof MarginLayoutParams) {
int topSpacing = model.get(DropdownCommonProperties.TOP_MARGIN);
int bottomSpacing = model.get(DropdownCommonProperties.BOTTOM_MARGIN);
((MarginLayoutParams) layoutParams)
.setMargins(sSideSpacing, topSpacing, sSideSpacing, bottomSpacing);
}
view.setLayoutParams(layoutParams);
}
/**
* Round top/bottom suggestion view corners to mark suggestions that begin or end section -- or
* are standalone suggestions.
*
* The rounding mechanism utilizes OutlineProviders to guarantee that focus and selection won't
* leak outside of the rounded edges.
*
* @param model A property model, defining which corners (specifically: corners along which
* edge) should be rounded,
* @param view The view that should receive rounding.
*/
private static void roundSuggestionViewCorners(PropertyModel model, View view) {
var roundTopEdge = model.get(DropdownCommonProperties.BG_TOP_CORNER_ROUNDED);
var roundBottomEdge = model.get(DropdownCommonProperties.BG_BOTTOM_CORNER_ROUNDED);
if (!roundTopEdge && !roundBottomEdge) {
// Note: Suggestion views are re-used. Make sure we don't carry over rounding from
// previous model.
view.setClipToOutline(false);
return;
}
// TODO(crbug.com/1418077): This should be part of BaseSuggestionView.
// Move this once we reconcile Pedals with Base.
var outlineProvider = view.getOutlineProvider();
if (!(outlineProvider instanceof RoundedCornerOutlineProvider)) {
outlineProvider =
new RoundedCornerOutlineProvider(view.getResources().getDimensionPixelSize(
R.dimen.omnibox_suggestion_bg_round_corner_radius));
view.setOutlineProvider(outlineProvider);
}
RoundedCornerOutlineProvider roundedCornerOutlineProvider =
(RoundedCornerOutlineProvider) outlineProvider;
roundedCornerOutlineProvider.setRoundingEdges(true, roundTopEdge, true, roundBottomEdge);
view.setClipToOutline(true);
}
private static void initializeDimensions(Context context) {
boolean showModernizeVisualUpdate =
OmniboxFeatures.shouldShowModernizeVisualUpdate(context);
Resources resources = context.getResources();
sIconWidthPx = resources.getDimensionPixelSize(showModernizeVisualUpdate
? R.dimen.omnibox_suggestion_icon_area_size_modern
: R.dimen.omnibox_suggestion_icon_area_size);
sPaddingStart = showModernizeVisualUpdate
? OmniboxResourceProvider.getIconStartPadding(context)
: resources.getDimensionPixelSize(
R.dimen.omnibox_suggestion_24dp_icon_margin_start);
sPaddingEnd =
resources.getDimensionPixelSize(R.dimen.omnibox_suggestion_24dp_icon_margin_end);
sPaddingStartLargeIcon = OmniboxResourceProvider.getLargeIconStartPadding(context);
sPaddingEndLargeIcon = OmniboxResourceProvider.getLargeIconEndPadding(context);
sEdgeSize = resources.getDimensionPixelSize(R.dimen.omnibox_suggestion_24dp_icon_size);
sEdgeSizeLargeIcon =
resources.getDimensionPixelSize(R.dimen.omnibox_suggestion_36dp_icon_size);
sSideSpacing = OmniboxResourceProvider.getSideSpacing(context);
}
/** @return Cached ConstantState for testing. */
@VisibleForTesting
public static Drawable.ConstantState getFocusableDrawableStateForTesting() {
return sFocusableDrawableState;
}
}