chromium/chrome/android/java/src/org/chromium/chrome/browser/compositor/bottombar/contextualsearch/RelatedSearchesControl.java

// Copyright 2021 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.compositor.bottombar.contextualsearch;

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

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;

import org.chromium.base.Callback;
import org.chromium.base.MathUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanelInflater;
import org.chromium.chrome.browser.compositor.bottombar.contextualsearch.ContextualSearchPanel.RelatedSearchesSectionHost;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchUma;
import org.chromium.chrome.browser.contextualsearch.RelatedSearchesUma;
import org.chromium.components.browser_ui.widget.chips.ChipProperties;
import org.chromium.components.browser_ui.widget.chips.ChipsCoordinator;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.base.ViewUtils;
import org.chromium.ui.modelutil.MVCListAdapter.ListItem;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.resources.dynamics.DynamicResourceLoader;

import java.util.List;

/**
 * Controls a section of the Panel that provides Related Searches chips.
 * This class is implemented along the lines of {@code ContextualSearchPanelHelp}.
 * TODO(donnd): consider further extracting the common pattern used for this class
 * and the Help and Promo classes.
 */
public class RelatedSearchesControl {
    private static final int INVALID_VIEW_ID = 0;
    private static final int NO_SELECTED_CHIP = -1;

    /**
     * In the carousel UI, the first chip is the default search, so the related search start from
     * the index 1.
     */
    public static final int INDEX_OF_THE_FIRST_RELATED_SEARCHES = 1;

    /** The Android {@link Context} used to inflate the View. */
    private final Context mContext;

    /** The container View used to inflate the View. */
    private final ViewGroup mViewContainer;

    /** The resource loader that will handle the snapshot capturing. */
    private final DynamicResourceLoader mResourceLoader;

    private final OverlayPanel mOverlayPanel;

    /** The pixel density. */
    private final float mDpToPx;

    /** The chip models being displayed for related searches. */
    private final ModelList mChips;

    /**
     * The inflated View, or {@code null} if the associated Feature is not enabled,
     * or {@link #destroy} has been called.
     */
    @Nullable private RelatedSearchesControlView mControlView;

    /** The query suggestions for this feature, or {@code null} if we don't have any. */
    private @Nullable List<String> mRelatedSearchesSuggestions;

    /** Whether the view is visible. */
    private boolean mIsVisible;

    /** The height of the view in pixels. */
    private float mHeightPx;

    /** The height of the content in pixels. */
    private float mContentHeightPx;

    /** Whether the view is showing. */
    private boolean mIsShowingView;

    /** The Y position of the view. */
    private float mViewY;

    /** The reference to the host for this part of the panel (callbacks to the Panel). */
    private RelatedSearchesSectionHost mPanelSectionHost;

    /** The number of chips that have been selected so far. */
    private int mChipsSelected;

    /** Whether any suggestions were actually shown to the user. */
    private boolean mDidShowAnySuggestions;

    /** Which chip is selected (if any) */
    private int mSelectedChip = NO_SELECTED_CHIP;

    /** Whether the carousel is scrolled. */
    private boolean mScrolled;

    /**
     * @param panel             The panel.
     * @param panelSectionHost  A reference to the host of this panel section for notifications.
     * @param context           The Android Context used to inflate the View.
     * @param container         The container View used to inflate the View.
     * @param resourceLoader    The resource loader that will handle the snapshot capturing.
     */
    RelatedSearchesControl(
            OverlayPanel panel,
            RelatedSearchesSectionHost panelSectionHost,
            Context context,
            ViewGroup container,
            DynamicResourceLoader resourceLoader) {
        mContext = context;
        mViewContainer = container;
        mResourceLoader = resourceLoader;
        mDpToPx = context.getResources().getDisplayMetrics().density;
        mOverlayPanel = panel;
        mPanelSectionHost = panelSectionHost;
        mChips = new ModelList();
        // mChipsSelected is default-initialized to 0.
    }

    // ============================================================================================
    // Public API
    // ============================================================================================

    /**
     * @return Whether the View is visible.
     */
    public boolean isVisible() {
        return mIsVisible;
    }

