// Copyright 2019 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.tasks.tab_management;
import static org.chromium.chrome.browser.tasks.tab_management.TabListModel.CardProperties.CARD_TYPE;
import android.app.Activity;
import android.content.Context;
import android.graphics.Rect;
import android.util.Size;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnLayoutChangeListener;
import android.view.ViewGroup;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.widget.ImageView;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ItemAnimator.ItemAnimatorFinishedListener;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.TraceEvent;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.lifecycle.DestroyObserver;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabUtils;
import org.chromium.chrome.browser.tab_ui.RecyclerViewPosition;
import org.chromium.chrome.browser.tab_ui.TabListFaviconProvider;
import org.chromium.chrome.browser.tab_ui.ThumbnailProvider;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelFilter;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_management.TabListModel.CardProperties.ModelType;
import org.chromium.chrome.browser.tasks.tab_management.TabProperties.TabActionState;
import org.chromium.chrome.browser.tasks.tab_management.TabProperties.UiType;
import org.chromium.chrome.tab_ui.R;
import org.chromium.ui.base.ViewUtils;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modelutil.MVCListAdapter;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
import org.chromium.ui.modelutil.SimpleRecyclerViewAdapter;
import org.chromium.ui.widget.ViewLookupCachingFrameLayout;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
/** Coordinator for showing UI for a list of tabs. Can be used in GRID or STRIP modes. */
public class TabListCoordinator
implements PriceMessageService.PriceWelcomeMessageProvider, DestroyObserver {
private static final String TAG = "TabListCoordinator";
/**
* Modes of showing the list of tabs.
*
* <p>NOTE: STRIP, LIST, and GRID modes will have height equal to that of the container view.
*/
@IntDef({TabListMode.GRID, TabListMode.STRIP, TabListMode.LIST, TabListMode.NUM_ENTRIES})
@Retention(RetentionPolicy.SOURCE)
public @interface TabListMode {
int GRID = 0;
int STRIP = 1;
// int CAROUSEL_DEPRECATED = 2;
int LIST = 3;
int NUM_ENTRIES = 4;
}
static final int GRID_LAYOUT_SPAN_COUNT_COMPACT = 2;
static final int GRID_LAYOUT_SPAN_COUNT_MEDIUM = 3;
static final int GRID_LAYOUT_SPAN_COUNT_LARGE = 4;
static final int MAX_SCREEN_WIDTH_COMPACT_DP = 600;
static final int MAX_SCREEN_WIDTH_MEDIUM_DP = 800;
static final float PERCENTAGE_AREA_OVERLAP_MERGE_THRESHOLD = 0.5f;
private final TabListMediator mMediator;
private final TabListRecyclerView mRecyclerView;
private final SimpleRecyclerViewAdapter mAdapter;
private final @TabListMode int mMode;
private final Context mContext;
private final BrowserControlsStateProvider mBrowserControlsStateProvider;
private final ObservableSupplier<TabModelFilter> mCurrentTabModelFilterSupplier;
private final TabListModel mModel;
private final boolean mAllowDragAndDrop;
private boolean mIsInitialized;
private OnLayoutChangeListener mListLayoutListener;
private boolean mLayoutListenerRegistered;
private @Nullable TabStripSnapshotter mTabStripSnapshotter;
private ItemTouchHelper mItemTouchHelper;
private OnItemTouchListener mOnItemTouchListener;
private TabListEmptyCoordinator mTabListEmptyCoordinator;
private boolean mHasEmptyView;
private int mEmptyStateImageResId;
private int mEmptyStateHeadingResId;
private int mEmptyStateSubheadingResId;
private boolean mIsEmptyViewInitialized;
private @Nullable Runnable mAwaitingLayoutRunnable;
private int mAwaitingTabId = Tab.INVALID_TAB_ID;
private @TabActionState int mTabActionState;
/**
* Construct a coordinator for UI that shows a list of tabs.
*
* @param mode Modes of showing the list of tabs. Can be used in GRID or STRIP.
* @param context The context to use for accessing {@link android.content.res.Resources}.
* @param browserControlsStateProvider The {@link BrowserControlsStateProvider} for top
* controls.
* @param modalDialogManager Used for managing the modal dialogs.
* @param tabModelFilterSupplier The supplier for the current tab model filter.
* @param thumbnailProvider Provider to provide screenshot related details.
* @param actionOnRelatedTabs Whether tab-related actions should be operated on all related
* tabs.
* @param gridCardOnClickListenerProvider Provides the onClickListener for opening dialog when
* click on a grid card.
* @param dialogHandler A handler to handle requests about updating TabGridDialog.
* @param initialTabActionState The initial {@link TabActionState} to use for the shown tabs.
* Must always be CLOSABLE for TabListMode.STRIP.
* @param selectionDelegateProvider Provider to provide selected Tabs for a selectable tab list.
* It's NULL when selection is not possible.
* @param priceWelcomeMessageControllerSupplier A supplier for a controller to show
* PriceWelcomeMessage.
* @param parentView {@link ViewGroup} The root view of the UI.
* @param attachToParent Whether the UI should attach to root view.
* @param componentName A unique string uses to identify different components for UMA recording.
* Recommended to use the class name or make sure the string is unique through actions.xml
* file.
* @param onModelTokenChange Callback to invoke whenever a model changes. Only currently
* respected in TabListMode.STRIP mode.
* @param allowDragAndDrop Whether to allow drag and drop for this tab list coordinator.
*/
TabListCoordinator(
@TabListMode int mode,
Context context,
@NonNull BrowserControlsStateProvider browserControlsStateProvider,
@NonNull ModalDialogManager modalDialogManager,
@NonNull ObservableSupplier<TabModelFilter> tabModelFilterSupplier,
@Nullable ThumbnailProvider thumbnailProvider,
boolean actionOnRelatedTabs,
@Nullable
TabListMediator.GridCardOnClickListenerProvider gridCardOnClickListenerProvider,
@Nullable TabListMediator.TabGridDialogHandler dialogHandler,
@TabActionState int initialTabActionState,
@Nullable TabListMediator.SelectionDelegateProvider selectionDelegateProvider,
@NonNull Supplier<PriceWelcomeMessageController> priceWelcomeMessageControllerSupplier,
@NonNull ViewGroup parentView,
boolean attachToParent,
String componentName,
@Nullable Callback<Object> onModelTokenChange,
boolean allowDragAndDrop) {
this(
mode,
context,
browserControlsStateProvider,
modalDialogManager,
tabModelFilterSupplier,
thumbnailProvider,
actionOnRelatedTabs,
gridCardOnClickListenerProvider,
dialogHandler,
initialTabActionState,
selectionDelegateProvider,
priceWelcomeMessageControllerSupplier,
parentView,
attachToParent,
componentName,
onModelTokenChange,
false,
0,
0,
0,
/* onTabGroupCreation= */ null,
/* allowDragAndDrop= */ allowDragAndDrop);
}
TabListCoordinator(
@TabListMode int mode,
Context context,
@NonNull BrowserControlsStateProvider browserControlsStateProvider,
@NonNull ModalDialogManager modalDialogManager,
@NonNull ObservableSupplier<TabModelFilter> tabModelFilterSupplier,
@Nullable ThumbnailProvider thumbnailProvider,
boolean actionOnRelatedTabs,
@Nullable
TabListMediator.GridCardOnClickListenerProvider gridCardOnClickListenerProvider,
@Nullable TabListMediator.TabGridDialogHandler dialogHandler,
@TabActionState int initialTabActionState,
@Nullable TabListMediator.SelectionDelegateProvider selectionDelegateProvider,
@NonNull Supplier<PriceWelcomeMessageController> priceWelcomeMessageControllerSupplier,
@NonNull ViewGroup parentView,
boolean attachToParent,
String componentName,
@Nullable Callback<Object> onModelTokenChange,
boolean hasEmptyView,
int emptyImageResId,
int emptyHeadingStringResId,
int emptySubheadingStringResId,
@Nullable Runnable onTabGroupCreation,
boolean allowDragAndDrop) {
mMode = mode;
mTabActionState = initialTabActionState;
mContext = context;
mBrowserControlsStateProvider = browserControlsStateProvider;
mCurrentTabModelFilterSupplier = tabModelFilterSupplier;
mModel = new TabListModel();
mAdapter = new SimpleRecyclerViewAdapter(mModel);
mAllowDragAndDrop = allowDragAndDrop;
RecyclerView.RecyclerListener recyclerListener = null;
if (mMode == TabListMode.GRID) {
mAdapter.registerType(
UiType.TAB,
parent -> {
ViewGroup group =
(ViewGroup)
LayoutInflater.from(context)
.inflate(
R.layout.tab_grid_card_item,
parentView,
false);
group.setClickable(true);
return group;
},
TabGridViewBinder::bindTab);
recyclerListener =
(holder) -> {
int holderItemViewType = holder.getItemViewType();
// TODO(crbug.com/40949143): Convert this logic block to a callback.
// If a custom message card item type is present, ensure that all attached
// child views are removed when the card is recycled.
if (holderItemViewType == UiType.CUSTOM_MESSAGE) {
CustomMessageCardView view = (CustomMessageCardView) holder.itemView;
view.removeAllViews();
}
if (holderItemViewType != UiType.TAB) {
return;
}
ViewLookupCachingFrameLayout root =
(ViewLookupCachingFrameLayout) holder.itemView;
ImageView thumbnail = (ImageView) root.fastFindViewById(R.id.tab_thumbnail);
if (thumbnail == null) return;
thumbnail.setImageDrawable(null);
};
} else if (mMode == TabListMode.STRIP) {
mAdapter.registerType(
UiType.STRIP,
parent -> {
return (ViewGroup)
LayoutInflater.from(context)
.inflate(R.layout.tab_strip_item, parentView, false);
},
TabStripViewBinder::bind);
} else if (mMode == TabListMode.LIST) {
mAdapter.registerType(
UiType.TAB,
parent -> {
ViewLookupCachingFrameLayout group =
(ViewLookupCachingFrameLayout)
LayoutInflater.from(context)
.inflate(
R.layout.tab_list_card_item,
parentView,
false);
group.setClickable(true);
return group;
},
TabListViewBinder::bindTab);
} else {
throw new IllegalArgumentException(
"Attempting to create a tab list UI with invalid mode");
}
// TODO (https://crbug.com/1048632): Use the current profile (i.e., regular profile or
// incognito profile) instead of always using regular profile. It works correctly now, but
// it is not safe.
TabListFaviconProvider tabListFaviconProvider =
new TabListFaviconProvider(
mContext,
mMode == TabListMode.STRIP,
R.dimen.default_favicon_corner_radius);
TabModelFilter currentFilter = mCurrentTabModelFilterSupplier.get();
ActionConfirmationManager actionConfirmationManager =
new ActionConfirmationManager(
currentFilter.getTabModel().getProfile().getOriginalProfile(),
mContext,
(TabGroupModelFilter) currentFilter,
modalDialogManager);
mMediator =
new TabListMediator(
context,
mModel,
mMode,
modalDialogManager,
tabModelFilterSupplier,
thumbnailProvider,
tabListFaviconProvider,
new TabGroupColorFaviconProvider(mContext),
actionOnRelatedTabs,
selectionDelegateProvider,
gridCardOnClickListenerProvider,
dialogHandler,
priceWelcomeMessageControllerSupplier,
componentName,
initialTabActionState,
actionConfirmationManager,
onTabGroupCreation);
try (TraceEvent e = TraceEvent.scoped("TabListCoordinator.setupRecyclerView")) {
// Ignore attachToParent initially. In some contexts multiple TabListCoordinators are
// created with the same parentView. Using attachToParent and subsequently trying to
// locate the View with findViewById could then resolve to the wrong view. Instead use
// LayoutInflater to return the inflated view and addView to circumvent the issue.
mRecyclerView =
(TabListRecyclerView)
LayoutInflater.from(context)
.inflate(
R.layout.tab_list_recycler_view_layout,
parentView,
/* attachToParent= */ false);
if (attachToParent) {
parentView.addView(mRecyclerView);
}
mRecyclerView.setAdapter(mAdapter);
mRecyclerView.setHasFixedSize(true);
if (recyclerListener != null) mRecyclerView.setRecyclerListener(recyclerListener);
if (mMode == TabListMode.GRID) {
GridLayoutManager gridLayoutManager =
new GridLayoutManager(context, GRID_LAYOUT_SPAN_COUNT_COMPACT) {
@Override
public void onLayoutCompleted(RecyclerView.State state) {
super.onLayoutCompleted(state);
checkAwaitingLayout();
}
};
mRecyclerView.setLayoutManager(gridLayoutManager);
mMediator.registerOrientationListener(gridLayoutManager);
mMediator.updateSpanCount(
gridLayoutManager, context.getResources().getConfiguration().screenWidthDp);
mMediator.setupAccessibilityDelegate(mRecyclerView);
Rect frame = new Rect();
((Activity) mRecyclerView.getContext())
.getWindow()
.getDecorView()
.getWindowVisibleDisplayFrame(frame);
updateGridCardLayout(frame.width());
} else if (mMode == TabListMode.STRIP
|| mMode == TabListMode.LIST) {
LinearLayoutManager layoutManager =
new LinearLayoutManager(
context,
mMode == TabListMode.LIST
? LinearLayoutManager.VERTICAL
: LinearLayoutManager.HORIZONTAL,
false) {
@Override
public void onLayoutCompleted(RecyclerView.State state) {
super.onLayoutCompleted(state);
checkAwaitingLayout();
}
};
mRecyclerView.setLayoutManager(layoutManager);
}
mMediator.setRecyclerViewItemAnimationToggle(mRecyclerView::setDisableItemAnimations);
}
if (mMode == TabListMode.GRID) {
mListLayoutListener =
(view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
updateGridCardLayout(right - left);
} else if (mMode == TabListMode.STRIP) {
mTabStripSnapshotter =
new TabStripSnapshotter(onModelTokenChange, mModel, mRecyclerView);
}
mHasEmptyView = hasEmptyView;
if (mHasEmptyView) {
mTabListEmptyCoordinator =
new TabListEmptyCoordinator(
parentView, mModel, this::runOnItemAnimatorFinished);
mEmptyStateHeadingResId = emptyHeadingStringResId;
mEmptyStateSubheadingResId = emptySubheadingStringResId;
mEmptyStateImageResId = emptyImageResId;
}
configureRecyclerViewTouchHelpers(mMode, mTabActionState);
}
/** Returns the {@link TabListMode} of the coordinator. */
public @TabListMode int getTabListMode() {
return mMode;
}
/**
* @param onLongPressTabItemEventListener to handle long press events on tabs.
*/
public void setOnLongPressTabItemEventListener(
@Nullable
TabGridItemTouchHelperCallback.OnLongPressTabItemEventListener
onLongPressTabItemEventListener) {
assert mMediator != null;
mMediator.setOnLongPressTabItemEventListener(onLongPressTabItemEventListener);
}
/** Sets the current {@link TabActionState} for the TabList. */
public void setTabActionState(@TabActionState int tabActionState) {
assert mMediator != null;
mTabActionState = tabActionState;
configureRecyclerViewTouchHelpers(mMode, mTabActionState);
mMediator.setTabActionState(tabActionState);
}
@NonNull
Rect getThumbnailLocationOfCurrentTab() {
// TODO(crbug.com/40627995): calculate the location before the real one is ready.
Rect rect =
mRecyclerView.getRectOfCurrentThumbnail(
mModel.indexFromId(mMediator.selectedTabId()), mMediator.selectedTabId());
if (rect == null) return new Rect();
rect.offset(0, 0);
return rect;
}
/**
* @param tabId The tab ID to get a rect for.
* @return a {@link Rect} for the tab's thumbnail (may be an empty rect if the tab is not
* found).
*/
@NonNull
Rect getTabThumbnailRect(int tabId) {
int index = getIndexForTabId(tabId);
if (index == TabModel.INVALID_TAB_INDEX) return new Rect();
return mRecyclerView.getRectOfTabThumbnail(
index, mModel.get(index).model.get(TabProperties.TAB_ID));
}
@NonNull
Size getThumbnailSize() {
Size size = mMediator.getDefaultGridCardSize();
return TabUtils.deriveThumbnailSize(size, mContext);
}
void waitForLayoutWithTab(int tabId, Runnable r) {
// Very fast navigations to/from the tab list may not have time for a layout to reach a
// completed state. Since this is primarily used for cancellable or skippable animations
// where the runnable will not be serviced downstream, dropping the runnable altogether is
// safe.
if (mAwaitingLayoutRunnable != null) {
Log.d(TAG, "Dropping AwaitingLayoutRunnable for " + mAwaitingTabId);
mAwaitingLayoutRunnable = null;
mAwaitingTabId = Tab.INVALID_TAB_ID;
}
int index = getIndexForTabId(tabId);
if (index == TabModel.INVALID_TAB_INDEX) {
r.run();
return;
}
mAwaitingLayoutRunnable = r;
mAwaitingTabId = mModel.get(index).model.get(TabProperties.TAB_ID);
mRecyclerView.runOnNextLayout(this::checkAwaitingLayout);
}
@NonNull
Rect getRecyclerViewLocation() {
Rect recyclerViewRect = new Rect();
mRecyclerView.getGlobalVisibleRect(recyclerViewRect);
return recyclerViewRect;
}
/**
* @return the position and offset of the first visible element in the list.
*/
@NonNull
RecyclerViewPosition getRecyclerViewPosition() {
return mRecyclerView.getRecyclerViewPosition();
}
/**
* @param recyclerViewPosition the position and offset to scroll the recycler view to.
*/
void setRecyclerViewPosition(@NonNull RecyclerViewPosition recyclerViewPosition) {
mRecyclerView.setRecyclerViewPosition(recyclerViewPosition);
}
void initWithNative(@NonNull Profile profile) {
if (mIsInitialized) return;
try (TraceEvent e = TraceEvent.scoped("TabListCoordinator.initWithNative")) {
mIsInitialized = true;
assert !profile.isOffTheRecord() : "Expecting a non-incognito profile.";
mMediator.initWithNative(profile);
}
}
private void configureRecyclerViewTouchHelpers(
@TabListMode int mode, @TabActionState int tabActionState) {
boolean modeAllowsDragAndDrop = mMode == TabListMode.GRID || mMode == TabListMode.LIST;
boolean actionStateAllowsDragAndDrop = mTabActionState != TabActionState.SELECTABLE;
if (mAllowDragAndDrop && modeAllowsDragAndDrop && actionStateAllowsDragAndDrop) {
if (mItemTouchHelper == null || mOnItemTouchListener == null) {
TabGridItemTouchHelperCallback callback =
(TabGridItemTouchHelperCallback)
mMediator.getItemTouchHelperCallback(
mContext.getResources()
.getDimension(R.dimen.swipe_to_dismiss_threshold),
PERCENTAGE_AREA_OVERLAP_MERGE_THRESHOLD,
mContext.getResources()
.getDimension(R.dimen.bottom_sheet_peek_height));
// Creates an instance of the ItemTouchHelper using TabGridItemTouchHelperCallback
// and attach a downsteam mOnItemTouchListener that watches for
// TabGridItemTouchHelperCallback#shouldBlockAction() to occur. This determines if
// on a longpress the final MOTION_UP event should be intercepted if it should have
// been filtered in the ItemTouchHelper, but was not handled. This then allows
// the mOnItemTouchHelper to intercept the event and prevent subsequent downstream
// click handlers from receiving an input possibly causing unexpected behaviors.
//
// See similar comments in TabGridItemTouchHelperCallback for more details.
mItemTouchHelper = new ItemTouchHelper(callback);
mOnItemTouchListener =
new OnItemTouchListener() {
@Override
public boolean onInterceptTouchEvent(
RecyclerView recyclerView, MotionEvent event) {
// There can be an edge case when adding the block action logic
// where minimal movement not picked up by the mItemTouchHelper
// can result in attempting to block an action that did have a
// DRAG event.
// Actually, blocking the next event in this can result in an
// unexpected event being consumed leading to an unexpected
// sequence of MotionEvents.
// This bad sequence can then result in invalid UI & click state for
// downstream touch handlers. This additional check ensures that for
// a given action, if a block is requested it must be the UP
// motion that ends the input.
if (callback.shouldBlockAction()
&& (event.getActionMasked() == MotionEvent.ACTION_UP
|| event.getActionMasked()
== MotionEvent.ACTION_POINTER_UP)) {
return true;
}
return false;
}
@Override
public void onTouchEvent(
RecyclerView recyclerView, MotionEvent event) {}
@Override
public void onRequestDisallowInterceptTouchEvent(
boolean disallowIntercept) {
// If a child component does not allow this recyclerView and any
// parent components to intercept touch events, shouldBlockAction
// should be called anyways to reset the tracking boolean.
// Otherwise, the original intercept method will do the check.
if (!disallowIntercept) return;
callback.shouldBlockAction();
}
};
}
mItemTouchHelper.attachToRecyclerView(mRecyclerView);
mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
} else {
if (mItemTouchHelper != null && mOnItemTouchListener != null) {
mItemTouchHelper.attachToRecyclerView(null);
mRecyclerView.removeOnItemTouchListener(mOnItemTouchListener);
}
}
}
private void updateGridCardLayout(int viewWidth) {
// Determine and set span count
final GridLayoutManager layoutManager =
(GridLayoutManager) mRecyclerView.getLayoutManager();
boolean updatedSpan =
mMediator.updateSpanCount(
layoutManager, mContext.getResources().getConfiguration().screenWidthDp);
if (updatedSpan) {
// Update the cards for the span change.
ViewUtils.requestLayout(mRecyclerView, "TabListCoordinator#updateGridCardLayout");
}
// Determine grid card width and account for margins on left and right.
final int cardWidthPx =
((viewWidth - mRecyclerView.getPaddingStart() - mRecyclerView.getPaddingEnd())
/ layoutManager.getSpanCount());
final int cardHeightPx =
TabUtils.deriveGridCardHeight(cardWidthPx, mContext, mBrowserControlsStateProvider);
final Size oldDefaultSize = mMediator.getDefaultGridCardSize();
final Size newDefaultSize = new Size(cardWidthPx, cardHeightPx);
if (oldDefaultSize != null && newDefaultSize.equals(oldDefaultSize)) return;
mMediator.setDefaultGridCardSize(newDefaultSize);
for (int i = 0; i < mModel.size(); i++) {
PropertyModel tabPropertyModel = mModel.get(i).model;
// Other GTS items might intentionally have different dimensions. For example, the
// pre-selected tab group divider and the large price tracking message span the width of
// the recycler view.
if (tabPropertyModel.get(CARD_TYPE) == ModelType.TAB) {
tabPropertyModel.set(
TabProperties.GRID_CARD_SIZE, new Size(cardWidthPx, cardHeightPx));
}
}
}
/**
* @see TabListMediator#getPriceWelcomeMessageInsertionIndex().
*/
int getPriceWelcomeMessageInsertionIndex() {
return mMediator.getPriceWelcomeMessageInsertionIndex();
}
/**
* @return The container {@link androidx.recyclerview.widget.RecyclerView} that is showing the
* tab list UI.
*/
public TabListRecyclerView getContainerView() {
return mRecyclerView;
}
/**
* @return The editor {@link TabGroupTitleEditor} that is used to update tab group title.
*/
TabGroupTitleEditor getTabGroupTitleEditor() {
return mMediator.getTabGroupTitleEditor();
}
/**
* @see TabListMediator#resetWithListOfTabs(List, boolean)
*/
boolean resetWithListOfTabs(@Nullable List<Tab> tabs, boolean quickMode) {
return mMediator.resetWithListOfTabs(tabs, quickMode);
}
void softCleanup() {
mMediator.softCleanup();
}
void hardCleanup() {
mMediator.hardCleanup();
}
private void registerLayoutChangeListener() {
if (mListLayoutListener != null) {
// TODO(crbug.com/40288028): There might be a timing or race condition that
// LayoutListener
// has been registered while it shouldn't be with Start surface refactor is enabled.
if (mLayoutListenerRegistered) return;
mLayoutListenerRegistered = true;
mRecyclerView.addOnLayoutChangeListener(mListLayoutListener);
}
}
private void unregisterLayoutChangeListener() {
if (mListLayoutListener != null) {
if (!mLayoutListenerRegistered) return;
mRecyclerView.removeOnLayoutChangeListener(mListLayoutListener);
mLayoutListenerRegistered = false;
}
}
void prepareTabSwitcherPaneView() {
registerLayoutChangeListener();
mRecyclerView.setupCustomItemAnimator();
mMediator.registerOnScrolledListener(mRecyclerView);
}
private void initializeEmptyStateView() {
if (mIsEmptyViewInitialized) {
return;
}
if (mHasEmptyView && mTabListEmptyCoordinator != null) {
mTabListEmptyCoordinator.initializeEmptyStateView(
mEmptyStateImageResId, mEmptyStateHeadingResId, mEmptyStateSubheadingResId);
mTabListEmptyCoordinator.attachEmptyView();
mIsEmptyViewInitialized = true;
}
}
public void prepareTabGridView() {
registerLayoutChangeListener();
mRecyclerView.setupCustomItemAnimator();
}
public void cleanupTabGridView() {
unregisterLayoutChangeListener();
}
public void destroyEmptyView() {
if (mHasEmptyView && mTabListEmptyCoordinator != null) {
mTabListEmptyCoordinator.destroyEmptyView();
mIsEmptyViewInitialized = false;
}
}
public void attachEmptyView() {
if (!mIsEmptyViewInitialized) {
initializeEmptyStateView();
}
if (mHasEmptyView && mTabListEmptyCoordinator != null) {
mTabListEmptyCoordinator.setIsTabSwitcherShowing(true);
}
}
void postHiding() {
unregisterLayoutChangeListener();
mMediator.postHiding();
if (mHasEmptyView && mTabListEmptyCoordinator != null) {
mTabListEmptyCoordinator.setIsTabSwitcherShowing(false);
}
}
/** Destroy any members that needs clean up. */
@Override
public void onDestroy() {
mMediator.destroy();
destroyEmptyView();
if (mTabListEmptyCoordinator != null) {
mTabListEmptyCoordinator.removeListObserver();
}
if (mListLayoutListener != null) {
mRecyclerView.removeOnLayoutChangeListener(mListLayoutListener);
mLayoutListenerRegistered = false;
}
mRecyclerView.setRecyclerListener(null);
if (mTabStripSnapshotter != null) {
mTabStripSnapshotter.destroy();
}
if (mItemTouchHelper != null) {
mItemTouchHelper.attachToRecyclerView(null);
}
if (mOnItemTouchListener != null) {
mRecyclerView.removeOnItemTouchListener(mOnItemTouchListener);
}
}
/**
* Register a new view type for the component.
*
* @see MVCListAdapter#registerType(int, MVCListAdapter.ViewBuilder,
* PropertyModelChangeProcessor.ViewBinder).
*/
<T extends View> void registerItemType(
@UiType int typeId,
MVCListAdapter.ViewBuilder<T> builder,
PropertyModelChangeProcessor.ViewBinder<PropertyModel, T, PropertyKey> binder) {
mAdapter.registerType(typeId, builder, binder);
}
/**
* Inserts a special {@link org.chromium.ui.modelutil.MVCListAdapter.ListItem} at given index of
* the model list.
* @see TabListMediator#addSpecialItemToModel(int, int, PropertyModel).
*/
void addSpecialListItem(int index, @UiType int uiType, PropertyModel model) {
mMediator.addSpecialItemToModel(index, uiType, model);
}
/**
* Inserts a special {@link org.chromium.ui.modelutil.MVCListAdapter.ListItem} to the end of
* model list.
*/
void addSpecialListItemToEnd(@UiType int uiType, PropertyModel model) {
mMediator.addSpecialItemToModel(mModel.size(), uiType, model);
}
/**
* Removes a special {@link org.chromium.ui.modelutil.MVCListAdapter.ListItem} that
* has the given {@code uiType} and/or its {@link PropertyModel} has the given
* {@code itemIdentifier}.
*
* @param uiType The uiType to match.
* @param itemIdentifier The itemIdentifier to match. This can be obsoleted if the {@link
* org.chromium.ui.modelutil.MVCListAdapter.ListItem} does not need additional
* identifier.
*/
void removeSpecialListItem(@UiType int uiType, int itemIdentifier) {
mMediator.removeSpecialItemFromModel(uiType, itemIdentifier);
}
// PriceWelcomeMessageService.PriceWelcomeMessageProvider implementation.
@Override
public int getTabIndexFromTabId(int tabId) {
return mModel.indexFromId(tabId);
}
@Override
public void showPriceDropTooltip(int index) {
mModel.get(index).model.set(TabProperties.SHOULD_SHOW_PRICE_DROP_TOOLTIP, true);
}
int getIndexOfNthTabCard(int index) {
return mMediator.getIndexOfNthTabCard(index);
}
/** Returns the filter index of a tab from its view index or TabList.INVALID_TAB_INDEX. */
int indexOfTabCardsOrInvalid(int index) {
return mMediator.indexOfTabCardsOrInvalid(index);
}
int getTabListModelSize() {
return mModel.size();
}
/**
* @see TabListMediator#specialItemExistsInModel(int)
*/
boolean specialItemExists(@MessageService.MessageType int itemIdentifier) {
return mMediator.specialItemExistsInModel(itemIdentifier);
}
boolean isLastItemMessage() {
return mMediator.isLastItemMessage();
}
private void checkAwaitingLayout() {
if (mAwaitingLayoutRunnable != null) {
SimpleRecyclerViewAdapter.ViewHolder holder =
(SimpleRecyclerViewAdapter.ViewHolder)
mRecyclerView.findViewHolderForAdapterPosition(
mModel.indexFromId(mAwaitingTabId));
if (holder == null) return;
assert holder.model.get(TabProperties.TAB_ID) == mAwaitingTabId;
Runnable r = mAwaitingLayoutRunnable;
mAwaitingTabId = Tab.INVALID_TAB_ID;
mAwaitingLayoutRunnable = null;
r.run();
}
}
private int getIndexForTabId(int tabId) {
int index = mModel.indexFromId(tabId);
if (index != TabModel.INVALID_TAB_INDEX) return index;
TabModel tabModel = mCurrentTabModelFilterSupplier.get().getTabModel();
Tab tab = tabModel.getTabById(tabId);
if (tab == null) return TabModel.INVALID_TAB_INDEX;
return mMediator.getIndexForTabWithRelatedTabs(tab);
}
void showQuickDeleteAnimation(Runnable onAnimationEnd, List<Tab> tabs) {
assert mMode == TabListMode.GRID : "Can only run animation in GRID mode.";
mMediator.showQuickDeleteAnimation(onAnimationEnd, tabs, mRecyclerView);
}
void showCloseAllTabsAnimation(Runnable closeTabs) {
@Nullable var itemAnimator = mRecyclerView.getItemAnimator();
if (itemAnimator == null) {
closeTabs.run();
return;
}
// Temporarily double the duration of the animation until it is finished then reset the
// behavior to the default duration.
itemAnimator.setRemoveDuration(TabListItemAnimator.DEFAULT_REMOVE_DURATION * 2);
closeTabs.run();
Runnable restoreRemoveDuration =
() -> {
itemAnimator.setRemoveDuration(TabListItemAnimator.DEFAULT_REMOVE_DURATION);
};
runOnItemAnimatorFinished(restoreRemoveDuration);
}
/** Runs a runnable after the item animator has finished its animations. */
void runOnItemAnimatorFinished(Runnable r) {
Runnable attachListener =
() -> {
// The item animator sometimes gets removed. If this happens run immediately.
@Nullable var itemAnimator = mRecyclerView.getItemAnimator();
if (itemAnimator == null) {
r.run();
return;
}
// Create a listener that is executed once the item animator is done all its
// animations.
var listener =
new ItemAnimatorFinishedListener() {
@Override
public void onAnimationsFinished() {
r.run();
}
};
itemAnimator.isRunning(listener);
};
// Delay attaching the listener in two ways:
// 1) Post so that the current model updates in the current task complete before we attempt
// anything.
// 2) Attach the listener only after the adapter has flushed any pending updates so
// animations have actually started.
mRecyclerView.post(() -> runAfterAdapterUpdates(attachListener));
}
/**
* Runs a runnable after the recycler view adapter has flushed any pending updates and started
* animations for them.
*/
private void runAfterAdapterUpdates(Runnable r) {
if (!mRecyclerView.hasPendingAdapterUpdates()) {
r.run();
return;
}
// It is unfortunate that a global layout listener is required, but we need to wait for
// views to be added/removed/rearranged as there is no other signal that pending updates
// were applied.
mRecyclerView
.getViewTreeObserver()
.addOnGlobalLayoutListener(
new OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// Keep waiting until all updates are applied.
if (mRecyclerView.hasPendingAdapterUpdates()) {
return;
}
mRecyclerView
.getViewTreeObserver()
.removeOnGlobalLayoutListener(this);
r.run();
}
});
}
}