chromium/chrome/browser/touch_to_fill/common/android/java/src/org/chromium/chrome/browser/touch_to_fill/common/TouchToFillViewBase.java

// Copyright 2022 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.touch_to_fill.common;

import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewGroup.MarginLayoutParams;
import android.widget.RelativeLayout;

import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.VisibleForTesting;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import org.chromium.base.Callback;
import org.chromium.chrome.browser.autofill.bottom_sheet_utils.DetailScreenScrollListener;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetContent;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetObserver;
import org.chromium.components.browser_ui.bottomsheet.EmptyBottomSheetObserver;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.base.ViewUtils;

import java.util.Set;

/** This is a base class for the Touch to Fill View classes. */
public abstract class TouchToFillViewBase implements BottomSheetContent {
    private static final int MAX_FULLY_VISIBLE_CREDENTIAL_COUNT = 3;

    private final BottomSheetController mBottomSheetController;
    private final RelativeLayout mContentView;
    private final DetailScreenScrollListener mScrollListener;
    private Callback<Integer> mDismissHandler;
    private RecyclerView mSheetItemListView;

    private final BottomSheetObserver mBottomSheetObserver =
            new EmptyBottomSheetObserver() {
                @Override
                public void onSheetClosed(@BottomSheetController.StateChangeReason int reason) {
                    super.onSheetClosed(reason);
                    assert mDismissHandler != null;
                    mDismissHandler.onResult(reason);
                    mBottomSheetController.removeObserver(mBottomSheetObserver);
                }

                @Override
                public void onSheetStateChanged(int newState, int reason) {
                    super.onSheetStateChanged(newState, reason);
                    if (newState == BottomSheetController.SheetState.FULL) {
                        // The list of items should be scrollable in full state.
                        mSheetItemListView.suppressLayout(false);
                    } else if (newState == BottomSheetController.SheetState.HALF
                            && mScrollListener.isScrolledToTop()) {
                        // The list of items should not be scrollable when the sheet transitions
                        // into half state if it's scrolled to the top. If the list is currently
                        // scrolled away from the top, it should stay scrolled in half state until
                        // the user scrolls to the top.
                        mSheetItemListView.suppressLayout(true);
                    }
                    if (newState != BottomSheetController.SheetState.HIDDEN) return;
                    // This is a fail-safe for cases where onSheetClosed isn't triggered.
                    mDismissHandler.onResult(BottomSheetController.StateChangeReason.NONE);
                    mBottomSheetController.removeObserver(mBottomSheetObserver);
                }
            };

    /**
     * Used to access the handlebar to measure it.
     * @return the {@link View} representing the drag handlebar.
     */
    protected abstract View getHandlebar();

    /**
     * Returns the margin between the last item in the scrollable list and the footer.
     * @return the margin size in pixels.
     */
    protected abstract @Px int getConclusiveMarginHeightPx();

    /**
     * Used as a helper to measure the size of the sheet content.
     * @return the side margin of the content view.
     */
    protected abstract @Px int getSideMarginPx();

    /**
     * Used as a helper for the suggestion list height calculation.
     * @return the item types of the suggestions in the list on the {@link BottomSheet}.
     */
    protected abstract Set<Integer> listedItemTypes();

    /**
     * Used as a helper for the suggestion list height calculation.
     * @return the item type of the footer on the {@link BottomSheet}.
     */
    protected abstract int footerItemType();

    /**
     * @param bottomSheetController The {@link BottomSheetController} used to show/hide the sheet.
     * @param contentView The content of the bottom sheet.
     * @param suppressCollectionA11y Disables/enables setting the collection related a11y node info,
     *                               basically removing the "2 of 4" part in a regular RecycleView
     *                               item announcement. Setting it to `true` implies that the item
     *                               content description is updated accordingly for items that are
     *                               eligible for indexing from the UI perspective.
     */
    public TouchToFillViewBase(
            BottomSheetController bottomSheetController,
            RelativeLayout contentView,
            Boolean suppressCollectionA11y) {
        mBottomSheetController = bottomSheetController;
        mContentView = contentView;
        mSheetItemListView = getContentView().findViewById(R.id.sheet_item_list);

        mSheetItemListView.setLayoutManager(
                new LinearLayoutManager(
                        mSheetItemListView.getContext(), LinearLayoutManager.VERTICAL, false) {
                    @Override
                    public boolean isAutoMeasureEnabled() {
                        return true;
                    }

                    @Override
                    public void onInitializeAccessibilityNodeInfo(
                            RecyclerView.Recycler recycler,
                            RecyclerView.State state,
                            AccessibilityNodeInfoCompat info) {
                        if (!suppressCollectionA11y) {
                            super.onInitializeAccessibilityNodeInfo(recycler, state, info);
                        }
                    }
                });

        // Apply RTL layout changes.
        int layoutDirection =
                LocalizationUtils.isLayoutRtl()
                        ? View.LAYOUT_DIRECTION_RTL
                        : View.LAYOUT_DIRECTION_LTR;
        mContentView.setLayoutDirection(layoutDirection);

        mScrollListener = new DetailScreenScrollListener(mBottomSheetController);
        mSheetItemListView.addOnScrollListener(mScrollListener);
    }

