chromium/components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/chips/ChipView.java

// 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.components.browser_ui.widget.chips;

import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;

import androidx.annotation.AttrRes;
import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes;
import androidx.annotation.Px;
import androidx.annotation.StyleRes;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.widget.ImageViewCompat;

import org.chromium.components.browser_ui.widget.R;
import org.chromium.ui.widget.ChromeImageView;
import org.chromium.ui.widget.LoadingView;
import org.chromium.ui.widget.RectProvider;
import org.chromium.ui.widget.RippleBackgroundHelper;
import org.chromium.ui.widget.ViewRectProvider;

/**
 * The view responsible for displaying a material chip. The chip has the following components:
 *
 * <ul>
 *   <li>A primary text to be shown.
 *   <li>An optional start icon that can be rounded as well.
 *   <li>An optional secondary text view that is shown to the right of the primary text view.
 *   <li>An optional remove icon at the end, intended for use with input chips.
 *   <li>An optional boolean (solidColorChip) to remove the default chip border.
 *   <li>An optional boolean (allowMultipleLines) to avoid longer text strings to wrap to a second
 *       line.
 *   <li>An optional boolean (showLoadingView) to show a loading view in place of the start icon.
 * </ul>
 */
public class ChipView extends LinearLayout {
    /** An id to use for {@link #setIcon(int, boolean)} when there is no icon on the chip. */
    public static final int INVALID_ICON_ID = -1;

    private static final int MAX_LINES = 2;

    private final RippleBackgroundHelper mRippleBackgroundHelper;
    private final AppCompatTextView mPrimaryText;
    private final ChromeImageView mStartIcon;
    private final boolean mUseRoundedStartIcon;
    private final LoadingView mLoadingView;
    private final @StyleRes int mSecondaryTextAppearanceId;
    private final int mEndIconWidth;
    private final int mEndIconHeight;
    private final int mEndIconStartPadding;
    private final int mEndIconEndPadding;
    private final int mCornerRadius;

    private ViewGroup mEndIconWrapper;
    private AppCompatTextView mSecondaryText;
    private int mMaxWidth = Integer.MAX_VALUE;
    private boolean mTintWithTextColor;

    /** Constructor for applying a theme overlay. */
    public ChipView(Context context, @StyleRes int themeOverlay) {
        this(new ContextThemeWrapper(context, themeOverlay), null, R.attr.chipStyle, 0);
    }

    /** Constructor for inflating from XML. */
    public ChipView(Context context, AttributeSet attrs) {
        this(
                new ContextThemeWrapper(context, R.style.SuggestionChipThemeOverlay),
                attrs,
                R.attr.chipStyle,
                0);
    }

    /** Constructor for base classes and programmatic creation. */
    public ChipView(
            Context context,
            AttributeSet attrs,
            @AttrRes int defStyleAttr,
            @StyleRes int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);

        TypedArray a =
                getContext()
                        .obtainStyledAttributes(
                                attrs, R.styleable.ChipView, defStyleAttr, defStyleRes);

        boolean extendLateralPadding =
                a.getBoolean(R.styleable.ChipView_extendLateralPadding, false);
        boolean reduceEndPadding = a.getBoolean(R.styleable.ChipView_reduceEndPadding, false);

        @Px
        int leadingElementPadding =
                extendLateralPadding
                        ? getResources()
                                .getDimensionPixelSize(
                                        R.dimen.chip_element_extended_leading_padding)
                        : getResources()
                                .getDimensionPixelSize(R.dimen.chip_element_leading_padding);

        // End padding is already longer so no need to adjust in the 'extendLateralPadding' case.
        @Px
        int endPadding =
                reduceEndPadding
                        ? getResources().getDimensionPixelSize(R.dimen.chip_reduced_end_padding)
                        : getResources().getDimensionPixelSize(R.dimen.chip_end_padding);

        mEndIconStartPadding =
                extendLateralPadding
                        ? getResources()
                                .getDimensionPixelSize(R.dimen.chip_end_icon_extended_margin_start)
                        : getResources().getDimensionPixelSize(R.dimen.chip_end_icon_margin_start);

        mEndIconEndPadding =
                extendLateralPadding
                        ? getResources()
                                .getDimensionPixelSize(
                                        R.dimen.chip_extended_end_padding_with_end_icon)
                        : getResources()
                                .getDimensionPixelSize(R.dimen.chip_end_padding_with_end_icon);

