chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/base/SuggestionLayout.java

// 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.suggestions.base;

import android.content.Context;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Px;
import androidx.annotation.VisibleForTesting;

import org.chromium.chrome.browser.omnibox.R;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.omnibox.suggestions.base.SuggestionLayout.LayoutParams.SuggestionViewType;
import org.chromium.components.browser_ui.widget.RoundedCornerOutlineProvider;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * SuggestionLayout is a container aiming to quickly and correctly arrange encompassed items. The
 * operation is similar to that of ConstraintLayout, with the exception that the purpose of every
 * item is known ahead of time. This layout is highly optimized around view types, and bypasses
 * certain measurement calls, where the size of the view is known ahead of time.
 */
class SuggestionLayout extends ViewGroup {
    @VisibleForTesting public final @Px int mDecorationIconWidthPx;
    @VisibleForTesting public final @Px int mLargeDecorationIconWidthPx;
    @VisibleForTesting public final @Px int mContentHeightPx;
    @VisibleForTesting public final @Px int mCompactContentHeightPx;
    @VisibleForTesting public final @NonNull RoundedCornerOutlineProvider mOutlineProvider;
    private final @Px int mActionButtonWidthPx;
    private final @Px int mContentPaddingPx;
    private final @Px int mMinimumContentPadding;
    private boolean mUseLargeDecoration;
    private boolean mShowDecoration;

    /**
     * SuggestionLayout's LayoutParams.
     *
     * <p>Additional parameters define the role of an element..
     */
    public static class LayoutParams extends ViewGroup.LayoutParams {

        @IntDef({
            SuggestionViewType.CONTENT,
            SuggestionViewType.DECORATION,
            SuggestionViewType.ACTION_BUTTON,
            SuggestionViewType.FOOTER
        })
        @Retention(RetentionPolicy.SOURCE)
        /// Defines suggestion building blocks.
        /// The placement of every SuggestionViewType element is as follows:
        ///
        /// +---+------------------+---+---+---+
        /// | 0 | 1                | 2 | 2 | 2 |
        /// +---+------------------+---+---+---+
        /// | 3                                |
        /// +----------------------------------+
        /// | 3                                |
        /// +----------------------------------+
        ///
        /// The lines on the diagram above are for reference purpose to outline the boundaries
        /// of individual suggestion view types.
        ///
        /// Unless otherwise specified, focus ripples through all suggestion views.
        /// To override this behavior, individual views need to override their background.
        public @interface SuggestionViewType {
            /// Main content, encompassing one or more lines of text. Must be horizontally and
            /// vertically resizable.
            /// Only a single CONTENT view is permitted right now.
            int CONTENT = 0;
            /// An image presented on the left hand side of the CONTENT, such as an icon
            /// (magnifying glass, globe), site favicon, solid color or image.
            /// Multiple DECORATION buttons are presently not permitted.
            int DECORATION = 1;
            /// An action button presented on the right hand side of the CONTENT. Buttons are
            /// aligned to the top/end edge of the suggestion, and build towards the beginning
            /// of the content view (left in LTR, or right in RTL layout directions).
            /// Multiple ACTION_BUTTON elements are permitted.
            int ACTION_BUTTON = 2;
            /// FOOTER element is always added at the bottom of the suggestion, and stretches from
            /// the beginning to the end of the entire suggestion view.
            /// Multiple FOOTER elements will be stacked one on top of another.
            int FOOTER = 3;
        }

        /// The role of the associated view in the SuggestionView.
        private final @SuggestionViewType int mSuggestionViewType;
        private final @NonNull Rect mPlacement;
        private final boolean mIsLargeDecoration;

        private LayoutParams(
                int width, int height, @SuggestionViewType int type, boolean isLargeDecoration) {
            super(width, height);
            mPlacement = new Rect();
            mSuggestionViewType = type;
            mIsLargeDecoration = isLargeDecoration;
        }

        /** Create LayoutParams for particular SuggestionViewType. */
        public static LayoutParams forViewType(@SuggestionViewType int type) {
            return new LayoutParams(WRAP_CONTENT, WRAP_CONTENT, type, false);
        }

        /** Create LayoutParams for particular SuggestionViewType. */
        public static LayoutParams forLargeDecorationIcon() {
            return new LayoutParams(
                    WRAP_CONTENT, WRAP_CONTENT, SuggestionViewType.DECORATION, true);
        }

