chromium/components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/selectable_list/SelectableListLayout.java

// Copyright 2016 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.selectable_list;

import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewStub;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.annotation.DrawableRes;
import androidx.annotation.IdRes;
import androidx.annotation.LayoutRes;
import androidx.annotation.MenuRes;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver;
import androidx.recyclerview.widget.RecyclerView.ItemAnimator;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;

import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.components.browser_ui.widget.FadingShadow;
import org.chromium.components.browser_ui.widget.FadingShadowView;
import org.chromium.components.browser_ui.widget.R;
import org.chromium.components.browser_ui.widget.displaystyle.DisplayStyleObserver;
import org.chromium.components.browser_ui.widget.displaystyle.HorizontalDisplayStyle;
import org.chromium.components.browser_ui.widget.displaystyle.UiConfig;
import org.chromium.components.browser_ui.widget.displaystyle.UiConfig.DisplayStyle;
import org.chromium.components.browser_ui.widget.gesture.BackPressHandler;
import org.chromium.components.browser_ui.widget.selectable_list.SelectionDelegate.SelectionObserver;
import org.chromium.ui.widget.LoadingView;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * Contains UI elements common to selectable list views: a loading view, empty view, selection
 * toolbar, shadow, and RecyclerView.
 *
 * <p>After the SelectableListLayout is inflated, it should be initialized through calls to
 * #initializeRecyclerView(), #initializeToolbar(), and #initializeEmptyStateView().
 *
 * <p>Must call #onDestroyed() to destroy SelectableListLayout properly, otherwise this would cause
 * memory leak consistently.
 *
 * @param <E> The type of the selectable items this layout holds.
 */