    @Override
    public View getContentView() {
        return mContentView;
    }

    public void setSheetItemListAdapter(RecyclerView.Adapter adapter) {
        mSheetItemListView.setAdapter(adapter);
    }

    /**
     * If set to true, requests to show the bottom sheet. Otherwise, requests to hide the sheet.
     *
     * @param isVisible A boolean describing whether to show or hide the sheet.
     * @return True if the request was successful, false otherwise
     */
    public boolean setVisible(boolean isVisible) {
        if (isVisible) {
            remeasure();
            mBottomSheetController.addObserver(mBottomSheetObserver);
            if (!mBottomSheetController.requestShowContent(this, true)) {
                mBottomSheetController.removeObserver(mBottomSheetObserver);
                return false;
            }
        } else {
            mBottomSheetController.hideContent(this, true);
        }
        return true;
    }

    /**
     * Sets a new listener that reacts to events like item selection or dismissal.
     *
     * @param dismissHandler A {@link Callback<Integer>}.
     */
    public void setDismissHandler(Callback<Integer> dismissHandler) {
        mDismissHandler = dismissHandler;
    }

    /**
     * Returns the height of the full state. Must show the footer items permanently. For up to four
     * suggestions, the sheet usually cannot fill the screen.
     *
     * @return the full state height in pixels. Never 0. Can theoretically exceed the screen height.
     */
    protected @Px int getMaximumSheetHeightPx() {
        if (mSheetItemListView.getAdapter() == null) {
            // TODO(crbug.com/40843561): Assert this condition in setVisible. Should never happen.
            return BottomSheetContent.HeightMode.DEFAULT;
        }
        @Px int requiredMaxHeight = getHeightWhenFullyExtendedPx();
        if (requiredMaxHeight <= mBottomSheetController.getContainerHeight()) {
            return requiredMaxHeight;
        }
        remeasure();
        ViewUtils.requestLayout(mContentView, "TouchToFillView.getMaximumSheetHeightPx");
        return getHeightWhenFullyExtendedPx();
    }

    /**
     * Returns the height of the half state. Does not show the footer items. For 1 suggestion (plus
     * fill button), 2 or 3 suggestions, it shows all items fully. For 4+ suggestions, it shows the
     * first 3.5 suggestion to encourage scrolling.
     *
     * @return the half state height in pixels. Never 0. Can theoretically exceed the screen height.
     */
    protected @Px int getDesiredSheetHeightPx() {
        if (mSheetItemListView.getAdapter() == null) {
            // TODO(crbug.com/40843561): Assert this condition in setVisible. Should never happen.
            return BottomSheetContent.HeightMode.DEFAULT;
        }
        int height =
                getHeightWithMarginsPx(getHandlebar(), false)
                        + getSheetItemListHeightWithMarginsPx(true);
        return height;
    }

    private @Px int getHeightWhenFullyExtendedPx() {
        assert mContentView.getMeasuredHeight() > 0 : "ContentView hasn't been measured.";
        int height =
                getHeightWithMarginsPx(getHandlebar(), false)
                        + getSheetItemListHeightWithMarginsPx(false);
        return height;
    }