    /**
     * @return The View height in pixels.
     */
    public float getHeightPx() {
        return !mIsVisible || mControlView == null ? 0f : mHeightPx;
    }

    /** Returns the maximum height of the control. */
    float getMaximumHeightPx() {
        return mContentHeightPx;
    }

    /**
     * Returns the amount of padding that is redundant between the Related Searches carousel that is
     * shown in the Bar with the content above it. The content above has its own padding that
     * provides a space between it and the bottom of the Bar. So when the Bar grows to include the
     * Related Searches (which has its own padding above and below) there is redundant padding.
     * @return The amount of overlap of padding values that can be removed (in pixels).
     */
    public float getRedundantPadding() {
        return !mIsVisible || mControlView == null
                ? 0f
                : mContext.getResources()
                        .getDimension(R.dimen.related_searches_in_bar_redundant_padding);
    }

    /** Returns the ID of the view, or {@code INVALID_VIEW_ID} if there's no view. */
    public int getViewId() {
        return mControlView != null ? mControlView.getViewId() : INVALID_VIEW_ID;
    }

    /** Returns whether the SERP is showing due to a Related Searches suggestion. */
    public boolean isShowingRelatedSearchSerp() {
        return mSelectedChip >= INDEX_OF_THE_FIRST_RELATED_SEARCHES;
    }

    // ============================================================================================
    // Package-private API
    // ============================================================================================

    /** Shows the View. This includes inflating the View and setting its initial state. */
    void show() {
        if (mIsVisible || !hasReleatedSearchesToShow()) return;

        // Invalidates the View in order to generate a snapshot, but do not show the View yet.
        if (mControlView != null) mControlView.invalidate();

        mIsVisible = true;
        mHeightPx = mContentHeightPx;
    }

    /** Hides the View */
    void hide() {
        if (!mIsVisible) return;

        hideView();

        mIsVisible = false;
        mHeightPx = 0.f;
    }

    /**
     * Sets the Related Searches suggestions to show in this view.
     * @param relatedSearches An {@code List} of suggested queries or {@code null} when none.
     */
    void setRelatedSearchesSuggestions(@Nullable List<String> relatedSearches) {
        if (mControlView == null) {
            mControlView =
                    new RelatedSearchesControlView(
                            mOverlayPanel,
                            mContext,
                            mViewContainer,
                            mResourceLoader,
                            R.layout.contextual_search_related_searches_view,
                            R.id.contextual_search_related_searches_view_id,
                            R.id.contextual_search_related_searches_view_control_id);
        }
        assert mChipsSelected == 0 || hasReleatedSearchesToShow();
        mRelatedSearchesSuggestions = relatedSearches;
        mChips.clear();
        if (hasReleatedSearchesToShow()) {
            show();
        } else {
            hide();
        }
        calculateHeight();
        mSelectedChip = NO_SELECTED_CHIP;
    }

    void onPanelCollapsing() {
        clearSelectedSuggestions();
    }

    /** Un-selects any currently selected chip. */
    void clearSelectedSuggestions() {
        if (mSelectedChip != NO_SELECTED_CHIP) {
            mChips.get(mSelectedChip).model.set(ChipProperties.SELECTED, false);
        }
        mSelectedChip = NO_SELECTED_CHIP;
    }

    /** Returns whether we have Related Searches to show or not.  */
    boolean hasReleatedSearchesToShow() {
        return mRelatedSearchesSuggestions != null && mRelatedSearchesSuggestions.size() > 0;
    }

    @VisibleForTesting
    @Nullable
    List<String> getRelatedSearchesSuggestions() {
        return mRelatedSearchesSuggestions;
    }

    // ============================================================================================
    // Test support API
    // ============================================================================================

    public void selectChipForTest(int chipIndex) {
        handleChipTapped(mChips.get(chipIndex).model);
    }

    public ModelList getChipsForTest() {
        return mControlView.getChipsForTest(); // IN-TEST
    }

    public int getSelectedChipForTest() {
        return mSelectedChip;
    }

    // ============================================================================================
    // Panel Motion Handling
    // ============================================================================================