public class SelectableListLayout<E> extends FrameLayout
        implements DisplayStyleObserver, SelectionObserver<E>, BackPressHandler {
    private static final int WIDE_DISPLAY_MIN_PADDING_DP = 16;
    private RecyclerView.Adapter mAdapter;
    private ViewStub mToolbarStub;
    private TextView mEmptyView;
    private TextView mEmptyStateSubHeadingView;
    private View mEmptyViewWrapper;
    private ImageView mEmptyImageView;
    private LoadingView mLoadingView;
    private RecyclerView mRecyclerView;
    private ItemAnimator mItemAnimator;
    SelectableListToolbar<E> mToolbar;
    private FadingShadowView mToolbarShadow;

    private @StringRes int mEmptyStringResId;
    private CharSequence mEmptySubheadingString;

    private UiConfig mUiConfig;

    private final ObservableSupplierImpl<Boolean> mBackPressStateSupplier =
            new ObservableSupplierImpl<>();
    private final Set<Integer> mIgnoredTypesForEmptyState = new HashSet<>();

    private final AdapterDataObserver mAdapterObserver =
            new AdapterDataObserver() {
                @Override
                public void onChanged() {
                    super.onChanged();
                    updateLayout();
                    // At inflation, the RecyclerView is set to gone, and the loading view is
                    // visible. As long as the adapter data changes, we show the recycler view,
                    // and hide loading view.
                    mLoadingView.hideLoadingUI();
                }

                @Override
                public void onItemRangeInserted(int positionStart, int itemCount) {
                    super.onItemRangeInserted(positionStart, itemCount);
                    updateLayout();
                    // At inflation, the RecyclerView is set to gone, and the loading view is
                    // visible. As long as the adapter data changes, we show the recycler view,
                    // and hide loading view.
                    mLoadingView.hideLoadingUI();
                }

                @Override
                public void onItemRangeRemoved(int positionStart, int itemCount) {
                    super.onItemRangeRemoved(positionStart, itemCount);
                    updateLayout();
                }
            };

    public SelectableListLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        onBackPressStateChanged(); // Initialize back press state.
    }

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

        LayoutInflater.from(getContext()).inflate(R.layout.selectable_list_layout, this);

        mEmptyView = findViewById(R.id.empty_view);
        mEmptyViewWrapper = findViewById(R.id.empty_view_wrapper);
        mLoadingView = findViewById(R.id.loading_view);
        mLoadingView.showLoadingUI();

        mToolbarStub = findViewById(R.id.action_bar_stub);

        setFocusable(true);
        setFocusableInTouchMode(true);
    }

    @Override
    protected void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);

        if (mUiConfig != null) mUiConfig.updateDisplayStyle();
    }

    /**
     * Creates a RecyclerView for the given adapter.
     *
     * @param adapter The adapter that provides a binding from an app-specific data set to views
     *                that are displayed within the RecyclerView.
     * @return The RecyclerView itself.
     */
    public RecyclerView initializeRecyclerView(RecyclerView.Adapter adapter) {
        return initializeRecyclerView(adapter, null);
    }

    /**
     * Initializes the layout with the given recycler view and adapter.
     *
     * @param adapter The adapter that provides a binding from an app-specific data set to views
     *                that are displayed within the RecyclerView.
     * @param recyclerView The recycler view to be shown.
     * @return The RecyclerView itself.
     */
    public RecyclerView initializeRecyclerView(
            RecyclerView.Adapter adapter, @Nullable RecyclerView recyclerView) {
        mAdapter = adapter;

        if (recyclerView == null) {
            mRecyclerView = findViewById(R.id.selectable_list_recycler_view);
            mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
        } else {
            mRecyclerView = recyclerView;

            // Replace the inflated recycler view with the one supplied to this method.
            FrameLayout contentView = findViewById(R.id.list_content);
            RecyclerView existingView =
                    contentView.findViewById(R.id.selectable_list_recycler_view);
            contentView.removeView(existingView);
            contentView.addView(mRecyclerView, 0);
        }

        mRecyclerView.setAdapter(mAdapter);
        initializeRecyclerViewProperties();
        return mRecyclerView;
    }

    private void initializeRecyclerViewProperties() {
        mAdapter.registerAdapterDataObserver(mAdapterObserver);

        mRecyclerView.setHasFixedSize(true);
        mRecyclerView.addOnScrollListener(
                new OnScrollListener() {
                    @Override
                    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                        setToolbarShadowVisibility();
                    }
                });
        mRecyclerView.addOnLayoutChangeListener(
                (View v,
                        int left,
                        int top,
                        int right,
                        int bottom,
                        int oldLeft,
                        int oldTop,
                        int oldRight,
                        int oldBottom) -> {
                    setToolbarShadowVisibility();
                });

        mItemAnimator = mRecyclerView.getItemAnimator();
    }

    /**
     * Initializes the SelectionToolbar.
     *
     * @param toolbarLayoutId The resource id of the toolbar layout. This will be inflated into a
     *     ViewStub.
     * @param delegate The SelectionDelegate that will inform the toolbar of selection changes.
     * @param titleResId The resource id of the title string. May be 0 if this class shouldn't set
     *     set a title when the selection is cleared.
     * @param normalGroupResId The resource id of the menu group to show when a selection isn't
     *     established.
     * @param selectedGroupResId The resource id of the menu item to show when a selection is
     *     established.
     * @param listener The OnMenuItemClickListener to set on the toolbar.
     * @param updateStatusBarColor Whether the status bar color should be updated to match the
     *     toolbar color. If true, the status bar will only be updated if the current device fully
     *     supports theming and is on Android M+.
     * @return The initialized SelectionToolbar.
     */
    public SelectableListToolbar<E> initializeToolbar(
            @LayoutRes int toolbarLayoutId,
            SelectionDelegate<E> delegate,
            @StringRes int titleResId,
            @IdRes int normalGroupResId,
            @IdRes int selectedGroupResId,
            @Nullable OnMenuItemClickListener listener,
            boolean updateStatusBarColor) {
        return initializeToolbar(
                toolbarLayoutId,
                delegate,
                titleResId,
                normalGroupResId,
                selectedGroupResId,
                listener,
                updateStatusBarColor,
                /* menuResId= */ 0,
                false);
    }

    /**
     * Initializes the SelectionToolbar with the option to show the back button in normal view.
     * #onNavigationBack must also be overridden in order to assign behavior to the button.
     *
     * @param toolbarLayoutId The resource id of the toolbar layout. This will be inflated into a
     *     ViewStub.
     * @param delegate The SelectionDelegate that will inform the toolbar of selection changes.
     * @param titleResId The resource id of the title string. May be 0 if this class shouldn't set
     *     set a title when the selection is cleared.
     * @param normalGroupResId The resource id of the menu group to show when a selection isn't
     *     established.
     * @param selectedGroupResId The resource id of the menu item to show when a selection is
     *     established.
     * @param listener The OnMenuItemClickListener to set on the toolbar.
     * @param updateStatusBarColor Whether the status bar color should be updated to match the
     *     toolbar color. If true, the status bar will only be updated if the current device fully
     *     supports theming and is on Android M+.
     * @param menuResId The resource id of the menu. {@code 0} if not required.
     * @param showBackInNormalView Whether the back arrow should appear on the normal view.
     * @return The initialized SelectionToolbar.
     */
    public SelectableListToolbar<E> initializeToolbar(
            @LayoutRes int toolbarLayoutId,
            SelectionDelegate<E> delegate,
            @StringRes int titleResId,
            @IdRes int normalGroupResId,
            @IdRes int selectedGroupResId,
            @Nullable OnMenuItemClickListener listener,
            boolean updateStatusBarColor,
            @MenuRes int menuResId,
            boolean showBackInNormalView) {
        mToolbarStub.setLayoutResource(toolbarLayoutId);
        @SuppressWarnings("unchecked")
        SelectableListToolbar<E> toolbar = (SelectableListToolbar<E>) mToolbarStub.inflate();
        mToolbar = toolbar;
        mToolbar.initialize(
                delegate,
                titleResId,
                normalGroupResId,
                selectedGroupResId,
                updateStatusBarColor,
                menuResId,
                showBackInNormalView);

        if (listener != null) {
            mToolbar.setOnMenuItemClickListener(listener);
        }

        mToolbarShadow = findViewById(R.id.shadow);
        mToolbarShadow.init(
                getContext().getColor(R.color.toolbar_shadow_color), FadingShadow.POSITION_TOP);

        delegate.addObserver(this);
        setToolbarShadowVisibility();

        return mToolbar;
    }

    /**
     * Initializes the view shown when the selectable list is empty.
     *
     * @param emptyStringResId The string to show when the selectable list is empty.
     * @return The {@link TextView} displayed when the list is empty.
     */
    public TextView initializeEmptyView(@StringRes int emptyStringResId) {
        setEmptyViewText(emptyStringResId);

        // Empty listener to have the touch events dispatched to this view tree for navigation UI.
        mEmptyViewWrapper.setOnTouchListener((v, event) -> true);

        return mEmptyView;
    }

    /**
     * Initializes the empty state view with an image, heading, and subheading.
     *
     * @param imageResId Image view to show when the selectable list is empty.
     * @param emptyHeadingStringResId Heading string to show when the selectable list is empty.
     * @param emptySubheadingString Subheading string to show when the selectable list is empty.
     * @return The {@link TextView} displayed when the list is empty.
     */
    // @TODO: (crbugs.com/1443648) Refactor return value for ForTesting method
    public TextView initializeEmptyStateView(
            @DrawableRes int imageResId,
            @StringRes int emptyHeadingStringResId,
            CharSequence emptySubheadingString) {
        // Initialize and inflate empty state view stub.
        ViewStub emptyViewStub = findViewById(R.id.empty_state_view_stub);
        View emptyStateView = emptyViewStub.inflate();

        // Initialize empty state resource.
        mEmptyView = emptyStateView.findViewById(R.id.empty_state_text_title);
        mEmptyStateSubHeadingView = emptyStateView.findViewById(R.id.empty_state_text_description);
        mEmptyImageView = emptyStateView.findViewById(R.id.empty_state_icon);
        mEmptyViewWrapper = emptyStateView.findViewById(R.id.empty_state_container);

        // Set empty state properties.
        setEmptyStateImageRes(imageResId);
        setEmptyStateViewText(emptyHeadingStringResId, emptySubheadingString);
        return mEmptyView;
    }

    /**
     * Sets the empty state view image when the selectable list is empty.
     *
     * @param imageResId The image view to show when the selectable list is empty.
     */
    public void setEmptyStateImageRes(@DrawableRes int imageResId) {
        mEmptyImageView.setImageResource(imageResId);
    }

    /**
     * Sets the view text when the selectable list is empty.
     *
     * @param emptyStringResId The string to show when the selectable list is empty.
     */
    public void setEmptyViewText(@StringRes int emptyStringResId) {
        mEmptyStringResId = emptyStringResId;

        mEmptyView.setText(mEmptyStringResId);
    }

    /**
     * Sets the view text when the selectable list is empty.
     *
     * @param emptyHeadingStringResId Heading string to show when the selectable list is empty.
     * @param emptySubheadingString Subheading string to show when the selectable list is empty.
     */
    public void setEmptyStateViewText(
            @StringRes int emptyHeadingStringResId, CharSequence emptySubheadingString) {
        mEmptyStringResId = emptyHeadingStringResId;
        mEmptySubheadingString = emptySubheadingString;

        mEmptyView.setText(mEmptyStringResId);
        mEmptyStateSubHeadingView.setText(mEmptySubheadingString);
    }

    /**
     * Adds the given type to the set of ignored item types. Items of this type in the adapter won't
     * be counted when deciding to show the empty state view.
     */
    public void ignoreItemTypeForEmptyState(int type) {
        mIgnoredTypesForEmptyState.add(type);
    }

    /** Called when the view that owns the SelectableListLayout is destroyed. */
    public void onDestroyed() {
        mAdapter.unregisterAdapterDataObserver(mAdapterObserver);
        mToolbar.getSelectionDelegate().removeObserver(this);
        mToolbar.destroy();
        mLoadingView.destroy();
        mRecyclerView.setAdapter(null);
    }

    /**
     * When this layout has a wide display style, it will be width constrained to
     * {@link UiConfig#WIDE_DISPLAY_STYLE_MIN_WIDTH_DP}. If the current screen width is greater than
     * UiConfig#WIDE_DISPLAY_STYLE_MIN_WIDTH_DP, the SelectableListLayout will be visually centered
     * by adding padding to both sides.
     *
     * This method should be called after the toolbar and RecyclerView are initialized.
     */
    public void configureWideDisplayStyle() {
        mUiConfig = new UiConfig(this);
        mToolbar.configureWideDisplayStyle(mUiConfig);
        mUiConfig.addObserver(this);
    }

    @Override
    public void onDisplayStyleChanged(DisplayStyle newDisplayStyle) {
        int padding = getPaddingForDisplayStyle(newDisplayStyle, getResources());
        mRecyclerView.setPaddingRelative(
                padding, mRecyclerView.getPaddingTop(), padding, mRecyclerView.getPaddingBottom());
    }

    @Override
    public void onSelectionStateChange(List<E> selectedItems) {
        onBackPressStateChanged();
        setToolbarShadowVisibility();
    }

    /**
     * Called when a search is starting.
     *
     * @param searchEmptyString The string to show when the selectable list is empty during a
     *     search.
     * @param searchEmptySubheadingResId The resource ID of the string to show as the description.
     */
    public void onStartSearch(String searchEmptyString, @StringRes int searchEmptySubheadingResId) {
        mRecyclerView.setItemAnimator(null);
        mToolbarShadow.setVisibility(View.VISIBLE);
        mEmptyView.setText(searchEmptyString);
        if (searchEmptySubheadingResId != Resources.ID_NULL) {
            mEmptyStateSubHeadingView.setText(searchEmptySubheadingResId);
        }
        onBackPressStateChanged();
    }

    /** Called when a search has ended. */
    public void onEndSearch() {
        mRecyclerView.setItemAnimator(mItemAnimator);
        setToolbarShadowVisibility();
        mEmptyView.setText(mEmptyStringResId);
        mEmptyStateSubHeadingView.setText(mEmptySubheadingString);

        onBackPressStateChanged();
    }

    /**
     * @param displayStyle The current display style..
     * @param resources The {@link Resources} used to retrieve configuration and display metrics.
     * @return The lateral padding to use for the current display style.
     */
    public static int getPaddingForDisplayStyle(DisplayStyle displayStyle, Resources resources) {
        int padding = 0;
        if (displayStyle.horizontal == HorizontalDisplayStyle.WIDE) {
            int screenWidthDp = resources.getConfiguration().screenWidthDp;
            float dpToPx = resources.getDisplayMetrics().density;
            padding =
                    (int)
                            (((screenWidthDp - UiConfig.WIDE_DISPLAY_STYLE_MIN_WIDTH_DP) / 2.f)
                                    * dpToPx);
            padding = (int) Math.max(WIDE_DISPLAY_MIN_PADDING_DP * dpToPx, padding);
        }
        return padding;
    }

    private void setToolbarShadowVisibility() {
        if (mToolbar == null || mRecyclerView == null) return;

        boolean showShadow = mRecyclerView.canScrollVertically(-1);
        mToolbarShadow.setVisibility(showShadow ? View.VISIBLE : View.GONE);
    }

    /**
     * Unlike ListView or GridView, RecyclerView does not provide default empty
     * view implementation. We need to check it ourselves.
     */
    private void updateEmptyViewVisibility() {
        int visible = isListEffectivelyEmpty() ? View.VISIBLE : View.GONE;
        mEmptyView.setVisibility(visible);
        mEmptyViewWrapper.setVisibility(visible);
    }

    /**
     * For efficiency, only loop over the items if there are ignored types present in the set and
     * bail on the loop as soon as one is detected.
     */
    private boolean isListEffectivelyEmpty() {
        if (mIgnoredTypesForEmptyState.isEmpty()) {
            return mAdapter.getItemCount() == 0;
        }

        for (int i = 0; i < mAdapter.getItemCount(); i++) {
            if (!mIgnoredTypesForEmptyState.contains(mAdapter.getItemViewType(i))) {
                return false;
            }
        }

        return true;
    }

    private void updateLayout() {
        updateEmptyViewVisibility();
        if (mAdapter.getItemCount() == 0) {
            mRecyclerView.setVisibility(View.GONE);
        } else {
            mRecyclerView.setVisibility(View.VISIBLE);
        }

        mToolbar.setSearchEnabled(mAdapter.getItemCount() != 0);
    }

    public View getToolbarShadowForTests() {
        return mToolbarShadow;
    }

    /**
     * Called when the user presses the back key. Note that this method is not called automatically.
     * The embedding UI must call this method
     * when a backpress is detected for the event to be handled.
     * @return Whether this event is handled.
     */
    public boolean onBackPressed() {
        SelectionDelegate selectionDelegate = mToolbar.getSelectionDelegate();
        if (selectionDelegate.isSelectionEnabled()) {
            selectionDelegate.clearSelection();
            return true;
        }

        if (mToolbar.isSearching()) {
            mToolbar.hideSearchView();
            return true;
        }

        return false;
    }

    @Override
    public @BackPressResult int handleBackPress() {
        var ret = onBackPressed();
        assert ret;
        return ret ? BackPressResult.SUCCESS : BackPressResult.FAILURE;
    }

    @Override
    public ObservableSupplier<Boolean> getHandleBackPressChangedSupplier() {
        return mBackPressStateSupplier;
    }

    private void onBackPressStateChanged() {
        if (mToolbar == null) {
            mBackPressStateSupplier.set(false);
            return;
        }
        mBackPressStateSupplier.set(
                mToolbar.getSelectionDelegate().isSelectionEnabled() || mToolbar.isSearching());
    }
}