        /**
         * @return The role of the view.
         */
        private @SuggestionViewType int getViewType() {
            return mSuggestionViewType;
        }

        /**
         * @return The placement of the view, relative to Suggestion area start.
         */
        private @NonNull Rect getPlacement() {
            return mPlacement;
        }

        /**
         * Specify the position of the view relative to the SuggestionLayout's Top/Start corner.
         *
         * <p>Placement is LayoutDirection agnostic. Callers should assume that the offsets are
         * relative to the start position of the view, and sizes expand in the direction appropriate
         * for the currently used LayoutDirection.
         *
         * @param start The offset from the start edge (left for LTR layouts, right for RTL layouts)
         *     of the SuggestionLayout.
         * @param top The offset from the top edge of the SuggestionLayout.
         * @param width The width of the view (expands to the right for LTR layouts, and to the left
         *     for RTL layouts).
         * @param height The height of the view.
         */
        private void setPlacement(int start, int top, int width, int height) {
            mPlacement.set(start, top, start + width, top + height);
        }
    }

    public SuggestionLayout(Context context) {
        super(context);

        var res = context.getResources();

        int endSpace = res.getDimensionPixelSize(R.dimen.omnibox_suggestion_end_padding);
        setPaddingRelative(0, 0, endSpace, 0);

        mDecorationIconWidthPx =
                OmniboxResourceProvider.getSuggestionDecorationIconSizeWidth(context);
        mLargeDecorationIconWidthPx =
                context.getResources()
                        .getDimensionPixelSize(R.dimen.omnibox_suggestion_icon_area_size_large);

        mActionButtonWidthPx =
                res.getDimensionPixelSize(R.dimen.omnibox_suggestion_action_button_width);
        mCompactContentHeightPx =
                res.getDimensionPixelSize(R.dimen.omnibox_suggestion_compact_content_height);
        mContentHeightPx = res.getDimensionPixelSize(R.dimen.omnibox_suggestion_content_height);

        mContentPaddingPx = res.getDimensionPixelSize(R.dimen.omnibox_suggestion_content_padding);
        mMinimumContentPadding = res.getDimensionPixelSize(R.dimen.omnibox_simple_card_leadin);

        mOutlineProvider =
                new RoundedCornerOutlineProvider(
                        res.getDimensionPixelSize(
                                R.dimen.omnibox_suggestion_bg_round_corner_radius));
        setOutlineProvider(mOutlineProvider);
        setRoundingEdges(false, false);
    }

    public void setRoundingEdges(boolean roundTopEdge, boolean roundBottomEdge) {
        boolean needUpdate =
                mOutlineProvider.isTopEdgeRounded() != roundTopEdge
                        || mOutlineProvider.isBottomEdgeRounded() != roundBottomEdge;

        if (!needUpdate) return;

        mOutlineProvider.setRoundingEdges(true, roundTopEdge, true, roundBottomEdge);
        setClipToOutline(roundTopEdge || roundBottomEdge);
        // Make sure the view redraws. Otherwise, the on-screen visuals may not reflect our desired
        // rounding effect.
        invalidateOutline();
    }