        boolean solidColorChip = a.getBoolean(R.styleable.ChipView_solidColorChip, false);
        int chipBorderWidthId =
                solidColorChip ? R.dimen.chip_solid_border_width : R.dimen.chip_border_width;
        int chipColorId =
                a.getResourceId(R.styleable.ChipView_chipColor, R.color.chip_background_color);
        int chipStateLayerColorId =
                a.getResourceId(
                        R.styleable.ChipView_chipStateLayerColor, R.color.chip_state_layer_color);
        int rippleColorId =
                a.getResourceId(R.styleable.ChipView_rippleColor, R.color.chip_ripple_color);
        int chipStrokeColorId =
                a.getResourceId(R.styleable.ChipView_chipStrokeColor, R.color.chip_stroke_color);
        mCornerRadius =
                a.getDimensionPixelSize(
                        R.styleable.ChipView_cornerRadius,
                        getContext()
                                .getResources()
                                .getDimensionPixelSize(R.dimen.chip_corner_radius));
        int iconWidth =
                a.getDimensionPixelSize(
                        R.styleable.ChipView_iconWidth,
                        getResources().getDimensionPixelSize(R.dimen.chip_icon_size));
        int iconHeight =
                a.getDimensionPixelSize(
                        R.styleable.ChipView_iconHeight,
                        getResources().getDimensionPixelSize(R.dimen.chip_icon_size));
        mUseRoundedStartIcon = a.getBoolean(R.styleable.ChipView_useRoundedIcon, false);
        int primaryTextAppearance =
                a.getResourceId(
                        R.styleable.ChipView_primaryTextAppearance,
                        R.style.TextAppearance_ChipText);

        mEndIconWidth =
                a.getDimensionPixelSize(
                        R.styleable.ChipView_endIconWidth,
                        getResources().getDimensionPixelSize(R.dimen.chip_icon_size));
        mEndIconHeight =
                a.getDimensionPixelSize(
                        R.styleable.ChipView_endIconHeight,
                        getResources().getDimensionPixelSize(R.dimen.chip_icon_size));
        mSecondaryTextAppearanceId =
                a.getResourceId(
                        R.styleable.ChipView_secondaryTextAppearance,
                        R.style.TextAppearance_ChipText);
        int verticalInset =
                a.getDimensionPixelSize(
                        R.styleable.ChipView_verticalInset,
                        getResources().getDimensionPixelSize(R.dimen.chip_bg_vertical_inset));
        boolean allowMultipleLines = a.getBoolean(R.styleable.ChipView_allowMultipleLines, false);
        int minMultilineVerticalTextPadding =
                a.getDimensionPixelSize(
                        R.styleable.ChipView_multiLineVerticalPadding,
                        getResources()
                                .getDimensionPixelSize(
                                        R.dimen.chip_text_multiline_vertical_padding));
        boolean textAlignStart = a.getBoolean(R.styleable.ChipView_textAlignStart, false);
        boolean reduceTextStartPadding =
                a.getBoolean(R.styleable.ChipView_reduceTextStartPadding, false);
        a.recycle();

        mStartIcon = new ChromeImageView(getContext());
        mStartIcon.setLayoutParams(new LayoutParams(iconWidth, iconHeight));
        addView(mStartIcon);

        if (mUseRoundedStartIcon) {
            int chipHeight = getResources().getDimensionPixelOffset(R.dimen.chip_default_height);
            leadingElementPadding = (chipHeight - iconHeight) / 2;
        }

        int loadingViewSize = getResources().getDimensionPixelSize(R.dimen.chip_loading_view_size);
        int loadingViewHeightPadding = (iconHeight - loadingViewSize) / 2;
        int loadingViewWidthPadding = (iconWidth - loadingViewSize) / 2;
        mLoadingView = new LoadingView(getContext());
        mLoadingView.setVisibility(GONE);
        mLoadingView.setIndeterminateTintList(
                ColorStateList.valueOf(
                        getContext().getColor(R.color.default_icon_color_accent1_baseline)));
        mLoadingView.setPaddingRelative(
                loadingViewWidthPadding,
                loadingViewHeightPadding,
                loadingViewWidthPadding,
                loadingViewHeightPadding);
        addView(mLoadingView, new LayoutParams(iconWidth, iconHeight));

