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

// Copyright 2020 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;

import android.view.View;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.LayoutManager;

/** Selection manager for RecyclerViews. */
public class RecyclerViewSelectionController
        implements RecyclerView.OnChildAttachStateChangeListener {
    private static final int ADVANCE_EXPOSE_VIEWS = 2;
    private int mSelectedItemIndex = RecyclerView.NO_POSITION;
    private LayoutManager mLayoutManager;

    /** When true, cycling to next/previous item will go through null selection. */
    private boolean mCycleThroughNoSelection;

    public RecyclerViewSelectionController(LayoutManager layoutManager) {
        mLayoutManager = layoutManager;
    }

    /**
     * Specifies whether advancing to previous/next element should go through no selection.
     *
     * <p>Note that advancing from no selection always proceeds to the next element:
     *
     * <p>- If the flag is set to `false`, once the last possible element is selected, the user
     * cannot advance any further.
     *
     * <pre>[A] -> [B] -> [C] -> [C] -> [C] -> [C] ...</pre>
     *
     * <p>- if the flag is set to `true`, once the last possible element is selected and the user
     * advances to the next item, selection will re-start from no selection, and advance to the
     * first selectable item afterwards.
     *
     * <pre>[A] -> [B] -> [C] -> [∅] -> [A] -> [B] ...</pre>
     */
    public void setCycleThroughNoSelection(boolean cycleThroughNoSelection) {
        mCycleThroughNoSelection = cycleThroughNoSelection;
    }

    @Override
    public void onChildViewAttachedToWindow(View view) {
        // Force update selection of the view that might come from a recycle pool.
        setSelectedItem(mSelectedItemIndex);
    }

    @Override
    public void onChildViewDetachedFromWindow(View view) {
        // Force move selection to the item that now occupies slot at mSelectedItemIndex.
        // The explicit state set here is necessary, because the setSelectedItem call
        // does not iterate over all available views, so when this view is re-used,
        // we do not want it to show up as selected right away.
        view.setSelected(false);
        setSelectedItem(mSelectedItemIndex);
    }

    /** Reset the active selection. */
    public void resetSelection() {
        setSelectedItem(RecyclerView.NO_POSITION);
    }

    /**
     * Move selection to the next element on the list.
     *
     * @return true, if change resulted in an item being highlighted
     */
    public boolean selectNextItem() {
        if (mLayoutManager == null || mLayoutManager.getItemCount() == 0) return false;

        // Note: this is also the index selected if there are no more selectable views after the
        // current one.
        int nextSelectableItemIndex =
                mCycleThroughNoSelection ? RecyclerView.NO_POSITION : mSelectedItemIndex;
        int currentIndex =
                mSelectedItemIndex == RecyclerView.NO_POSITION ? 0 : mSelectedItemIndex + 1;

        while (currentIndex != mSelectedItemIndex) {
            if (currentIndex >= mLayoutManager.getItemCount()) {
                break;
            }

            var view = mLayoutManager.findViewByPosition(currentIndex);

            if (view != null && view.isFocusable()) {
                nextSelectableItemIndex = currentIndex;
                break;
            }

            currentIndex++;
        }

        setSelectedItem(nextSelectableItemIndex);
        return mSelectedItemIndex != RecyclerView.NO_POSITION;
    }

    /**
     * Move selection to the previous element on the list.
     *
     * @return true, if change resulted in an item being highlighted
     */
    public boolean selectPreviousItem() {
        if (mLayoutManager == null || mLayoutManager.getItemCount() == 0) return false;

        // Note: this is also the index selected if there are no more selectable views after the
        // current one.
        int nextSelectableItemIndex =
                mCycleThroughNoSelection ? RecyclerView.NO_POSITION : mSelectedItemIndex;
        int currentIndex =
                mSelectedItemIndex == RecyclerView.NO_POSITION
                        ? mLayoutManager.getItemCount() - 1
                        : mSelectedItemIndex - 1;

        while (currentIndex != mSelectedItemIndex) {
            if (currentIndex == RecyclerView.NO_POSITION) {
                break;
            }

            var view = mLayoutManager.findViewByPosition(currentIndex);

            if (view != null && view.isFocusable()) {
                nextSelectableItemIndex = currentIndex;
                break;
            }

            currentIndex--;
        }

        setSelectedItem(nextSelectableItemIndex);
        return mSelectedItemIndex != RecyclerView.NO_POSITION;
    }

    /** Retrieve currently selected element. */
    @Nullable
    public View getSelectedView() {
        return mLayoutManager.findViewByPosition(mSelectedItemIndex);
    }

    /**
     * Move focus to another view.
     *
     * @param index Index of the child view to be selected.
     */
    @VisibleForTesting
    public void setSelectedItem(int index) {
        if (mLayoutManager == null) return;
        if (index != RecyclerView.NO_POSITION
                && (index < 0 || index >= mLayoutManager.getItemCount())) {
            return;
        }

        View previousSelectedView = mLayoutManager.findViewByPosition(mSelectedItemIndex);
        if (previousSelectedView != null) {
            previousSelectedView.setSelected(false);
        }

        // Ensure additional views are exposed when tabbing through items.
        // This has the additional benefit of
        // - presenting the extra views to the user so they don't have to tab through to them to see
        //   them, and
        // - instantiating these extra views, so these can be evaluated when the user tabs through
        //   the list.
        // If we don't expose additional views, the user may occasionally be unable to tab through
        // the list; the LayoutManager may report <null> when we request a view at a specific
        // position, because the view is not yet bound when we need it.
        int exposeUntilViewIndex =
                (mSelectedItemIndex < index)
                        ? Math.min(index + ADVANCE_EXPOSE_VIEWS, mLayoutManager.getItemCount() - 1)
                        : Math.max(index - ADVANCE_EXPOSE_VIEWS, 0);
        mLayoutManager.scrollToPosition(exposeUntilViewIndex);

        mSelectedItemIndex = index;

        View currentSelectedView = mLayoutManager.findViewByPosition(index);
        if (currentSelectedView != null) {
            currentSelectedView.setSelected(true);
        }
    }

    /** Returns the selected item index. */
    @VisibleForTesting
    int getSelectedItemForTest() {
        return mSelectedItemIndex;
    }
}