    private @Px int getSheetItemListHeightWithMarginsPx(boolean showOnlyInitialItems) {
        assert mSheetItemListView.getMeasuredHeight() > 0 : "Sheet item list hasn't been measured.";
        @Px int totalHeight = 0;
        int visibleItems = 0;
        for (int posInSheet = 0; posInSheet < mSheetItemListView.getChildCount(); posInSheet++) {
            View child = mSheetItemListView.getChildAt(posInSheet);
            if (isListedItem(child)) {
                // Counting how many clickable suggestions are displayed.
                visibleItems++;
            } else if (showOnlyInitialItems && isFooterItem(child)) {
                // If we want to show only the initial items, the footer should remain hidden.
                return totalHeight + getConclusiveMarginHeightPx();
            }
            if (showOnlyInitialItems && visibleItems > MAX_FULLY_VISIBLE_CREDENTIAL_COUNT) {
                // If the current item is the last to be shown, skip remaining elements and margins.
                totalHeight += getHeightWithMarginsPx(child, true);
                return totalHeight;
            }
            totalHeight += getHeightWithMarginsPx(child, false);
        }
        return totalHeight;
    }

    private static @Px int getHeightWithMarginsPx(View view, boolean shouldPeek) {
        assert view.getMeasuredHeight() > 0 : "View hasn't been measured.";
        return getMarginsPx(view, /* excludeBottomMargin= */ shouldPeek)
                + (shouldPeek ? view.getMeasuredHeight() / 2 : view.getMeasuredHeight());
    }

    private static @Px int getMarginsPx(View view, boolean excludeBottomMargin) {
        LayoutParams params = view.getLayoutParams();
        if (params instanceof MarginLayoutParams) {
            MarginLayoutParams marginParams = (MarginLayoutParams) params;
            return marginParams.topMargin + (excludeBottomMargin ? 0 : marginParams.bottomMargin);
        }
        return 0;
    }

    /** Measures the content of the bottom sheet. */
    protected void remeasure() {
        mContentView.measure(
                View.MeasureSpec.makeMeasureSpec(getInsetDisplayWidthPx(), MeasureSpec.AT_MOST),
                MeasureSpec.UNSPECIFIED);
        mSheetItemListView.measure(
                View.MeasureSpec.makeMeasureSpec(getInsetDisplayWidthPx(), MeasureSpec.AT_MOST),
                MeasureSpec.UNSPECIFIED);
    }

    private @Px int getInsetDisplayWidthPx() {
        return mContentView.getContext().getResources().getDisplayMetrics().widthPixels
                - 2 * getSideMarginPx();
    }

    private boolean isListedItem(View childInSheetView) {
        int posInAdapter = mSheetItemListView.getChildAdapterPosition(childInSheetView);
        return listedItemTypes()
                .contains(mSheetItemListView.getAdapter().getItemViewType(posInAdapter));
    }

    private boolean isFooterItem(View childInSheetView) {
        int posInAdapter = mSheetItemListView.getChildAdapterPosition(childInSheetView);
        return mSheetItemListView.getAdapter().getItemViewType(posInAdapter) == footerItemType();
    }

    @Nullable
    @Override
    public View getToolbarView() {
        return null;
    }

    @Override
    public int getPriority() {
        return BottomSheetContent.ContentPriority.HIGH;
    }

    @Override
    public boolean hasCustomScrimLifecycle() {
        return false;
    }

    @Override
    public boolean swipeToDismissEnabled() {
        return false;
    }

    @Override
    public boolean skipHalfStateOnScrollingDown() {
        // Skip the half state if a service requesting touch exploration is enabled.
        return AccessibilityState.isTouchExplorationEnabled();
    }

    @Override
    public int getPeekHeight() {
        return BottomSheetContent.HeightMode.DISABLED;
    }

    @Override
    public float getFullHeightRatio() {
        // WRAP_CONTENT would be the right fit but this disables the HALF state.
        return Math.min(getMaximumSheetHeightPx(), mBottomSheetController.getContainerHeight())
                / (float) mBottomSheetController.getContainerHeight();
    }

    @Override
    public float getHalfHeightRatio() {
        // Disable the half state when touch exploration is enabled.
        if (skipHalfStateOnScrollingDown()) return HeightMode.DISABLED;
        return Math.min(getDesiredSheetHeightPx(), mBottomSheetController.getContainerHeight())
                / (float) mBottomSheetController.getContainerHeight();
    }

    @Override
    public boolean hideOnScroll() {
        return false;
    }

    @Override
    public void destroy() {
        mBottomSheetController.removeObserver(mBottomSheetObserver);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    public RecyclerView getSheetItemListView() {
        return mSheetItemListView;
    }
}