        // Setting this enforces 16dp padding at the end and 8dp at the start (unless overridden).
        // For text, the start padding needs to be 16dp which is why a ChipTextView contributes the
        // remaining 8dp.
        this.setPaddingRelative(leadingElementPadding, 0, endPadding, 0);

        mPrimaryText =
                new AppCompatTextView(new ContextThemeWrapper(getContext(), R.style.ChipTextView));
        mPrimaryText.setTextAppearance(primaryTextAppearance);

        // If false fall back to single line defined in XML styles.
        if (allowMultipleLines) {
            mPrimaryText.setMaxLines(MAX_LINES);
            // TODO(benwgold): Test for non multiline chips to see if 4dp vertical padding can be
            // safely applied to all chips without affecting styling.
            mPrimaryText.setPaddingRelative(
                    mPrimaryText.getPaddingStart(),
                    minMultilineVerticalTextPadding,
                    mPrimaryText.getPaddingEnd(),
                    minMultilineVerticalTextPadding);
        }
        if (textAlignStart) {
            // Default of 'center' is defined in the ChipTextView style.
            mPrimaryText.setTextAlignment((View.TEXT_ALIGNMENT_VIEW_START));
        }
        if (reduceTextStartPadding) {
            mPrimaryText.setPaddingRelative(
                    getResources().getDimensionPixelSize(R.dimen.chip_text_reduced_leading_padding),
                    mPrimaryText.getPaddingTop(),
                    mPrimaryText.getPaddingEnd(),
                    mPrimaryText.getPaddingBottom());
        }
        addView(mPrimaryText);