    /**
     * Interpolates the UI from states Closed to Peeked.
     *
     * @param percentage The completion percentage.
     */
    void onUpdateFromCloseToPeek(float percentage) {
        if (!isVisible()) return;

        // The View snapshot should be fully visible here.
        updateAppearance(1.0f);

        showView(true);
    }

    /**
     * Interpolates the UI from states Peeked to Expanded.
     *
     * @param percentage The completion percentage.
     */
    void onUpdateFromPeekToExpand(float percentage) {
        updateViewAndNativeAppearance(percentage);
    }

    /**
     * Interpolates the UI from states Expanded to Maximized.
     *
     * @param percentage The completion percentage.
     */
    void onUpdateFromExpandToMaximize(float percentage) {
        updateViewAndNativeAppearance(percentage);
    }

    /** Destroys as much of this instance as possible. */
    void destroy() {
        if (hasReleatedSearchesToShow()) {
            RelatedSearchesUma.logNumberOfSuggestionsClicked(mChipsSelected);
            if (mDidShowAnySuggestions) RelatedSearchesUma.logCtr(mChipsSelected > 0);
        }

        if (mControlView != null) {
            if (mDidShowAnySuggestions) {
                RelatedSearchesUma.logCarouselScrolled(mScrolled);
                RelatedSearchesUma.logCarouselScrollAndClickStatus(mScrolled, mChipsSelected > 0);
            }
            mControlView.destroy();
            mControlView = null;
        }
    }

    /** Invalidates the view. */
    void invalidate(boolean didViewSizeChange) {
        if (mControlView != null) mControlView.invalidate(didViewSizeChange);
    }

    /**
     * Updates the Android View's hidden state and the native appearance attributes.
     * This hides the Android View when transitioning between states and instead shows the
     * native snapshot (by setting the native attributes to do so).
     * @param percentage The completion percentage.
     */
    private void updateViewAndNativeAppearance(float percentage) {
        if (!isVisible()) return;

        // The panel stays full sized during this size transition.
        updateAppearance(1.0f);

        // We should show the View only when the Panel has reached the exact height.
        // If not exact then the non-interactive native code draws the Panel during the transition.
        if (percentage == 1.f) {
            showView(false);
        } else {
            // If the View is still showing, capture a new snapshot in case anything changed.
            // TODO(donnd): grab snapshots when the view changes instead.
            // See https://crbug.com/1184308 for details.
            if (mIsShowingView) {
                invalidate(false);
            }
            hideView();
        }
    }

    /**
     * Updates the appearance of the View.
     * @param percentage The completion percentage. 0.f means the view is fully collapsed and
     *        transparent. 1.f means the view is fully expanded and opaque.
     */
    private void updateAppearance(float percentage) {
        if (mIsVisible) {
            mHeightPx =
                    Math.round(
                            MathUtils.clamp(percentage * mContentHeightPx, 0.f, mContentHeightPx));
        } else {
            mHeightPx = 0.f;
        }
    }

    // ============================================================================================
    // Helpers
    // ============================================================================================

    /**
     * Shows the Android View. By making the Android View visible, we are allowing the
     * View to be interactive. Since snapshots are not interactive (they are just a bitmap),
     * we need to temporarily show the Android View on top of the snapshot, so the user will
     * be able to click in the View button.
     * @param fromCloseToPeek Whether the view is shown from states Closed to Peeked.
     */
    private void showView(boolean fromCloseToPeek) {
        if (mControlView == null) return;

        float y = mPanelSectionHost.getYPositionPx();
        View view = mControlView.getView();
        if (view == null || !mIsVisible || (mIsShowingView && mViewY == y) || mHeightPx == 0.f) {
            return;
        }

        float offsetX = mOverlayPanel.getOffsetX() * mDpToPx;
        if (LocalizationUtils.isLayoutRtl()) {
            offsetX = -offsetX;
        }

        if (mSelectedChip == NO_SELECTED_CHIP && !fromCloseToPeek) {
            mSelectedChip = 0;
            mChips.get(mSelectedChip).model.set(ChipProperties.SELECTED, true);
        }

        view.setTranslationX(offsetX);
        view.setTranslationY(y - mOverlayPanel.getBarMarginBottomPx());
        view.setVisibility(View.VISIBLE);

        // NOTE: We need to call requestLayout, otherwise the View will not become visible.
        ViewUtils.requestLayout(view, "RelatedSearchesControl.showView");

        mIsShowingView = true;
        mViewY = y;
    }