    @Override
    protected void onMeasure(int widthSpec, int heightSpec) {
        // The only measure spec we know is the WIDTH of the suggestion and padding around the
        // content.
        var suggestionWidthPx =
                MeasureSpec.getSize(widthSpec) - getPaddingLeft() - getPaddingRight();

        // Check to see if and how large of a decoration icon we're going to render
        mUseLargeDecoration = getUseLargeDecoration();
        mShowDecoration = isDecorationShown();
        // First, compute the width of the content area.
        // We know the size of every DECORATION and ACTION_BUTTON, which surround the CONTENT.
        var measuredContentWidthPx = measureContentViewsWidthPx(suggestionWidthPx);

        // Next, compute the height of the CONTENT area. CONTENT may hold multiple lines of wrapped
        // text, such as dictionary entries (type: "define dictionary" for an example entry).
        // As such, these views may have varying heights.
        var measuredContentHeightPx = measureContentViewHeightPx(measuredContentWidthPx);

        // Now that we know how tall the CONTENT area is, apply that measurement to the views
        // surrounding the CONTENT area. This will permit proper content scaling and placement.
        measureDecorationIconAndActionButtons(measuredContentHeightPx);

        // Compute the height of all FOOTER views, such as Action Chips (type: "play dino game"
        // to see example action chip).
        // Multiple footers are permitted and will stack on top of each other.
        // Note that, unlike CONTENT, FOOTER views are not surrounded by DECORATION or ACTION
        // BUTTON views.
        var measuredFooterHeightPx = measureFooterViewsHeightPx(suggestionWidthPx);

        // Finally, compute the placements of every view relative to the START of the view,
        // including our own.
        // Note that while START means different things for RTL and LTR layout directions, these
        // offsets are RELATIVE, and will expand in appropriate direction during Layout phase.
        applySuggestionViewPlacements(
                suggestionWidthPx, measuredContentWidthPx, measuredContentHeightPx);
        setMeasuredDimension(
                widthSpec,
                MeasureSpec.makeMeasureSpec(
                        measuredContentHeightPx
                                + measuredFooterHeightPx
                                + getPaddingTop()
                                + getPaddingBottom(),
                        MeasureSpec.EXACTLY));
    }

    @VisibleForTesting
    boolean getUseLargeDecoration() {
        for (int index = 0; index < getChildCount(); ++index) {
            var view = getChildAt(index);
            if (view.getVisibility() == GONE) continue;

            var params = (LayoutParams) view.getLayoutParams();
            if (params.getViewType() == SuggestionViewType.DECORATION) {
                return params.mIsLargeDecoration;
            }
        }
        return false;
    }

    /**
     * Returns whether the decoration view is visible or not. Also returns true if there is no
     * decoration view present.
     */
    private boolean isDecorationShown() {
        // Default to true so that we reserve space for alignment purposes even when there is no
        // decoration icon.
        var decorationShown = true;
        for (int index = 0; index < getChildCount(); ++index) {
            var view = getChildAt(index);

            var params = (LayoutParams) view.getLayoutParams();
            if (params.getViewType() == SuggestionViewType.DECORATION) {
                decorationShown = view.getVisibility() == VISIBLE;
                break;
            }
        }

        return decorationShown;
    }

    private int getDecorationIconWidthPx() {
        return mUseLargeDecoration ? mLargeDecorationIconWidthPx : mDecorationIconWidthPx;
    }

    private int getContentStart() {
        return mShowDecoration ? getDecorationIconWidthPx() : mMinimumContentPadding;
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        var topPx = getPaddingTop();
        var layoutDirectionRTL = getLayoutDirection() == LAYOUT_DIRECTION_RTL;
        var startPx = layoutDirectionRTL ? right - left - getPaddingRight() : getPaddingLeft();

        for (int index = 0; index < getChildCount(); ++index) {
            var view = getChildAt(index);
            if (view.getVisibility() == GONE) continue;

            var params = (LayoutParams) view.getLayoutParams();

            // All our placements are relative to the view START:
            // - the LEFT edge, when the layout direction is LTR,
            // - the RIGHT edge, when the layout direction is RTL.
            // and grow in the direction appropriate for the layout direction.
            // - to the RIGHT, when the layout direction is LTR,
            // - to the LEFT, when the layout direction is RTL.
            var placement = params.getPlacement();
            if (layoutDirectionRTL) {
                view.layout(
                        startPx - placement.right,
                        topPx + placement.top,
                        startPx - placement.left,
                        topPx + placement.bottom);
            } else {
                view.layout(
                        startPx + placement.left,
                        topPx + placement.top,
                        startPx + placement.right,
                        topPx + placement.bottom);
            }
        }
    }

    /**
     * Given the SuggestionView width, compute the width available to the CONTENT views. CONTENT
     * views are surrounded by DECORATION and ACTION_BUTTON view types.
     *
     * <p>NOTE: this is invoked directly by the measure cycle. Do not allocate memory, and avoid any
     * calls that trigger measurement invalidation and restart.
     *
     * @param suggestionWidthPx The width of the suggestion content area
     * @return The computed width of the CONTENT views.
     */
    private @Px int measureContentViewsWidthPx(@Px int suggestionWidthPx) {
        // Reserve space for the decoration view if it's present. Otherwise, ensure a minimal
        // padding.
        var contentWidthPx = suggestionWidthPx - getContentStart();

        // Measure all other views surrounding the CONTENT area. Currently these are only
        // ACTION_BUTTONs.
        for (int index = 0; index < getChildCount(); ++index) {
            var view = getChildAt(index);
            if (view.getVisibility() == GONE) continue;

            var params = (LayoutParams) view.getLayoutParams();
            if (params.getViewType() == LayoutParams.SuggestionViewType.ACTION_BUTTON) {
                contentWidthPx -= mActionButtonWidthPx;
            }
        }
        return contentWidthPx;
    }