        // Reset icon and background:
        mRippleBackgroundHelper =
                new RippleBackgroundHelper(
                        this,
                        chipColorId,
                        chipStateLayerColorId,
                        rippleColorId,
                        mCornerRadius,
                        chipStrokeColorId,
                        chipBorderWidthId,
                        verticalInset);
        setIcon(INVALID_ICON_ID, false);
    }

    /**
     * Unlike setSelected, setEnabled doesn't properly propagate the new state to its subcomponents.
     * Enforce this so ColorStateLists used for the text appearance apply as intended.
     *
     * @param enabled The new enabled state for the chip view and the TextViews owned by it.
     */
    @Override
    public void setEnabled(boolean enabled) {
        super.setEnabled(enabled);
        getPrimaryTextView().setEnabled(enabled);
        mStartIcon.setEnabled(enabled);
        if (mSecondaryText != null) mSecondaryText.setEnabled(enabled);
    }

    /**
     * Sets the icon at the start of the chip view.
     *
     * @param icon The resource id pointing to the icon.
     */
    public void setIcon(@DrawableRes int icon, boolean tintWithTextColor) {
        if (icon == INVALID_ICON_ID) {
            mStartIcon.setVisibility(ViewGroup.GONE);
            return;
        }

        mStartIcon.setVisibility(ViewGroup.VISIBLE);
        mStartIcon.setImageResource(icon);
        setTint(tintWithTextColor);
    }

    /**
     * Sets the icon at the start of the chip view.
     *
     * @param drawable Drawable to display.
     */
    public void setIcon(Drawable drawable, boolean tintWithTextColor) {
        if (drawable == null) {
            mStartIcon.setVisibility(ViewGroup.GONE);
            return;
        }

        mStartIcon.setVisibility(ViewGroup.VISIBLE);
        mStartIcon.setImageDrawable(drawable);
        setTint(tintWithTextColor);
    }

    /**
     * Shows a {@link LoadingView} at the start of the chip view. This replaces the start icon.
     *
     * @param loadingViewObserver A {@link LoadingView.Observer} to add to the LoadingView.
     */
    public void showLoadingView(LoadingView.Observer loadingViewObserver) {
        mLoadingView.addObserver(
                new LoadingView.Observer() {
                    @Override
                    public void onShowLoadingUIComplete() {
                        mStartIcon.setVisibility(GONE);
                    }

                    @Override
                    public void onHideLoadingUIComplete() {
                        mStartIcon.setVisibility(VISIBLE);
                    }
                });
        mLoadingView.addObserver(loadingViewObserver);
        mLoadingView.showLoadingUI();
    }

    /**
     * Hides the {@link LoadingView} at the start of the chip view.
     *
     * @param loadingViewObserver A {@link LoadingView.Observer} to add to the LoadingView.
     */
    public void hideLoadingView(LoadingView.Observer loadingViewObserver) {
        mLoadingView.addObserver(loadingViewObserver);
        mLoadingView.hideLoadingUI();
    }

    /** Adds a remove icon (X button) at the trailing end of the chip next to the primary text. */
    public void addRemoveIcon() {
        if (mEndIconWrapper != null) return;

        ChromeImageView endIcon = new ChromeImageView(getContext());
        endIcon.setImageResource(R.drawable.btn_close);
        ImageViewCompat.setImageTintList(endIcon, mPrimaryText.getTextColors());

        // Adding a wrapper view around the X icon to make the touch target larger, which would
        // cover the start and end margin for the X icon, and full height of the chip.
        mEndIconWrapper = new FrameLayout(getContext());
        mEndIconWrapper.setId(R.id.chip_cancel_btn);

        FrameLayout.LayoutParams layoutParams =
                new FrameLayout.LayoutParams(mEndIconWidth, mEndIconHeight);
        layoutParams.setMarginStart(mEndIconStartPadding);
        layoutParams.setMarginEnd(mEndIconEndPadding);
        layoutParams.gravity = Gravity.CENTER_VERTICAL;
        mEndIconWrapper.addView(endIcon, layoutParams);
        addView(
                mEndIconWrapper,
                new LayoutParams(
                        ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));

        // Remove the end padding from the chip to make X icon touch target extend till the end of
        // the chip.
        this.setPaddingRelative(getPaddingStart(), getPaddingTop(), 0, getPaddingBottom());
    }

    /** Adds a dropdown icon at the trailing end of the chip next to the primary text. */
    public void addDropdownIcon() {
        if (mEndIconWrapper != null) return;

        ChromeImageView endIcon = new ChromeImageView(getContext());
        endIcon.setImageResource(R.drawable.mtrl_dropdown_arrow);
        ImageViewCompat.setImageTintList(endIcon, mPrimaryText.getTextColors());

        mEndIconWrapper = new FrameLayout(getContext());

        FrameLayout.LayoutParams layoutParams =
                new FrameLayout.LayoutParams(mEndIconWidth, mEndIconHeight);
        layoutParams.setMarginStart(mEndIconStartPadding);
        layoutParams.setMarginEnd(mEndIconEndPadding);
        layoutParams.gravity = Gravity.CENTER_VERTICAL;
        mEndIconWrapper.addView(endIcon, layoutParams);
        addView(
                mEndIconWrapper,
                new LayoutParams(
                        ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));

        // Remove the end padding from the chip to make X icon touch target extend till the end of
        // the chip.
        this.setPaddingRelative(getPaddingStart(), getPaddingTop(), 0, getPaddingBottom());
    }

    /**
     * Sets a {@link android.view.View.OnClickListener} for the remove icon. {@link
     * ChipView#addRemoveIcon()} must be called prior to this method.
     *
     * @param listener The listener to be invoked on click events.
     */
    public void setRemoveIconClickListener(OnClickListener listener) {
        mEndIconWrapper.setOnClickListener(listener);
        String chipText = mPrimaryText.getText().toString();
        assert !TextUtils.isEmpty(chipText);
        mEndIconWrapper.setContentDescription(
                mPrimaryText
                        .getContext()
                        .getString(R.string.chip_remove_icon_content_description, chipText));
    }

    /**
     * Returns the {@link TextView} that contains the label of the chip.
     *
     * @return A {@link TextView}.
     */
    public TextView getPrimaryTextView() {
        return mPrimaryText;
    }

    /**
     * Returns the {@link TextView} that contains the secondary label of the chip. If it wasn't used
     * until now, this creates the view.
     *
     * @return A {@link TextView}.
     */
    public TextView getSecondaryTextView() {
        if (mSecondaryText == null) {
            mSecondaryText =
                    new AppCompatTextView(
                            new ContextThemeWrapper(getContext(), R.style.ChipTextView));
            mSecondaryText.setTextAppearance(mSecondaryTextAppearanceId);
            // Ensure that basic state changes are aligned with the ChipView. They update
            // automatically once the view is part of the hierarchy.
            mSecondaryText.setSelected(isSelected());
            mSecondaryText.setEnabled(isEnabled());
            addView(mSecondaryText);
        }
        return mSecondaryText;
    }

    /**
     * Returns the {@link RectProvider} that contains the start icon for the chip view.
     *
     * @return A {@link RectProvider}
     */
    public RectProvider getStartIconViewRect() {
        return new ViewRectProvider(mStartIcon);
    }

    /**
     * Sets the correct tinting on the Chip's image view.
     *
     * @param tintWithTextColor If true then the image view will be tinted with the primary text
     *     color. If not, the tint will be cleared.
     */
    private void setTint(boolean tintWithTextColor) {
        mTintWithTextColor = tintWithTextColor;
        if (mPrimaryText.getTextColors() != null && tintWithTextColor) {
            ImageViewCompat.setImageTintList(mStartIcon, mPrimaryText.getTextColors());
        } else {
            ImageViewCompat.setImageTintList(mStartIcon, null);
        }
    }

    /**
     * Sets border around the chip. If width is zero, then no border is drawn.
     *
     * @param width of the border in pixels.
     * @param color of the border.
     */
    public void setBorder(int width, @ColorInt int color) {
        mRippleBackgroundHelper.setBorder(width, color);
    }

    @Override
    public void setBackgroundColor(@ColorInt int color) {
        mRippleBackgroundHelper.setBackgroundColor(color);
    }

    @Override
    public void setBackgroundTintList(ColorStateList color) {
        mRippleBackgroundHelper.setBackgroundColor(color);
    }

    /** @return The corner radius in pixels of this ChipView. */
    public @Px int getCornerRadius() {
        return mCornerRadius;
    }

    /**
     * TODO (crbug.com/1376691): Set a constant minimum width for the chips. The chips must always
     * display some text. Sets the maximum width of the chip. This is achieved by resizing the
     * primary text view. The primary text is either truncated or completely removed depending on
     * the space available after all other chip contents are accounted for. After the primary text
     * gets removed, the secondary text is truncated. Note: This method can cause additional
     * measure/layout passes and could impact performance.
     *
     * @param maxWidth of the chip in px.
     */
    public void setMaxWidth(int maxWidth) {
        mMaxWidth = maxWidth;
    }

    /**
     * Another approach is to override the {@link LinearLayout#onLayout()} which doesn't require an
     * additional measure pass at the end. Performance wise they are comparable.
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        // If the chip width exceeds the maximum allowed size, resize the contents to respect the
        // width constraint.
        if (getMeasuredWidth() > mMaxWidth) {
            int newPrimaryTextWidth =
                    mMaxWidth
                            - getPaddingLeft()
                            - getPaddingRight()
                            - ((mStartIcon != null && mStartIcon.getVisibility() != GONE)
                                    ? mStartIcon.getMeasuredWidth()
                                    : 0)
                            - ((mSecondaryText != null && mSecondaryText.getVisibility() != GONE)
                                    ? mSecondaryText.getMeasuredWidth()
                                    : 0);
            // TODO (crbug.com/1376691): The primary text must be at least a few pixels wide, else
            // only the ellipses will be visible.
            // If there is space for displaying the {@link mPrimaryText}, adjust it's size, and add
            // trailing ellipses. If not, check if the secondary text exists. If it does, remove the
            // primary text, else do not width constrain the chip. The chip should ALWAYS display
            // some text.
            if (newPrimaryTextWidth > 0) {
                mPrimaryText.setMaxWidth(newPrimaryTextWidth);
                mPrimaryText.setEllipsize(TextUtils.TruncateAt.END);
            } else if (mSecondaryText != null && mSecondaryText.getVisibility() != GONE) {
                mPrimaryText.setVisibility(GONE);
            } else {
                return;
            }
            super.onMeasure(
                    MeasureSpec.makeMeasureSpec(mMaxWidth, MeasureSpec.EXACTLY), heightMeasureSpec);
        }
    }

    @Override
    public boolean isFocused() {
        // When the selection does not follow focus, we still want to properly reflect the user
        // selection by highlighting the chip.
        // An example where this happens is: the user interacts with the Omnibox, and the typed
        // query triggers an Action chip to be shown.
        // These chips can be navigated to using physical keyboard (arrow keys to select
        // corresponding suggestion, tab to activate the chip).
        // At this time the Omnibox continues to retain focus, but Chip should be highlighted, as
        // pressing <Enter> on the keyboard will activate the Chip.
        // Make sure the highlight is properly reflected.
        return super.isFocused() || isSelected();
    }
}