    /** Hides the Android View. See {@link #showView()}. */
    private void hideView() {
        if (mControlView == null) return;

        View view = mControlView.getView();
        if (view == null || !mIsVisible || !mIsShowingView) {
            return;
        }

        view.setVisibility(View.INVISIBLE);
        mIsShowingView = false;
    }

    /**
     * Calculates the content height of the View, and adjusts the height of the View while
     * preserving the proportion of the height with the content height. This should be called
     * whenever the the size of the View changes.
     */
    private void calculateHeight() {
        if (mControlView == null || !hasReleatedSearchesToShow()) return;

        mControlView.updateRelatedSearchesToShow();
        mControlView.layoutView();

        final float previousContentHeight = mContentHeightPx;
        mContentHeightPx = mControlView.getMeasuredHeight();

        if (mIsVisible) {
            // Calculates the ratio between the current height and the previous content height,
            // and uses it to calculate the new height, while preserving the ratio.
            final float ratio = mHeightPx / previousContentHeight;
            mHeightPx = Math.round(mContentHeightPx * ratio);
        }
    }

    /**
     * Notifies that the user has clicked on a suggestion in this section of the panel.
     * See https://crbug.com/1216593.
     * @param suggestionIndex The 0-based index into the list of suggestions provided by the panel
     *        and presented in the UI. E.g. if the user clicked the second chip this
     *        value would be 1.
     */
    private void onSuggestionClicked(int suggestionIndex) {
        mPanelSectionHost.onSuggestionClicked(suggestionIndex);
        // TODO(donnd): add infrastructure to check if the suggestion is an RS before logging.
        RelatedSearchesUma.logSelectedCarouselIndex(suggestionIndex);
        RelatedSearchesUma.logSelectedSuggestionIndex(suggestionIndex);
        mChipsSelected++;
        boolean isRelatedSearchesSuggestion =
                suggestionIndex >= INDEX_OF_THE_FIRST_RELATED_SEARCHES;
        ContextualSearchUma.logAllSearches(isRelatedSearchesSuggestion);

        mControlView.smoothScrollToPosition(suggestionIndex);
    }

    // ============================================================================================
    // Chips
    // ============================================================================================

    public void updateChips() {
        Callback<PropertyModel> selectedCallback = (model) -> handleChipTapped(model);

        if (mChips.size() == 0 && hasReleatedSearchesToShow()) {
            for (String suggestion : mRelatedSearchesSuggestions) {
                final int index = mChips.size();
                ListItem chip =
                        ChipsCoordinator.buildChipListItem(index, suggestion, selectedCallback);

                if (index < INDEX_OF_THE_FIRST_RELATED_SEARCHES) {
                    chip.model.set(
                            ChipProperties.TEXT_MAX_WIDTH_PX,
                            mContext.getResources()
                                    .getDimensionPixelSize(
                                            R.dimen.contextual_search_chip_max_width));
                }
                mChips.add(chip);
            }
            mDidShowAnySuggestions = true;
        }
    }

    private void handleChipTapped(PropertyModel tappedChip) {
        if (mControlView == null) return;

        onSuggestionClicked(tappedChip.get(ChipProperties.ID));
        if (mSelectedChip != NO_SELECTED_CHIP) {
            mChips.get(mSelectedChip).model.set(ChipProperties.SELECTED, false);
        }
        mSelectedChip = tappedChip.get(ChipProperties.ID);
        tappedChip.set(ChipProperties.SELECTED, true);
    }

    // ============================================================================================
    // ControlView - the Android View that this class controls.
    // ============================================================================================

    /**
     * The {@code RelatedSearchesControlView} is an {@link OverlayPanelInflater} controlled view
     * that renders the actual View and can be created and destroyed under the control of the
     * enclosing Panel section class. The enclosing class delegates several public methods to this
     * class, e.g. {@link #invalidate}.
     */
    private class RelatedSearchesControlView extends OverlayPanelInflater {
        private final ChipsCoordinator mChipsCoordinator;
        // TODO(donnd): track the offset of the carousel here, so we can use it for snapshotting
        // and log that the user has scrolled it.
        private float mLastOffset;
        private final int mControlId;