    /**
     * Given the CONTENT width, compute the height of the CONTENT view.
     *
     * <p>NOTE: this is invoked directly by the measure cycle. Do not allocate memory, and avoid any
     * calls that trigger measurement invalidation and restart.
     *
     * @param contentWidthPx The width of the CONTENT view.
     * @return The measured height of the CONTENT view, no less than the minimum size.
     */
    private @Px int measureContentViewHeightPx(@Px int contentWidthPx) {
        int contentHeightPx = 0;
        boolean hasFooter = false;
        View contentView = null;

        for (int index = 0; index < getChildCount(); ++index) {
            var view = getChildAt(index);
            if (view.getVisibility() == GONE) continue;

            var params = (LayoutParams) view.getLayoutParams();
            if (params.getViewType() == LayoutParams.SuggestionViewType.CONTENT) {
                assert contentHeightPx == 0 : "Content view already defined";
                // Content views' width is constrained by how much space the decoration views
                // allocate. These views may, as a result, wrap around to one or more extra lines of
                // text.
                contentView = view;
                view.measure(
                        MeasureSpec.makeMeasureSpec(contentWidthPx, MeasureSpec.EXACTLY),
                        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
                contentHeightPx = view.getMeasuredHeight();
            } else if (params.getViewType() == LayoutParams.SuggestionViewType.FOOTER) {
                hasFooter = true;
            }
        }

        // It is possible for the measured CONTENT area to be smaller than our minimum
        // suggestion height. Apply necessary corrections here. We currently expect the
        // CONTENT view to properly utilize the LAYOUT GRAVITY to position its content
        // around the center, if its measured height is smaller than our minimum.
        assert contentView != null : "No content views";

        // Pad suggestion around to guarantee appropriate spacing around suggestions.
        // Modernized UI present their content in distinc blocks, and the extra space
        // does not break visually the relationship between the content and footer parts.
        contentHeightPx += mContentPaddingPx;

        // Guarantee that the suggestion height meets our required minimum tap target size.
        var height =
                Math.max(contentHeightPx, hasFooter ? mCompactContentHeightPx : mContentHeightPx);
        // Some views (e.g. TextView) won't render correctly unless measure specs are explicitly
        // supplied, failing to properly center the content.
        contentView.measure(
                MeasureSpec.makeMeasureSpec(contentWidthPx, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
        return height;
    }

    /**
     * Given the SuggestionView width, compute the height of all FOOTER views.
     *
     * <p>NOTE: this is invoked directly by the measure cycle. Do not allocate memory, and avoid any
     * calls that trigger measurement invalidation and restart.
     *
     * @param suggestionWidthPx The width of the suggestion content area
     * @return The cumulative height of the FOOTER views.
     */
    private @Px int measureFooterViewsHeightPx(@Px int suggestionWidthPx) {
        int footerHeightPx = 0;

        for (int index = 0; index < getChildCount(); ++index) {
            var view = getChildAt(index);
            if (view.getVisibility() == GONE) continue;

            var params = (LayoutParams) view.getLayoutParams();
            if (params.getViewType() == LayoutParams.SuggestionViewType.FOOTER) {
                // FOOTER views stretch from one end of the Suggestion view to the other.
                // Unlike CONTENT views, FOOTERs are not surrounded by DECORATION or ACTION_BUTTON
                // views.
                view.measure(
                        MeasureSpec.makeMeasureSpec(suggestionWidthPx, MeasureSpec.EXACTLY),
                        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
                footerHeightPx += view.getMeasuredHeight();
            }
        }

        return footerHeightPx;
    }

    /**
     * Given the CONTENT area dimensions, apply measurements and placement of all elements
     * surrounding the CONTENT view (currently: DECORATION and ACTION_BUTTONs).
     *
     * <p>NOTE: this is invoked directly by the measure cycle. Do not allocate memory, and avoid any
     * calls that trigger measurement invalidation and restart.
     *
     * @param contentHeightPx The height of the CONTENT area.
     */
    private void measureDecorationIconAndActionButtons(@Px int contentHeightPx) {
        var contentHeightSpec = MeasureSpec.makeMeasureSpec(contentHeightPx, MeasureSpec.AT_MOST);

        for (int index = 0; index < getChildCount(); ++index) {
            var view = getChildAt(index);
            if (view.getVisibility() == GONE) continue;

            // Capture the measure spec of the area available to DECORATION and ACTION_BUTTONs.
            // Note that at this stage everything else has already been measured.
            var viewWidthSpec = 0;
            var params = (LayoutParams) view.getLayoutParams();
            if (params.getViewType() == LayoutParams.SuggestionViewType.DECORATION) {
                viewWidthSpec =
                        getChildMeasureSpec(
                                MeasureSpec.makeMeasureSpec(
                                        getDecorationIconWidthPx(), MeasureSpec.AT_MOST),
                                0,
                                params.width);
            } else if (params.getViewType() == LayoutParams.SuggestionViewType.ACTION_BUTTON) {
                viewWidthSpec =
                        MeasureSpec.makeMeasureSpec(mActionButtonWidthPx, MeasureSpec.EXACTLY);
            } else {
                continue;
            }

            view.measure(viewWidthSpec, getChildMeasureSpec(contentHeightSpec, 0, params.height));
        }
    }

    /**
     * Apply placements to all the views.
     *
     * <p>The views are placed linearly, offering offset from the START of the encompassing view's
     * padded area. The concept of LayoutDirection is irrelevant at this point for simplicity, and
     * can be assumed "any": the placements computed here will expand views
     *
     * <ul>
     *   <li>to the RIGHT, when the layout direction is LTR, and
     *   <li>to the LEFT, when the layout direction ir RTL.
     * </ul>
     *
     * @param suggestionWidthPx The width of the Suggestion area.
     * @param contentViewsWidth The width of the CONTENT area.
     * @param contentViewHeight The height of the CONTENT area.
     */
    private void applySuggestionViewPlacements(
            @Px int suggestionWidthPx, @Px int contentViewsWidth, @Px int contentViewHeight) {
        int contentStart = getContentStart();
        var nextActionButtonStartPx = contentStart + contentViewsWidth;
        var nextFooterViewTopPx = contentViewHeight;

        for (int index = 0; index < getChildCount(); ++index) {
            var view = getChildAt(index);
            if (view.getVisibility() == GONE) continue;

            var params = (LayoutParams) view.getLayoutParams();
            switch (params.getViewType()) {
                case LayoutParams.SuggestionViewType.DECORATION:
                    // DECORATION space is square, but the image inside does not have to be.
                    // Retrieve the information about target image height and center it with the
                    // CONTENT.
                    var decorationWidth = view.getMeasuredWidth();
                    var decorationHeight = view.getMeasuredHeight();
                    var decorationLeft = (getDecorationIconWidthPx() - decorationWidth) / 2;
                    var decorationTop = (contentViewHeight - decorationHeight) / 2;
                    params.setPlacement(
                            decorationLeft, decorationTop, decorationWidth, decorationHeight);
                    break;

                case LayoutParams.SuggestionViewType.CONTENT:
                    params.setPlacement(contentStart, 0, contentViewsWidth, contentViewHeight);
                    break;

                case LayoutParams.SuggestionViewType.ACTION_BUTTON:
                    params.setPlacement(
                            nextActionButtonStartPx, 0, mActionButtonWidthPx, contentViewHeight);
                    // Horizontally line up ACTION_BUTTONs.
                    nextActionButtonStartPx += mActionButtonWidthPx;
                    break;

                case LayoutParams.SuggestionViewType.FOOTER:
                    var footerViewHeight = view.getMeasuredHeight();
                    params.setPlacement(
                            0, nextFooterViewTopPx, suggestionWidthPx, footerViewHeight);
                    // Vertically Stack FOOTERs.
                    nextFooterViewTopPx += footerViewHeight;
                    break;
            }
        }
    }
}