        /**
         * Constructs a view that can be shown in the panel.
         * @param panel             The panel.
         * @param context           The Android Context used to inflate the View.
         * @param container         The container View used to inflate the View.
         * @param resourceLoader    The resource loader that will handle the snapshot capturing.
         * @param layoutId          The XML Layout that declares the View.
         * @param viewId            The id of the root View of the Layout.
         * @param controlId         The id of the control View.
         */
        RelatedSearchesControlView(
                OverlayPanel panel,
                Context context,
                ViewGroup container,
                DynamicResourceLoader resourceLoader,
                int layoutId,
                int viewId,
                int controlId) {
            super(panel, layoutId, viewId, context, container, resourceLoader);
            mControlId = controlId;

            // Setup Chips handling
            mChipsCoordinator = new ChipsCoordinator(context, mChips);
            mChipsCoordinator.setSpaceItemDecoration(
                    context.getResources()
                            .getDimensionPixelSize(
                                    R.dimen.contextual_search_chip_list_chip_spacing),
                    context.getResources()
                            .getDimensionPixelSize(
                                    R.dimen.contextual_search_chip_list_side_padding));

            RecyclerView recyclerView = (RecyclerView) mChipsCoordinator.getView();
            recyclerView.addOnScrollListener(
                    new OnScrollListener() {
                        @Override
                        public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
                            if (newState == RecyclerView.SCROLL_STATE_DRAGGING) mScrolled = true;
                            if (newState == RecyclerView.SCROLL_STATE_IDLE) invalidate(false);
                        }
                    });
        }

        /**
         * Smoothly scroll to the view in the position.
         * @param position the position of the view to scroll to.
         */
        private void smoothScrollToPosition(int position) {
            RecyclerView recyclerView = (RecyclerView) mChipsCoordinator.getView();
            recyclerView.smoothScrollToPosition(position);
        }

        /** Returns the view for this control. */
        View getControlView() {
            View view = getView();
            if (view == null) return null;

            return view.findViewById(mControlId);
        }

        @Override
        public View getView() {
            return super.getView();
        }

        /** Triggers a view layout. */
        void layoutView() {
            super.layout();
        }

        /** Tells the control to update the Related Searches to display in the view. */
        void updateRelatedSearchesToShow() {
            View relatedSearchesView = getControlView();
            ViewGroup relatedSearchesViewGroup = (ViewGroup) relatedSearchesView;
            if (relatedSearchesViewGroup == null) return;

            // Notify the coordinator that the chips need to change
            updateChips();
            View coordinatorView = mChipsCoordinator.getView();
            if (coordinatorView == null) return;

            // Put the chip coordinator view into our control view.
            ViewGroup parent = (ViewGroup) coordinatorView.getParent();
            if (parent != null) parent.removeView(coordinatorView);
            relatedSearchesViewGroup.addView(coordinatorView);
            invalidate(false);

            // Log carousel visible item position
            RecyclerView recyclerView = (RecyclerView) mChipsCoordinator.getView();
            LinearLayoutManager layoutManager =
                    (LinearLayoutManager) recyclerView.getLayoutManager();
            int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();
            if (lastVisibleItemPosition != RecyclerView.NO_POSITION) {
                RelatedSearchesUma.logCarouselLastVisibleItemPosition(lastVisibleItemPosition);
            }
        }

        @Override
        public void destroy() {
            hide();
            super.destroy();
        }

        @Override
        public void invalidate(boolean didViewSizeChange) {
            super.invalidate(didViewSizeChange);

            if (didViewSizeChange) {
                calculateHeight();
            }
        }

        @Override
        protected void onFinishInflate() {
            super.onFinishInflate();

            calculateHeight();
        }

        @Override
        protected boolean shouldDetachViewAfterCapturing() {
            return false;
        }

        ModelList getChipsForTest() {
            updateChips();
            return mChips;
        }
    }
}