// Copyright 2015 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.overlays.strip;
import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import android.view.View;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ArrayAdapter;
import android.widget.ListPopupWindow;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.res.ResourcesCompat;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.MathUtils;
import org.chromium.base.Token;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.compositor.LayerTitleCache;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerHost;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerImpl;
import org.chromium.chrome.browser.compositor.layouts.LayoutRenderHost;
import org.chromium.chrome.browser.compositor.layouts.LayoutUpdateHost;
import org.chromium.chrome.browser.compositor.layouts.components.CompositorButton;
import org.chromium.chrome.browser.compositor.layouts.components.CompositorButton.CompositorOnClickHandler;
import org.chromium.chrome.browser.compositor.layouts.components.TintedCompositorButton;
import org.chromium.chrome.browser.compositor.layouts.phone.stack.StackScroller;
import org.chromium.chrome.browser.compositor.overlays.strip.StripLayoutGroupTitle.StripLayoutGroupTitleDelegate;
import org.chromium.chrome.browser.compositor.overlays.strip.StripLayoutTab.StripLayoutTabDelegate;
import org.chromium.chrome.browser.compositor.overlays.strip.TabLoadTracker.TabLoadTrackerCallback;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.layouts.animation.CompositorAnimator;
import org.chromium.chrome.browser.layouts.components.VirtualView;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncFeatures;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncIphController;
import org.chromium.chrome.browser.tabmodel.TabClosureParams;
import org.chromium.chrome.browser.tabmodel.TabCreator;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupColorUtils;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilterObserver;
import org.chromium.chrome.browser.tasks.tab_management.ActionConfirmationManager;
import org.chromium.chrome.browser.tasks.tab_management.ActionConfirmationManager.ConfirmationResult;
import org.chromium.chrome.browser.tasks.tab_management.ColorPickerUtils;
import org.chromium.chrome.browser.tasks.tab_management.TabGroupTitleEditor;
import org.chromium.chrome.browser.tasks.tab_management.TabUiFeatureUtilities;
import org.chromium.chrome.browser.tasks.tab_management.TabUiThemeProvider;
import org.chromium.chrome.browser.tasks.tab_management.TabUiThemeUtil;
import org.chromium.chrome.browser.user_education.UserEducationHelper;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.tab_groups.TabGroupColorId;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.ui.MotionEventUtils;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.interpolators.Interpolators;
import org.chromium.ui.util.ColorUtils;
import org.chromium.ui.widget.RectProvider;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* This class handles managing the positions and behavior of all tabs in a tab strip. It is
* responsible for both responding to UI input events and model change notifications, adjusting and
* animating the tab strip as required.
*
* <p>The stacking and visual behavior is driven by setting a {@link StripStacker}.
*/
public class StripLayoutHelper implements StripLayoutTabDelegate, StripLayoutGroupTitleDelegate {
// Drag Constants
private static final int REORDER_SCROLL_NONE = 0;
private static final int REORDER_SCROLL_LEFT = 1;
private static final int REORDER_SCROLL_RIGHT = 2;
// Behavior Constants
private static final float EPSILON = 0.001f;
private static final float REORDER_OVERLAP_SWITCH_PERCENTAGE = 0.53f;
private static final float DROP_INTO_GROUP_MAX_OFFSET = 36.f;
// Animation/Timer Constants
private static final int RESIZE_DELAY_MS = 1500;
private static final int SPINNER_UPDATE_DELAY_MS = 66;
// Degrees per millisecond.
private static final float SPINNER_DPMS = 0.33f;
private static final int ANIM_FOLIO_DETACH_MS = 75;
private static final int ANIM_TAB_CREATED_MS = 150;
private static final int ANIM_TAB_CLOSED_MS = 150;
private static final int ANIM_TAB_RESIZE_MS = 250;
private static final int ANIM_TAB_DRAW_X_MS = 250;
private static final int ANIM_TAB_MOVE_MS = 125;
private static final int ANIM_TAB_SLIDE_OUT_MS = 250;
private static final int ANIM_BUTTONS_FADE_MS = 150;
private static final long INVALID_TIME = 0L;
private static final int ANIM_HOVERED_TAB_CONTAINER_FADE_MS = 200;
static final long DROP_INTO_GROUP_MS = 300L;
// Visibility Constants
private static final float TAB_OVERLAP_WIDTH_LARGE_DP = 28.f;
private static final float TAB_WIDTH_MEDIUM = 156.f;
private static final float REORDER_EDGE_SCROLL_MAX_SPEED_DP = 1000.f;
private static final float REORDER_EDGE_SCROLL_START_MIN_DP = 87.4f;
private static final float REORDER_EDGE_SCROLL_START_MAX_DP = 18.4f;
private static final float NEW_TAB_BUTTON_BACKGROUND_Y_OFFSET_DP = 3.f;
private static final float NEW_TAB_BUTTON_CLICK_SLOP_DP = 8.f;
private static final float NEW_TAB_BUTTON_BACKGROUND_WIDTH_DP = 32.f;
private static final float NEW_TAB_BUTTON_BACKGROUND_HEIGHT_DP = 32.f;
@VisibleForTesting static final float FOLIO_ATTACHED_BOTTOM_MARGIN_DP = 0.f;
private static final float FOLIO_ANIM_INTERMEDIATE_MARGIN_DP = -12.f;
@VisibleForTesting static final float FOLIO_DETACHED_BOTTOM_MARGIN_DP = 4.f;
private static final float BUTTON_DESIRED_TOUCH_TARGET_SIZE = 48.f;
// Desired spacing between new tab button and tabs when tab strip is not full.
private static final float NEW_TAB_BUTTON_X_OFFSET_TOWARDS_TABS = 4.f;
private static final float DESIRED_PADDING_BETWEEN_NEW_TAB_BUTTON_AND_TABS = 2.f;
private static final float NEW_TAB_BUTTON_DEFAULT_PRESSED_OPACITY = 0.2f;
private static final float NEW_TAB_BUTTON_HOVER_BACKGROUND_PRESSED_OPACITY = 0.12f;
private static final float NEW_TAB_BUTTON_HOVER_BACKGROUND_DEFAULT_OPACITY = 0.08f;
static final float TAB_OPACITY_HIDDEN = 0.f;
static final float TAB_OPACITY_VISIBLE_BACKGROUND = 0.55f;
static final float TAB_OPACITY_VISIBLE_FOREGROUND = 1.f;
static final float FADE_FULL_OPACITY_THRESHOLD_DP = 24.f;
private static final float TAB_STRIP_TAB_WIDTH = 108.f;
private static final float NEW_TAB_BUTTON_WITH_MODEL_SELECTOR_BUTTON_PADDING = 8.f;
// The bottom indicator should align with the contents of the last tab in group. This value is
// calculated as:
// closeButtonEndPadding(10) + tabContainerEndPadding(16) + groupTitleStartMargin(13)
// - overlap(28-16) =
@VisibleForTesting static final float TAB_GROUP_BOTTOM_INDICATOR_WIDTH_OFFSET = 27.f;
private static final int MESSAGE_RESIZE = 1;
private static final int MESSAGE_UPDATE_SPINNER = 2;
private static final float CLOSE_BTN_VISIBILITY_THRESHOLD_START = 96.f;
private static final long TAB_SWITCH_METRICS_MAX_ALLOWED_SCROLL_INTERVAL =
DateUtils.MINUTE_IN_MILLIS;
// Histogram Constants
private static final String PLACEHOLDER_LEFTOVER_TABS_HISTOGRAM_NAME =
"Android.TabStrip.PlaceholderStripLeftoverTabsCount";
private static final String PLACEHOLDER_TABS_CREATED_DURING_RESTORE_HISTOGRAM_NAME =
"Android.TabStrip.PlaceholderStripTabsCreatedDuringRestoreCount";
private static final String PLACEHOLDER_TABS_NEEDED_DURING_RESTORE_HISTOGRAM_NAME =
"Android.TabStrip.PlaceholderStripTabsNeededDuringRestoreCount";
private static final String PLACEHOLDER_VISIBLE_DURATION_HISTOGRAM_NAME =
"Android.TabStrip.PlaceholderStripVisibleDuration";
// An observer that is notified of changes to a {@link TabGroupModelFilter} object.
private final TabGroupModelFilterObserver mTabGroupModelFilterObserver =
new TabGroupModelFilterObserver() {
int mSourceRootId = Tab.INVALID_TAB_ID;
@Override
public void willMoveTabGroup(int tabModelOldIndex, int tabModelNewIndex) {
mMovingGroup = true;
}
@Override
public void didMoveTabGroup(
Tab movedTab, int tabModelOldIndex, int tabModelNewIndex) {
mMovingGroup = false;
}
@Override
public void didMergeTabToGroup(Tab movedTab, int selectedTabIdInGroup) {
int rootId = movedTab.getRootId();
updateGroupTitleText(rootId);
// Removing the tab at the end of a group through the GTS will result in the
// width of a group changing without a tab moving. This means a rebuild won't
// occur, and we'll need to manually update the bottom indicator here.
if (!mInReorderMode) {
finishAnimations();
computeAndUpdateTabOrders(false, false);
mRenderHost.requestRender();
}
// Tab merging should not automatically expand a collapsed tab group. If the
// target group is collapsed, the tab being merged should also be collapsed.
StripLayoutGroupTitle groupTitle = findGroupTitle(rootId);
if (groupTitle != null) {
updateTabCollapsed(
findTabById(movedTab.getId()), groupTitle.isCollapsed(), false);
}
}
@Override
public void willMoveTabOutOfGroup(Tab movedTab, int newRootId) {
// TODO(crbug.com/326494015): Refactor #didMoveTabOutOfGroup to pass in previous
// root ID.
mSourceRootId = movedTab.getRootId();
}
@Override
public void didMoveTabOutOfGroup(Tab movedTab, int prevFilterIndex) {
updateGroupTitleText(mSourceRootId);
// Removing the tab at the end of a group through the GTS will result in the
// width of a group changing without a tab moving. This means a rebuild won't
// occur, and we'll need to manually update the bottom indicator here.
if (!mInReorderMode
|| mTabGroupModelFilter.getTabGroupCount()
!= mStripGroupTitles.length) {
finishAnimations();
computeAndUpdateTabOrders(false, false);
mRenderHost.requestRender();
}
// Expand the tab if necessary.
StripLayoutTab tab = findTabById(movedTab.getId());
if (tab != null && tab.isCollapsed()) {
updateTabCollapsed(tab, false, false);
resizeTabStrip(true, false, false);
}
}
@Override
public void didMoveWithinGroup(
Tab movedTab, int tabModelOldIndex, int tabModelNewIndex) {
updateGroupAccessibilityDescription(findGroupTitle(movedTab.getRootId()));
}
@Override
public void didCreateNewGroup(Tab destinationTab, TabGroupModelFilter filter) {
rebuildStripViews();
}
@Override
public void didChangeTabGroupTitle(int rootId, String newTitle) {
final StripLayoutGroupTitle groupTitle = findGroupTitle(rootId);
if (groupTitle == null) return;
updateGroupTitleText(groupTitle, newTitle);
mRenderHost.requestRender();
}
@Override
public void didChangeTabGroupColor(int rootId, @TabGroupColorId int newColor) {
updateGroupTitleTint(findGroupTitle(rootId), newColor);
}
@Override
public void didChangeTabGroupCollapsed(int rootId, boolean isCollapsed) {
final StripLayoutGroupTitle groupTitle = findGroupTitle(rootId);
if (groupTitle == null) return;
updateTabGroupCollapsed(groupTitle, isCollapsed, true);
}
@Override
public void didChangeGroupRootId(int oldRootId, int newRootId) {
releaseResourcesForGroupTitle(oldRootId);
StripLayoutGroupTitle groupTitle = findGroupTitle(oldRootId);
if (groupTitle != null) {
groupTitle.updateRootId(newRootId);
// Refresh properties since removing the root tab may have cleared the ones
// associated with the oldRootId before updating to the newRootId here.
updateGroupTitleText(groupTitle);
updateGroupTitleTint(groupTitle);
}
// Update LastSyncedGroupId to prevent the IPH from being dismissed when the
// synced rootId changes.
if (oldRootId == mLastSyncedGroupId) {
mLastSyncedGroupId = newRootId;
}
}
@Override
public void didRemoveTabGroup(
int oldRootId,
@Nullable Token oldTabGroupId,
@DidRemoveTabGroupReason int removalReason) {
releaseResourcesForGroupTitle(oldRootId);
// dismiss the iph text bubble when the synced tab group is unsynced.
if (oldRootId == mLastSyncedGroupId) {
dismissTabGroupSyncIph();
}
}
};
// External influences
private final LayoutUpdateHost mUpdateHost;
private final LayoutRenderHost mRenderHost;
private final LayoutManagerHost mManagerHost;
private final WindowAndroid mWindowAndroid;
private TabModel mModel;
private TabGroupModelFilter mTabGroupModelFilter;
private TabCreator mTabCreator;
private LayerTitleCache mLayerTitleCache;
private ActionConfirmationManager mActionConfirmationManager;
private StripStacker mStripStacker = new ScrollingStripStacker();
// Internal State
private StripLayoutView[] mStripViews = new StripLayoutView[0];
private StripLayoutTab[] mStripTabs = new StripLayoutTab[0];
private StripLayoutTab[] mStripTabsVisuallyOrdered = new StripLayoutTab[0];
private StripLayoutTab[] mStripTabsToRender = new StripLayoutTab[0];
private StripLayoutGroupTitle[] mStripGroupTitles = new StripLayoutGroupTitle[0];
private StripLayoutGroupTitle[] mStripGroupTitlesToRender = new StripLayoutGroupTitle[0];
private StripLayoutTab mTabAtPositionForTesting;
private final StripTabEventHandler mStripTabEventHandler = new StripTabEventHandler();
private final TabLoadTrackerCallback mTabLoadTrackerHost = new TabLoadTrackerCallbackImpl();
private final RectF mTouchableRect = new RectF();
private final Supplier<Rect> mWindowRectSupplier;
// Common state used for animations on the strip triggered by independent actions including and
// not limited to tab closure, tab creation/selection, and tab reordering. Not intended to be
// used for hover actions. Consider using setAndStartRunningAnimator() to set and start this
// animator.
private Animator mRunningAnimator;
private final TintedCompositorButton mNewTabButton;
@Nullable private final CompositorButton mModelSelectorButton;
// Layout Constants
private final float mTabOverlapWidth;
private final float mNewTabButtonWidth;
private final float mMinTabWidth;
private final float mMaxTabWidth;
private final ListPopupWindow mTabMenu;
// All views are overlapped by mTabOverlapWidth. Group titles do not need to be overlapped by
// this much, so we offset the drawX.
private final float mGroupTitleDrawXOffset;
// The effective overlap width for group titles. This is the "true" overlap width, but adjusted
// to account for the start offset above.
private final float mGroupTitleOverlapWidth;
// Strip State
private ScrollDelegate mScrollDelegate = new ScrollDelegate();
private float mCachedTabWidth;
// Reorder State
private int mReorderState = REORDER_SCROLL_NONE;
private boolean mInReorderMode;
private float mLastReorderX;
private float mTabMarginWidth;
private float mHalfTabWidth;
private float mStripStartMarginForReorder;
private long mLastReorderScrollTime;
private long mLastUpdateTime;
private long mHoverStartTime;
private float mHoverStartOffset;
private boolean mHoveringOverGroup;
private boolean mMovingGroup;
// Tab switch efficiency
private Long mTabScrollStartTime;
private Long mMostRecentTabScroll;
// UI State
private StripLayoutTab mInteractingTab;
private float mWidth;
private float mHeight;
private long mLastSpinnerUpdate;
// The margins on the tab strip used when positioning tabs. Tabs within these margins are not
// touchable, but other strip widgets (e.g new tab button) could be.
private float mLeftMargin;
private float mRightMargin;
private float mLeftFadeWidth;
private float mRightFadeWidth;
// Padding regions on both ends of the strip where strip touch events are blocked. Different
// than margins, no strip widgets should be drawn within the padding region.
private float mLeftPadding;
private float mRightPadding;
// Set during onDown called via BUTTON_PRIMARY. Cleared in onUpOrCancel.
private boolean mOnDownWithButtonPrimary;
// New tab button with tab strip end padding
private final float mFixedEndPadding;
private float mReservedEndMargin;
// 3-dots menu button with tab strip end padding
private final boolean mIncognito;
private boolean mIsFirstLayoutPass;
private boolean mAnimationsDisabledForTesting;
// Whether tab strip scrolling is in progress
private boolean mIsStripScrollInProgress;
// Tab menu item IDs
public static final int ID_CLOSE_ALL_TABS = 0;
private Context mContext;
// Animation states. True while the relevant animations are running, and false otherwise.
private boolean mMultiStepTabCloseAnimRunning;
private boolean mNewTabButtonAnimRunning;
private boolean mTabGroupMarginAnimRunning;
private boolean mTabResizeAnimRunning;
private boolean mGroupTitleSliding;
// TabModel info available before the tab state is actually initialized. Determined from frozen
// tab metadata.
private boolean mTabStateInitialized;
private boolean mPlaceholderStripReady;
private boolean mSelectedOnStartup;
private boolean mCreatedTabOnStartup;
private boolean mActiveTabReplaced;
private int mTabCountOnStartup;
private int mActiveTabIndexOnStartup;
private int mCurrentPlaceholderIndex;
private long mPlaceholderCreationTime;
private int mTabsCreatedDuringRestore;
private int mPlaceholdersNeededDuringRestore;
// Tab Drag and Drop state to hold clicked tab being dragged.
private View mToolbarContainerView;
@Nullable private final TabDragSource mTabDragSource;
private StripLayoutTab mActiveClickedTab;
// Tab Drag and Drop state to set correct reorder state when dragging on/off tab strip.
private boolean mReorderingForTabDrop;
private float mLastOffsetX;
private float mLastTrailingMargin;
// Tab hover state.
private StripLayoutTab mLastHoveredTab;
private StripTabHoverCardView mTabHoverCardView;
// Tab Group Sync.
private float mTabStripHeight;
private TabGroupSyncIphController mTabGroupSyncIphController;
private int mLastSyncedGroupId = Tab.INVALID_TAB_ID;
private final Supplier<Boolean> mTabStripVisibleSupplier;
// Tab group delete dialog.
private int mTabGroupIdToHide = Tab.INVALID_TAB_ID;
private PrefService mPrefService;
// Tab group context menu.
private TabGroupContextMenuCoordinator mTabGroupContextMenuCoordinator;
/**
* Creates an instance of the {@link StripLayoutHelper}.
*
* @param context The current Android {@link Context}.
* @param managerHost The parent {@link LayoutManagerHost}.
* @param updateHost The parent {@link LayoutUpdateHost}.
* @param renderHost The {@link LayoutRenderHost}.
* @param incognito Whether or not this tab strip is incognito.
* @param modelSelectorButton The {@link CompositorButton} used to toggle between regular and
* incognito models.
* @param tabDragSource The @{@link TabDragSource} instance to initiate drag and drop.
* @param toolbarContainerView The @{link View} passed to @{link TabDragSource} for drag and
* drop.
* @param windowAndroid The @{@link WindowAndroid} instance to access Activity.
* @param tabStripHeight The height of current tab strip.
* @param tabStripVisibleSupplier Supplier of the boolean indicating whether the tab strip is
* visible. The tab strip can be hidden due to the tab switcher being displayed or the
* window width is less than 600dp.
*/
public StripLayoutHelper(
Context context,
LayoutManagerHost managerHost,
LayoutUpdateHost updateHost,
LayoutRenderHost renderHost,
Supplier<Rect> windowRectSupplier,
boolean incognito,
CompositorButton modelSelectorButton,
@Nullable TabDragSource tabDragSource,
@NonNull View toolbarContainerView,
@NonNull WindowAndroid windowAndroid,
ActionConfirmationManager actionConfirmationManager,
int tabStripHeight,
Supplier<Boolean> tabStripVisibleSupplier) {
mTabOverlapWidth = TAB_OVERLAP_WIDTH_LARGE_DP;
mGroupTitleDrawXOffset = mTabOverlapWidth - StripLayoutTab.FOLIO_FOOT_LENGTH_DP;
mGroupTitleOverlapWidth = StripLayoutTab.FOLIO_FOOT_LENGTH_DP - mGroupTitleDrawXOffset;
mNewTabButtonWidth = NEW_TAB_BUTTON_BACKGROUND_WIDTH_DP;
mModelSelectorButton = modelSelectorButton;
mToolbarContainerView = toolbarContainerView;
mTabDragSource = tabDragSource;
mWindowAndroid = windowAndroid;
mActionConfirmationManager = actionConfirmationManager;
mTabStripHeight = tabStripHeight;
mTabStripVisibleSupplier = tabStripVisibleSupplier;
mWindowRectSupplier = windowRectSupplier;
// Use toolbar menu button padding to align NTB with menu button.
mFixedEndPadding =
context.getResources().getDimension(R.dimen.button_end_padding)
/ context.getResources().getDisplayMetrics().density;
mReservedEndMargin = mFixedEndPadding + mNewTabButtonWidth;
updateMargins(false);
mMinTabWidth = TAB_STRIP_TAB_WIDTH;
mMaxTabWidth = TabUiThemeUtil.getMaxTabStripTabWidthDp();
mManagerHost = managerHost;
mUpdateHost = updateHost;
mRenderHost = renderHost;
CompositorOnClickHandler newTabClickHandler =
new CompositorOnClickHandler() {
@Override
public void onClick(long time) {
handleNewTabClick();
}
};
// Set new tab button background resource.
mNewTabButton =
new TintedCompositorButton(
context,
NEW_TAB_BUTTON_BACKGROUND_WIDTH_DP,
NEW_TAB_BUTTON_BACKGROUND_HEIGHT_DP,
newTabClickHandler,
R.drawable.ic_new_tab_button);
mNewTabButton.setBackgroundResourceId(R.drawable.bg_circle_tab_strip_button);
int apsBackgroundHoveredTint =
ColorUtils.setAlphaComponentWithFloat(
SemanticColorUtils.getDefaultTextColor(context),
NEW_TAB_BUTTON_HOVER_BACKGROUND_DEFAULT_OPACITY);
int apsBackgroundPressedTint =
ColorUtils.setAlphaComponentWithFloat(
SemanticColorUtils.getDefaultTextColor(context),
NEW_TAB_BUTTON_HOVER_BACKGROUND_PRESSED_OPACITY);
int apsBackgroundIncognitoHoveredTint =
ColorUtils.setAlphaComponentWithFloat(
context.getColor(R.color.tab_strip_button_hover_bg_color),
NEW_TAB_BUTTON_HOVER_BACKGROUND_DEFAULT_OPACITY);
int apsBackgroundIncognitoPressedTint =
ColorUtils.setAlphaComponentWithFloat(
context.getColor(R.color.tab_strip_button_hover_bg_color),
NEW_TAB_BUTTON_HOVER_BACKGROUND_PRESSED_OPACITY);
// Primary container for default bg color.
int BackgroundDefaultTint = TabUiThemeProvider.getDefaultNTBContainerColor(context);
// Primary @ 20% for default pressed bg color.
int BackgroundPressedTint =
ColorUtils.setAlphaComponentWithFloat(
SemanticColorUtils.getDefaultIconColorAccent1(context),
NEW_TAB_BUTTON_DEFAULT_PRESSED_OPACITY);
// Surface-2 baseline for incognito bg color.
int BackgroundIncognitoDefaultTint =
context.getColor(R.color.default_bg_color_dark_elev_2_baseline);
// Surface-5 baseline for incognito pressed bg color
int BackgroundIncognitoPressedTint =
context.getColor(R.color.default_bg_color_dark_elev_5_baseline);
// Tab strip redesign new tab button night mode bg color.
if (ColorUtils.inNightMode(context)) {
// Surface-1 for night mode bg color.
BackgroundDefaultTint =
ChromeColors.getSurfaceColor(context, R.dimen.default_elevation_1);
// Surface 5 for pressed night mode bg color.
BackgroundPressedTint =
ChromeColors.getSurfaceColor(context, R.dimen.default_elevation_5);
}
mNewTabButton.setBackgroundTint(
BackgroundDefaultTint,
BackgroundPressedTint,
BackgroundIncognitoDefaultTint,
BackgroundIncognitoPressedTint,
apsBackgroundHoveredTint,
apsBackgroundPressedTint,
apsBackgroundIncognitoHoveredTint,
apsBackgroundIncognitoPressedTint);
// No pressed state color change for new tab button icon.
mNewTabButton.setTintResources(
R.color.default_icon_color_tint_list,
R.color.default_icon_color_tint_list,
R.color.modern_white,
R.color.modern_white);
// y-offset = lowered tab container + (tab container size - bg size)/2 -
// Tab title y-offset = 2 + (38 - 32)/2 - 2 = 3dp
mNewTabButton.setDrawY(NEW_TAB_BUTTON_BACKGROUND_Y_OFFSET_DP);
mNewTabButton.setIncognito(incognito);
mNewTabButton.setClickSlop(NEW_TAB_BUTTON_CLICK_SLOP_DP);
Resources res = context.getResources();
mNewTabButton.setAccessibilityDescription(
res.getString(R.string.accessibility_toolbar_btn_new_tab),
res.getString(R.string.accessibility_toolbar_btn_new_incognito_tab));
mContext = context;
mIncognito = incognito;
// Create tab menu
mTabMenu = new ListPopupWindow(mContext);
mTabMenu.setAdapter(
new ArrayAdapter<String>(
mContext,
android.R.layout.simple_list_item_1,
new String[] {
mContext.getString(
!mIncognito
? R.string.menu_close_all_tabs
: R.string.menu_close_all_incognito_tabs)
}));
mTabMenu.setOnItemClickListener(
new OnItemClickListener() {
@Override
public void onItemClick(
AdapterView<?> parent, View view, int position, long id) {
mTabMenu.dismiss();
if (position == ID_CLOSE_ALL_TABS) {
mTabGroupModelFilter.closeTabs(
TabClosureParams.closeAllTabs().hideTabGroups(true).build());
RecordUserAction.record("MobileToolbarCloseAllTabs");
}
}
});
int menuWidth = mContext.getResources().getDimensionPixelSize(R.dimen.menu_width);
mTabMenu.setWidth(menuWidth);
mTabMenu.setModal(true);
mIsFirstLayoutPass = true;
}
/** Cleans up internal state. */
public void destroy() {
mStripTabEventHandler.removeCallbacksAndMessages(null);
if (mTabHoverCardView != null) {
mTabHoverCardView.destroy();
mTabHoverCardView = null;
}
if (mTabGroupModelFilter != null) {
mTabGroupModelFilter.removeTabGroupObserver(mTabGroupModelFilterObserver);
mTabGroupModelFilter = null;
}
}
/**
* Get a list of virtual views for accessibility.
*
* @param views A List to populate with virtual views.
*/
public void getVirtualViews(List<VirtualView> views) {
for (int i = 0; i < mStripViews.length; i++) {
final StripLayoutView view = mStripViews[i];
view.getVirtualViews(views);
}
if (mNewTabButton.isVisible()) mNewTabButton.getVirtualViews(views);
}
/**
* Get the touchable area within the strip, presented as a {@link RectF}, where (0,0) is the
* top-left point of the StripLayoutHelper. The area will include the tabs and new tab button.
*/
RectF getTouchableRect() {
return mTouchableRect;
}
/**
* @return The visually ordered list of visible {@link StripLayoutTab}s.
*/
public StripLayoutTab[] getStripLayoutTabsToRender() {
return mStripTabsToRender;
}
/**
* @return The visually ordered list of visible {@link StripLayoutGroupTitle}s.
*/
public StripLayoutGroupTitle[] getStripLayoutGroupTitlesToRender() {
return mStripGroupTitlesToRender;
}
/**
* @return A {@link TintedCompositorButton} that represents the positioning of the new tab
* button.
*/
public TintedCompositorButton getNewTabButton() {
return mNewTabButton;
}
/**
* @return The visual offset to be applied to the new tab button.
*/
protected float getNewTabButtonVisualOffset() {
boolean isRtl = LocalizationUtils.isLayoutRtl();
float newTabButtonTouchTargetOffset;
if (isTabStripFull()) {
newTabButtonTouchTargetOffset = 0;
} else {
newTabButtonTouchTargetOffset = getNtbVisualOffsetHorizontal();
}
return isRtl ? newTabButtonTouchTargetOffset : -newTabButtonTouchTargetOffset;
}
/**
* Check whether the tab strip is full by checking whether tab width has decreased to fit more
* tabs.
*
* @return Whether the tab strip is full.
*/
private boolean isTabStripFull() {
return mCachedTabWidth < TabUiThemeUtil.getMaxTabStripTabWidthDp();
}
/**
* Determine How far to shift new tab button icon visually towards the tab in order to achieve
* the desired spacing between new tab button and tabs when tab strip is not full.
*
* @return Visual offset of new tab button icon.
*/
protected float getNtbVisualOffsetHorizontal() {
return (BUTTON_DESIRED_TOUCH_TARGET_SIZE - mNewTabButtonWidth) / 2
- DESIRED_PADDING_BETWEEN_NEW_TAB_BUTTON_AND_TABS;
}
/**
* @return The opacity to use for the fade on the left side of the tab strip.
*/
public float getLeftFadeOpacity() {
return getFadeOpacity(true);
}
/**
* @return The opacity to use for the fade on the right side of the tab strip.
*/
public float getRightFadeOpacity() {
return getFadeOpacity(false);
}
/**
* When the {@link ScrollingStripStacker} is being used, a fade is shown at the left and right
* edges to indicate there is tab strip content off screen. As the scroll position approaches
* the edge of the screen, the fade opacity is lowered.
*
* @param isLeft Whether the opacity for the left or right side should be returned.
* @return The opacity to use for the fade.
*/
private float getFadeOpacity(boolean isLeft) {
float edgeOffset = mScrollDelegate.getEdgeOffset(isLeft);
if (edgeOffset <= 0.f) {
return 0.f;
} else if (edgeOffset >= FADE_FULL_OPACITY_THRESHOLD_DP) {
return 1.f;
} else {
return edgeOffset / FADE_FULL_OPACITY_THRESHOLD_DP;
}
}
/**
* @return The strip's current scroll offset.
*/
float getScrollOffset() {
return mScrollDelegate.getScrollOffset();
}
float getVisibleLeftBound() {
return mLeftPadding;
}
float getVisibleRightBound() {
return mWidth - mRightPadding;
}
/**
* @param msbTouchTargetSize The touch target size for the model selector button.
*/
public void updateEndMarginForStripButtons(float msbTouchTargetSize) {
// When MSB is not visible we add strip end padding here. When MSB is visible strip end
// padding will be included in MSB margin, so just add padding between NTB and MSB here.
mReservedEndMargin =
msbTouchTargetSize
+ mNewTabButtonWidth
+ (mModelSelectorButton != null && mModelSelectorButton.isVisible()
? NEW_TAB_BUTTON_WITH_MODEL_SELECTOR_BUTTON_PADDING
: mFixedEndPadding);
updateMargins(true);
}
private void updateMargins(boolean recalculateTabWidth) {
if (LocalizationUtils.isLayoutRtl()) {
mLeftMargin = mReservedEndMargin + mLeftPadding;
mRightMargin = mRightPadding;
} else {
mLeftMargin = mLeftPadding;
mRightMargin = mReservedEndMargin + mRightPadding;
}
if (recalculateTabWidth) computeAndUpdateTabWidth(false, false, null);
}
/**
* Sets the left fade width based on which fade is showing.
*
* @param fadeWidth The width of the left fade.
*/
public void setLeftFadeWidth(float fadeWidth) {
if (mLeftFadeWidth != fadeWidth) {
mLeftFadeWidth = fadeWidth;
bringSelectedTabToVisibleArea(LayoutManagerImpl.time(), false);
}
}
/**
* Sets the right fade width based on which fade is showing.
* @param fadeWidth The width of the right fade.
*/
public void setRightFadeWidth(float fadeWidth) {
if (mRightFadeWidth != fadeWidth) {
mRightFadeWidth = fadeWidth;
bringSelectedTabToVisibleArea(LayoutManagerImpl.time(), false);
}
}
/**
* Updates the size of the virtual tab strip, making the tabs resize and move accordingly.
*
* @param width The new available width.
* @param height The new height this stack should be.
* @param orientationChanged Whether the screen orientation was changed.
* @param time The current time of the app in ms.
* @param leftPadding The new left padding.
* @param rightPadding The new right padding.
*/
public void onSizeChanged(
float width,
float height,
boolean orientationChanged,
long time,
float leftPadding,
float rightPadding) {
if (mWidth == width
&& mHeight == height
&& leftPadding == mLeftPadding
&& rightPadding == mRightPadding) {
return;
}
StripLayoutTab selectedTab = getSelectedStripTab();
boolean wasSelectedTabVisible = selectedTab != null && selectedTab.isVisible();
boolean recalculateTabWidth =
mWidth != width || mLeftPadding != leftPadding || mRightPadding != rightPadding;
mWidth = width;
mHeight = height;
mLeftPadding = leftPadding;
mRightPadding = rightPadding;
for (int i = 0; i < mStripViews.length; i++) {
final StripLayoutView view = mStripViews[i];
view.setHeight(mHeight);
}
updateMargins(recalculateTabWidth);
if (mStripViews.length > 0) mUpdateHost.requestUpdate();
// Dismiss tab menu, similar to how the app menu is dismissed on orientation change
mTabMenu.dismiss();
// Dismiss iph on orientation change, as its position might become incorrect.
dismissTabGroupSyncIph();
if ((orientationChanged && wasSelectedTabVisible) || !mTabStateInitialized) {
bringSelectedTabToVisibleArea(time, mTabStateInitialized);
}
}
/**
* Updates all internal resources and dimensions.
*
* @param context The current Android {@link Context}.
*/
public void onContextChanged(Context context) {
// TODO(crbug.com/360206998): Clean up this method as the Context doesn't change.
mContext = context;
mScrollDelegate.onContextChanged(context);
}
/**
* Notify the a title has changed.
*
* @param tabId The id of the tab that has changed.
* @param title The new title.
*/
public void tabTitleChanged(int tabId, String title) {
Tab tab = getTabById(tabId);
if (tab != null) setAccessibilityDescription(findTabById(tabId), title, tab.isHidden());
}
/**
* Sets the {@link TabModel} that this {@link StripLayoutHelper} will visually represent.
* @param model The {@link TabModel} to visually represent.
* @param tabCreator The {@link TabCreator}, used to create new tabs.
* @param tabStateInitialized Whether the tab model's tab state is fully initialized after
* startup or not.
*/
public void setTabModel(TabModel model, TabCreator tabCreator, boolean tabStateInitialized) {
if (mModel == model) return;
mModel = model;
mTabCreator = tabCreator;
mTabStateInitialized = tabStateInitialized;
// If the tabs are still restoring and the refactoring experiment is enabled, we'll create a
// placeholder strip. This means we don't need to call computeAndUpdateTabOrders() to
// generate "real" strip tabs.
if (!mTabStateInitialized && ChromeFeatureList.sTabStripStartupRefactoring.isEnabled()) {
// If the placeholder strip is ready, replace the matching placeholders for the tabs
// that have already been restored.
mSelectedOnStartup = mModel.isActiveModel();
if (mPlaceholderStripReady) replacePlaceholdersForRestoredTabs();
} else {
RecordHistogram.recordMediumTimesHistogram(
PLACEHOLDER_VISIBLE_DURATION_HISTOGRAM_NAME, 0L);
computeAndUpdateTabOrders(false, false);
resizeTabStrip(false, false, false);
}
}
/** Called to notify that the tab state has been initialized. */
protected void onTabStateInitialized() {
mTabStateInitialized = true;
if (ChromeFeatureList.sTabStripStartupRefactoring.isEnabled() && mPlaceholderStripReady) {
int numLeftoverPlaceholders = 0;
for (int i = 0; i < mStripTabs.length; i++) {
if (mStripTabs[i].getIsPlaceholder()) numLeftoverPlaceholders++;
}
RecordHistogram.recordCount1000Histogram(
PLACEHOLDER_LEFTOVER_TABS_HISTOGRAM_NAME, numLeftoverPlaceholders);
RecordHistogram.recordCount1000Histogram(
PLACEHOLDER_TABS_CREATED_DURING_RESTORE_HISTOGRAM_NAME,
mTabsCreatedDuringRestore);
RecordHistogram.recordCount1000Histogram(
PLACEHOLDER_TABS_NEEDED_DURING_RESTORE_HISTOGRAM_NAME,
mPlaceholdersNeededDuringRestore);
RecordHistogram.recordMediumTimesHistogram(
PLACEHOLDER_VISIBLE_DURATION_HISTOGRAM_NAME,
SystemClock.uptimeMillis() - mPlaceholderCreationTime);
}
// Recreate the StripLayoutTabs from the TabModel, now that all of the real Tabs have been
// restored. This will reuse valid tabs, discard invalid tabs, and correct tab orders.
computeAndUpdateTabOrders(false, false);
}
/**
* Sets the {@link TabGroupModelFilter} that will access the internal tab group state.
*
* @param tabGroupModelFilter The {@link TabGroupModelFilter}.
*/
public void setTabGroupModelFilter(TabGroupModelFilter tabGroupModelFilter) {
if (mTabGroupModelFilter != null) {
mTabGroupModelFilter.removeTabGroupObserver(mTabGroupModelFilterObserver);
}
mTabGroupModelFilter = tabGroupModelFilter;
mTabGroupModelFilter.addTabGroupObserver(mTabGroupModelFilterObserver);
updateTitleCacheForInit();
rebuildStripViews();
}
TabGroupModelFilterObserver getTabGroupModelFilterObserverForTesting() {
return mTabGroupModelFilterObserver;
}
/**
* Sets the {@link LayerTitleCache} for the tab strip bitmaps.
*
* @param layerTitleCache The {@link LayerTitleCache}.
*/
public void setLayerTitleCache(LayerTitleCache layerTitleCache) {
mLayerTitleCache = layerTitleCache;
updateTitleCacheForInit();
mRenderHost.requestRender();
}
private void updateTitleCacheForInit() {
if (mTabGroupModelFilter == null || mLayerTitleCache == null) return;
for (int i = 0; i < mStripGroupTitles.length; ++i) {
final StripLayoutGroupTitle groupTitle = mStripGroupTitles[i];
updateGroupTitleText(groupTitle, groupTitle.getTitle());
}
}
/** Dismiss the iph text bubble for synced tab group. */
private void dismissTabGroupSyncIph() {
if (mLastSyncedGroupId != Tab.INVALID_TAB_ID && mTabGroupSyncIphController != null) {
mTabGroupSyncIphController.dismissTextBubble();
mLastSyncedGroupId = Tab.INVALID_TAB_ID;
}
}
/**
* Helper-specific updates. Cascades the values updated by the animations and flings.
*
* @param time The current time of the app in ms.
* @return Whether or not animations are done.
*/
public boolean updateLayout(long time) {
mLastUpdateTime = time;
// 1.a. Handle any Scroller movements (flings).
if (mScrollDelegate.updateScrollInProgress(time)) {
// 1.b. Scroll still in progress, so request update.
mUpdateHost.requestUpdate();
}
// 2. Handle reordering automatically scrolling the tab strip.
handleReorderAutoScrolling(time);
// 3. Update tab spinners.
updateSpinners(time);
final boolean doneAnimating = mRunningAnimator == null || !mRunningAnimator.isRunning();
updateStrip();
// If this is the first layout pass, scroll to the selected tab so that it is visible.
// This is needed if the ScrollingStripStacker is being used because the selected tab is
// not guaranteed to be visible.
if (mIsFirstLayoutPass) {
bringSelectedTabToVisibleArea(time, false);
mIsFirstLayoutPass = false;
}
// 4. Show iph text bubble for synced tab group if necessary.
if (doneAnimating && mScrollDelegate.isFinished()) {
showTabGroupSyncIph();
}
return doneAnimating;
}
private void showTabGroupSyncIph() {
// Return early if no tab group is being synced, or profile is invalid, or if in incognito
// mode.
if (mLastSyncedGroupId == Tab.INVALID_TAB_ID
|| mModel.isIncognito()
|| mModel.getProfile() == null) {
return;
}
// Return early if the tab strip is not visible on screen.
if (Boolean.FALSE.equals(mTabStripVisibleSupplier.get())) {
return;
}
// Skip initialization if testing value has been set.
if (mTabGroupSyncIphController == null) {
UserEducationHelper userEducationHelper =
new UserEducationHelper(
mWindowAndroid.getActivity().get(),
mModel.getProfile(),
new Handler(Looper.getMainLooper()));
Tracker tracker = TrackerFactory.getTrackerForProfile(mModel.getProfile());
mTabGroupSyncIphController =
new TabGroupSyncIphController(
mContext.getResources(),
userEducationHelper,
R.string.newly_synced_tab_group_iph,
tracker);
}
StripLayoutGroupTitle groupTitle = findGroupTitle(mLastSyncedGroupId);
// Display iph only when synced tab group title is fully visible.
if (groupTitle == null
|| !groupTitle.isVisible()
|| groupTitle.getPaddedX() + groupTitle.getPaddedWidth()
>= mNewTabButton.getDrawX()) {
return;
}
float dpToPx = mContext.getResources().getDisplayMetrics().density;
if (groupTitle != null) {
mTabGroupSyncIphController.maybeShowIphOnTabStrip(
mToolbarContainerView,
groupTitle.getDrawX() * dpToPx,
0.f,
(mWidth - groupTitle.getDrawX() - groupTitle.getWidth()) * dpToPx,
mToolbarContainerView.getHeight() - mTabStripHeight);
}
}
void setLastSyncedGroupIdForTesting(int id) {
mLastSyncedGroupId = id;
}
void setTabGroupSyncIphControllerForTesting(
TabGroupSyncIphController tabGroupSyncIphController) {
mTabGroupSyncIphController = tabGroupSyncIphController;
}
void setIsFirstLayoutPassForTesting(boolean isFirstLayoutPass) {
mIsFirstLayoutPass = isFirstLayoutPass;
}
/**
* Called when a new tab model is selected.
*
* @param selected If the new tab model selected is the model that this strip helper associated
* with.
*/
public void tabModelSelected(boolean selected) {
if (selected) {
bringSelectedTabToVisibleArea(0, false);
} else {
mTabMenu.dismiss();
}
}
/**
* Called when a tab get selected.
* @param time The current time of the app in ms.
* @param id The id of the selected tab.
* @param prevId The id of the previously selected tab.
* @param skipAutoScroll Whether autoscroll to bring selected tab to view can be skipped.
*/
public void tabSelected(long time, int id, int prevId, boolean skipAutoScroll) {
StripLayoutTab stripTab = findTabById(id);
if (stripTab == null) {
tabCreated(time, id, prevId, true, false, false);
} else {
updateVisualTabOrdering();
updateCloseButtons();
Tab tab = getTabById(id);
if (tab != null && mTabGroupModelFilter.getTabGroupCollapsed(tab.getRootId())) {
mTabGroupModelFilter.deleteTabGroupCollapsed(tab.getRootId());
}
if (!skipAutoScroll && !mInReorderMode) {
// If the tab was selected through a method other than the user tapping on the
// strip, it may not be currently visible. Scroll if necessary.
bringSelectedTabToVisibleArea(time, true);
}
mUpdateHost.requestUpdate();
setAccessibilityDescription(stripTab, getTabById(id));
setAccessibilityDescription(findTabById(prevId), getTabById(prevId));
}
}
/**
* Called when a tab has been moved in the tabModel.
* @param time The current time of the app in ms.
* @param id The id of the Tab.
* @param oldIndex The old index of the tab in the {@link TabModel}.
* @param newIndex The new index of the tab in the {@link TabModel}.
*/
public void tabMoved(long time, int id, int oldIndex, int newIndex) {
reorderTab(id, oldIndex, newIndex, false);
updateVisualTabOrdering();
mUpdateHost.requestUpdate();
}
/**
* Called when a tab will be closed. When called, the closing tab will be part of the model.
*
* @param time The current time of the app in ms.
* @param tab The tab that will be closed.
*/
public void willCloseTab(long time, Tab tab) {
if (tab != null) updateGroupTitleText(tab.getRootId());
}
/**
* Called when a tab is being closed. When called, the closing tab will not be part of the
* model.
*
* @param time The current time of the app in ms.
* @param id The id of the tab being closed.
*/
public void tabClosed(long time, int id) {
if (findTabById(id) == null) return;
// 1. Find out if we're closing the last tab. This determines if we resize immediately.
// We know mStripTabs.length >= 1 because findTabById did not return null.
boolean closingLastTab = mStripTabs[mStripTabs.length - 1].getTabId() == id;
// 2. Rebuild the strip.
computeAndUpdateTabOrders(!closingLastTab, false);
mUpdateHost.requestUpdate();
}
/** Called when all tabs are closed at once. */
public void willCloseAllTabs() {
computeAndUpdateTabOrders(true, false);
mUpdateHost.requestUpdate();
}
/**
* Called when a tab close has been undone and the tab has been restored. This also re-selects
* the last tab the user was on before the tab was closed.
* @param time The current time of the app in ms.
* @param id The id of the Tab.
*/
public void tabClosureCancelled(long time, int id) {
final boolean selected = TabModelUtils.getCurrentTabId(mModel) == id;
tabCreated(time, id, Tab.INVALID_TAB_ID, selected, true, false);
}
/**
* Called when a tab is created from the top left button.
* @param time The current time of the app in ms.
* @param id The id of the newly created tab.
* @param prevId The id of the source tab.
* @param selected Whether the tab will be selected.
* @param closureCancelled Whether the tab was restored by a tab closure cancellation.
* @param onStartup Whether the tab is being unfrozen during startup.
*/
public void tabCreated(
long time,
int id,
int prevId,
boolean selected,
boolean closureCancelled,
boolean onStartup) {
if (findTabById(id) != null) return;
// 1. If tab state is still initializing, replace the matching placeholder tab.
if (!mTabStateInitialized && ChromeFeatureList.sTabStripStartupRefactoring.isEnabled()) {
replaceNextPlaceholder(id, selected, onStartup);
return;
}
// Otherwise, 2. Build any tabs that are missing. Determine if it will be collapsed.
finishAnimationsAndPushTabUpdates();
List<Animator> animationList = computeAndUpdateTabOrders(false, !onStartup);
Tab tab = getTabById(id);
boolean collapsed = false;
if (tab != null) {
int rootId = tab.getRootId();
updateGroupTitleText(rootId);
if (mTabGroupModelFilter.getTabGroupCollapsed(rootId)) {
if (selected) {
mTabGroupModelFilter.deleteTabGroupCollapsed(rootId);
} else {
collapsed = true;
}
}
}
// 3. Start an animation for the newly created tab, unless it is collapsed.
if (animationList == null) animationList = new ArrayList<>();
StripLayoutTab stripTab = findTabById(id);
if (stripTab != null) {
updateTabCollapsed(stripTab, collapsed, false);
if (!onStartup && !collapsed) runTabAddedAnimator(animationList, stripTab);
}
// 4. If the new tab will be selected, scroll it to view. If the new tab will not be
// selected, scroll the currently selected tab to view. Skip auto-scrolling if the tab is
// being created due to a tab closure being undone.
if (stripTab != null && !closureCancelled && !collapsed) {
boolean animate = !onStartup && !mAnimationsDisabledForTesting;
if (selected) {
float delta = calculateDeltaToMakeTabVisible(stripTab);
setScrollForScrollingTabStacker(delta, animate, time);
} else {
bringSelectedTabToVisibleArea(time, animate);
}
}
mUpdateHost.requestUpdate();
}
private void runTabAddedAnimator(@NonNull List<Animator> animationList, StripLayoutTab tab) {
animationList.add(
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
tab,
StripLayoutTab.Y_OFFSET,
tab.getHeight(),
0f,
ANIM_TAB_CREATED_MS));
startAnimationList(animationList, /* listener= */ null);
}
/**
* Set the relevant tab model metadata prior to the tab state initialization.
*
* @param activeTabIndexOnStartup What the active tab index should be after tabs finish
* restoring.
* @param tabCountOnStartup What the tab count should be after tabs finish restoring.
* @param createdTabOnStartup If an additional tab was created on startup (e.g. through intent).
*/
protected void setTabModelStartupInfo(
int tabCountOnStartup, int activeTabIndexOnStartup, boolean createdTabOnStartup) {
if (!ChromeFeatureList.sTabStripStartupRefactoring.isEnabled()) return;
mTabCountOnStartup = tabCountOnStartup;
mActiveTabIndexOnStartup = activeTabIndexOnStartup;
mCreatedTabOnStartup = createdTabOnStartup;
// Avoid creating the placeholder strip if we have an invalid active tab index.
if (mActiveTabIndexOnStartup < 0 || mActiveTabIndexOnStartup >= mTabCountOnStartup) return;
// If tabs are still being restored on startup, create placeholder tabs to mitigate jank.
if (!mTabStateInitialized) {
prepareEmptyPlaceholderStripLayout();
// If the TabModel has already been set, then replace placeholders for restored tabs.
if (mModel != null) replacePlaceholdersForRestoredTabs();
}
}
/**
* Creates the placeholder tabs that will be shown on startup before the tab state is
* initialized.
*/
private void prepareEmptyPlaceholderStripLayout() {
// TODO(crbug.com/41497111): Investigate if we can update for tab group indicators.
if (mPlaceholderStripReady || mTabStateInitialized) return;
// 1. Fill with placeholder tabs.
mStripTabs = new StripLayoutTab[mTabCountOnStartup];
for (int i = 0; i < mStripTabs.length; i++) {
mStripTabs[i] = createPlaceholderStripTab();
}
rebuildStripViews();
// 2. Initialize the draw parameters.
computeAndUpdateTabWidth(false, false, null);
updateVisualTabOrdering();
// 3. Scroll the strip to bring the selected tab to view and ensure that the active tab
// container is visible.
if (mActiveTabIndexOnStartup != TabModel.INVALID_TAB_INDEX) {
bringSelectedTabToVisibleArea(LayoutManagerImpl.time(), false);
mStripTabs[mActiveTabIndexOnStartup].setContainerOpacity(
TAB_OPACITY_VISIBLE_FOREGROUND);
}
// 4. Mark that the placeholder strip layout is ready and request a visual update.
mPlaceholderStripReady = true;
mPlaceholderCreationTime = SystemClock.uptimeMillis();
mUpdateHost.requestUpdate();
}
/**
* Replace placeholders for all tabs that have already been restored. Do so by updating all
* relevant properties in the StripLayoutTab (id).
*/
private void replacePlaceholdersForRestoredTabs() {
if (!mPlaceholderStripReady || mTabStateInitialized) return;
// If the number of tabs is less than the expected active tab index, it means that there
// will need to be placeholders before the active tab. If this is the case, replace the
// active tab later to ensure it's at the correct index.
int numTabsToCopy = mModel.getCount();
if (mCreatedTabOnStartup) numTabsToCopy--;
boolean needPlaceholdersBeforeActiveTab =
numTabsToCopy <= mActiveTabIndexOnStartup && mSelectedOnStartup;
if (needPlaceholdersBeforeActiveTab && numTabsToCopy > 0) numTabsToCopy--;
mCurrentPlaceholderIndex = numTabsToCopy;
// There should not be more restored tabs than the allotted placeholder tabs.
assert numTabsToCopy <= mStripTabs.length;
// 1. Replace the placeholder tabs by updating the relevant properties.
for (int i = 0; i < numTabsToCopy; i++) {
final StripLayoutTab stripTab = mStripTabs[i];
final Tab tab = mModel.getTabAt(i);
pushPropertiesToPlaceholder(stripTab, tab);
}
if (!needPlaceholdersBeforeActiveTab) mActiveTabReplaced = true;
// 2. If a new tab was created on startup (e.g. through intent), copy it over now.
if (mCreatedTabOnStartup) {
final StripLayoutTab stripTab = mStripTabs[mStripTabs.length - 1];
final Tab tab = mModel.getTabAt(mModel.getCount() - 1);
pushPropertiesToPlaceholder(stripTab, tab);
}
// 3. If the active tab could not be copied earlier, copy it over now at the correct index.
if (needPlaceholdersBeforeActiveTab) {
int prevActiveIndex = mModel.getCount() - 1;
if (mCreatedTabOnStartup) prevActiveIndex--;
if (prevActiveIndex >= 0) {
final StripLayoutTab stripTab = mStripTabs[mActiveTabIndexOnStartup];
final Tab tab = mModel.getTabAt(prevActiveIndex);
pushPropertiesToPlaceholder(stripTab, tab);
mActiveTabReplaced = true;
}
}
// 4. Request new frame.
mRenderHost.requestRender();
}
private void replaceNextPlaceholder(int id, boolean selected, boolean onStartup) {
assert !mTabStateInitialized;
// Placeholders are not yet ready. This strip tab will instead be created when we
// prepare the placeholder strip.
if (!mPlaceholderStripReady) return;
// The active tab is handled separately.
if (mCurrentPlaceholderIndex == mActiveTabIndexOnStartup && mSelectedOnStartup) {
mCurrentPlaceholderIndex++;
}
// Tab manually created while tabs were still restoring on startup.
if (!onStartup) {
mTabsCreatedDuringRestore++;
return;
}
// Unexpectedly ran out of placeholders.
if (mCurrentPlaceholderIndex >= mStripTabs.length && !selected) {
mPlaceholdersNeededDuringRestore++;
return;
}
// Replace the matching placeholder.
int replaceIndex;
if (selected || !mActiveTabReplaced) {
replaceIndex = mActiveTabIndexOnStartup;
mActiveTabReplaced = true;
} else {
// Should match the index in the model. Though there are some mechanisms to return us to
// a "valid" state that may break this, such as ensuring that grouped tabs are
// contiguous. See https://crbug.com/329191924 for details.
replaceIndex = mCurrentPlaceholderIndex++;
if (replaceIndex != mModel.indexOf(getTabById(id))) return;
}
if (replaceIndex >= 0 && replaceIndex < mStripTabs.length) {
final StripLayoutTab placeholderTab = mStripTabs[replaceIndex];
final Tab tab = getTabById(id);
pushPropertiesToPlaceholder(placeholderTab, tab);
if (placeholderTab.isVisible()) {
mRenderHost.requestRender();
}
}
}
/**
* @return The expected tab count after tabs finish restoring.
*/
protected int getTabCountOnStartupForTesting() {
return mTabCountOnStartup;
}
/**
* @return The expected active tab index after tabs finish restoring.
*/
protected int getActiveTabIndexOnStartupForTesting() {
return mActiveTabIndexOnStartup;
}
/**
* @return Whether a non-restored tab was created during startup (e.g. through intent).
*/
protected boolean getCreatedTabOnStartupForTesting() {
return mCreatedTabOnStartup;
}
/**
* Called to hide close tab buttons when tab width is <156dp when min tab width is 108dp or for
* partially visible tabs at the edge of the tab strip when min tab width is set to >=156dp.
*/
private void updateCloseButtons() {
final int count = mStripTabs.length;
int selectedIndex = getSelectedStripTabIndex();
for (int i = 0; i < count; i++) {
final StripLayoutTab tab = mStripTabs[i];
boolean tabSelected = selectedIndex == i;
boolean canShowCloseButton =
tab.getWidth() >= TAB_WIDTH_MEDIUM
|| (tabSelected && shouldShowCloseButton(tab, i));
mStripTabs[i].setCanShowCloseButton(canShowCloseButton, !mIsFirstLayoutPass);
}
}
private void setTabContainerVisible(StripLayoutTab tab, boolean selected, boolean hovered) {
// Don't interrupt a hovered tab container visibility animation, this will be handled in the
// #onHover* methods.
if (hovered) return;
// Don't interrupt tab group background tab visibility.
if (tab.getContainerOpacity() == TAB_OPACITY_VISIBLE_BACKGROUND) return;
// The container will be visible if the tab is selected or is a placeholder tab.
float containerOpacity =
selected || tab.getIsPlaceholder()
? TAB_OPACITY_VISIBLE_FOREGROUND
: TAB_OPACITY_HIDDEN;
tab.setContainerOpacity(containerOpacity);
}
/**
* Called to show/hide dividers and the foreground/hovered tab container. Dividers are only
* necessary between tabs that both do not have a visible tab container (foreground or
* background).
*/
private void updateTabContainersAndDividers() {
if (mStripTabs.length < 1) return;
int hoveredId = mLastHoveredTab != null ? mLastHoveredTab.getTabId() : Tab.INVALID_TAB_ID;
// Divider is never shown for the first tab.
if (mStripViews[0] instanceof StripLayoutTab tab) {
tab.setStartDividerVisible(false);
setTabContainerVisible(tab, isSelectedTab(tab.getTabId()), hoveredId == tab.getTabId());
boolean currContainerHidden = tab.getContainerOpacity() == TAB_OPACITY_HIDDEN;
boolean endDividerVisible;
if (ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
// End divider should only be shown if the following view is a group indicator.
endDividerVisible =
currContainerHidden
&& mStripViews.length > 1
&& mStripViews[1] instanceof StripLayoutGroupTitle;
} else {
// End divider for first tab is only shown in reorder mode when tab has trailing
// margin and container is not visible.
endDividerVisible =
mInReorderMode && currContainerHidden && tab.getTrailingMargin() > 0;
}
tab.setEndDividerVisible(endDividerVisible);
}
for (int i = 1; i < mStripViews.length; i++) {
if (!(mStripViews[i] instanceof StripLayoutTab currTab)) continue;
boolean currTabSelected = isSelectedTab(currTab.getTabId());
boolean currTabHovered = hoveredId == currTab.getTabId();
// Set container opacity.
setTabContainerVisible(currTab, currTabSelected, currTabHovered);
/**
* Start divider should be visible when: 1. prevTab is dragged off of the strip OR 2.
* currTab container is hidden and (a) prevTab has trailing margin (ie: currTab is start
* of group or an individual tab) OR (b) prevTab container is also hidden.
*/
boolean currContainerHidden = currTab.getContainerOpacity() == TAB_OPACITY_HIDDEN;
boolean startDividerVisible;
if (mStripViews[i - 1] instanceof StripLayoutTab prevTab) {
boolean prevTabNotLeftMostAndDraggedOffStrip = prevTab.isDraggedOffStrip() && i > 1;
boolean prevContainerHidden = prevTab.getContainerOpacity() == TAB_OPACITY_HIDDEN;
boolean prevTabHasMargin = prevTab.getTrailingMargin() > 0;
startDividerVisible =
prevTabNotLeftMostAndDraggedOffStrip
|| (currContainerHidden
&& (prevContainerHidden || prevTabHasMargin));
} else {
startDividerVisible = false;
}
currTab.setStartDividerVisible(startDividerVisible);
/**
* End divider should be applied when: 1. currTab container is hidden and (a) currTab's
* trailing margin > 0 (i.e. is last tab in group) OR (b) currTab is last tab in strip
* (as the last tab does not have trailing margin)
*/
boolean currIsLastTab = i == (mStripViews.length - 1);
boolean endDividerVisible;
if (ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
// End divider should be shown if the following view is a group indicator.
endDividerVisible =
currContainerHidden
&& (currIsLastTab
|| mStripViews[i + 1] instanceof StripLayoutGroupTitle);
} else {
boolean currTabHasMargin = currTab.getTrailingMargin() > 0;
endDividerVisible = currContainerHidden && (currTabHasMargin || currIsLastTab);
}
currTab.setEndDividerVisible(endDividerVisible);
}
}
private void updateTouchableRect() {
// Make the entire strip touchable when during dragging / reordering mode.
boolean isTabDraggingInProgress =
mTabDragSource != null && mTabDragSource.isTabDraggingInProgress();
if (isTabStripFull() || mInReorderMode || isTabDraggingInProgress) {
mTouchableRect.set(getVisibleLeftBound(), 0, getVisibleRightBound(), mHeight);
return;
}
// When the tab strip is not full and not in recording mode, NTB is always showing after
// the last visible tab on strip.
RectF touchableRect = new RectF(0, 0, 0, mHeight);
RectF ntbTouchRect = new RectF();
getNewTabButton().getTouchTarget(ntbTouchRect);
boolean isRtl = LocalizationUtils.isLayoutRtl();
if (isRtl) {
touchableRect.right = getVisibleRightBound();
touchableRect.left = Math.max(ntbTouchRect.left, getVisibleLeftBound());
} else {
touchableRect.left = getVisibleLeftBound();
touchableRect.right = Math.min(ntbTouchRect.right, getVisibleRightBound());
}
mTouchableRect.set(touchableRect);
}
@VisibleForTesting
protected int getStripTabRootId(StripLayoutTab stripTab) {
if (mModel == null || stripTab == null || getTabById(stripTab.getTabId()) == null) {
return Tab.INVALID_TAB_ID;
}
return getTabById(stripTab.getTabId()).getRootId();
}
private boolean isStripTabInTabGroup(StripLayoutTab stripTab) {
if (stripTab == null || getTabById(stripTab.getTabId()) == null) {
return false;
}
return mTabGroupModelFilter.isTabInTabGroup(getTabById(stripTab.getTabId()));
}
/**
* Checks whether a tab at the edge of the strip is partially hidden, in which case the close
* button will be hidden to avoid accidental clicks.
*
* @param tab The tab to check.
* @param index The index of the tab.
* @return Whether the close button should be shown for this tab.
*/
private boolean shouldShowCloseButton(StripLayoutTab tab, int index) {
boolean tabStartHidden;
boolean tabEndHidden;
boolean isLastTab = index == mStripTabs.length - 1;
if (LocalizationUtils.isLayoutRtl()) {
if (isLastTab) {
tabStartHidden =
tab.getDrawX() + mTabOverlapWidth
< getVisibleLeftBound()
+ mNewTabButton.getDrawX()
+ mNewTabButton.getWidth();
} else {
tabStartHidden =
tab.getDrawX() + mTabOverlapWidth
< getVisibleLeftBound() + getCloseBtnVisibilityThreshold(false);
}
tabEndHidden =
tab.getDrawX() > getVisibleRightBound() - getCloseBtnVisibilityThreshold(true);
} else {
tabStartHidden =
tab.getDrawX() + tab.getWidth()
< getVisibleLeftBound() + getCloseBtnVisibilityThreshold(true);
if (isLastTab) {
tabEndHidden =
tab.getDrawX() + tab.getWidth() - mTabOverlapWidth
> getVisibleLeftBound() + mNewTabButton.getDrawX();
} else {
tabEndHidden =
(tab.getDrawX() + tab.getWidth() - mTabOverlapWidth
> getVisibleRightBound() - getCloseBtnVisibilityThreshold(false));
}
}
return !tabStartHidden && !tabEndHidden;
}
/**
* Called when a tab has started loading resources.
*
* @param id The id of the Tab.
*/
public void tabLoadStarted(int id) {
StripLayoutTab tab = findTabById(id);
if (tab != null) tab.loadingStarted();
}
/**
* Called when a tab has stopped loading resources.
* @param id The id of the Tab.
*/
public void tabLoadFinished(int id) {
StripLayoutTab tab = findTabById(id);
if (tab != null) tab.loadingFinished();
}
/**
* Called on touch drag event.
*
* @param time The current time of the app in ms.
* @param x The x coordinate of the end of the drag event.
* @param y The y coordinate of the end of the drag event.
* @param deltaX The number of pixels dragged in the x direction.
*/
public void drag(long time, float x, float y, float deltaX) {
resetResizeTimeout(false);
mLastUpdateTime = time;
deltaX = MathUtils.flipSignIf(deltaX, LocalizationUtils.isLayoutRtl());
// 1. Reset the button state.
mNewTabButton.drag(x, y);
// 2. If a tab was pressed in onDown and is now dragged, start tab drag/reorder.
// This is to enable tab drag with BUTTON_PRIMARY (mouse / trackpad) via onDown.
// Tab drags for touch events are handled via onLongPress.
if (mOnDownWithButtonPrimary && mInteractingTab != null && !mInReorderMode) {
startDragOrReorderTab(time, x, y, mInteractingTab);
}
if (mInReorderMode) {
// 3.a. Handle reordering tabs.
// This isn't the accumulated delta since the beginning of the drag. It accumulates
// the delta X until a threshold is crossed and then the event gets processed.
float accumulatedDeltaX = x - mLastReorderX;
if (Math.abs(accumulatedDeltaX) >= 1.f) {
if (!LocalizationUtils.isLayoutRtl()) {
if (deltaX >= 1.f) {
mReorderState |= REORDER_SCROLL_RIGHT;
} else if (deltaX <= -1.f) {
mReorderState |= REORDER_SCROLL_LEFT;
}
} else {
if (deltaX >= 1.f) {
mReorderState |= REORDER_SCROLL_LEFT;
} else if (deltaX <= -1.f) {
mReorderState |= REORDER_SCROLL_RIGHT;
}
}
mLastReorderX = x;
if (mReorderingForTabDrop) {
updateReorderPositionForTabDrop(x);
} else {
updateReorderPosition(accumulatedDeltaX);
}
}
} else {
// 3.b. Handle scroll.
if (!mIsStripScrollInProgress) {
mIsStripScrollInProgress = true;
RecordUserAction.record("MobileToolbarSlideTabs");
onStripScrollStart();
}
updateScrollOffsetPosition(mScrollDelegate.getScrollOffset() + deltaX);
}
// If we're scrolling at all we aren't interacting with any particular tab.
// We already kicked off a fast expansion earlier if we needed one. Reorder mode will
// repopulate this if necessary.
if (!mInReorderMode) mInteractingTab = null;
mUpdateHost.requestUpdate();
}
void dragForTabDrop(long time, float x, float y, float deltaX, boolean draggedTabIncognito) {
if (mIncognito == draggedTabIncognito) {
drag(time, x, y, deltaX);
}
}
private void onStripScrollStart() {
long currentTime = SystemClock.elapsedRealtime();
// If last scroll is within the max allowed interval, do not reset start time.
if (mMostRecentTabScroll != null
&& currentTime - mMostRecentTabScroll
<= TAB_SWITCH_METRICS_MAX_ALLOWED_SCROLL_INTERVAL) {
mMostRecentTabScroll = currentTime;
return;
}
mTabScrollStartTime = currentTime;
mMostRecentTabScroll = currentTime;
}
/**
* Called on touch fling event. This is called before the onUpOrCancel event.
* @param time The current time of the app in ms.
* @param x The y coordinate of the start of the fling event.
* @param y The y coordinate of the start of the fling event.
* @param velocityX The amount of velocity in the x direction.
* @param velocityY The amount of velocity in the y direction.
*/
public void fling(long time, float x, float y, float velocityX, float velocityY) {
resetResizeTimeout(false);
// 1. If we're currently in reorder mode, don't allow the user to fling. Else, ensure that
// the interacting tab is cleared.
if (mInReorderMode) return;
mInteractingTab = null;
// 2. Begin scrolling.
mScrollDelegate.fling(
time, MathUtils.flipSignIf(velocityX, LocalizationUtils.isLayoutRtl()));
mUpdateHost.requestUpdate();
}
/**
* Called on onDown event.
* @param time The time stamp in millisecond of the event.
* @param x The x position of the event.
* @param y The y position of the event.
* @param fromMouse Whether the event originates from a mouse.
* @param buttons State of all buttons that are pressed.
*/
public void onDown(long time, float x, float y, boolean fromMouse, int buttons) {
// Prepare for drag and drop beyond the StripLayout view, if needed.
// The first onDown is passed by the Chrome pipeline directly by GestureHandler. The
// subsequent ones may be simulated by the DragDrop handler if the pointer goes beyond the
// strip layout view.
mActiveClickedTab = null;
mLastOffsetX = 0.f;
resetResizeTimeout(false);
if (mNewTabButton.onDown(x, y, fromMouse, buttons)) {
mRenderHost.requestRender();
return;
}
final StripLayoutTab clickedTab = getTabAtPosition(x);
final int index =
clickedTab != null
? TabModelUtils.getTabIndexById(mModel, clickedTab.getTabId())
: TabModel.INVALID_TAB_INDEX;
// http://crbug.com/472186 : Needs to handle a case that index is invalid.
// The case could happen when the current tab is touched while we're inflating the rest of
// the tabs from disk.
mInteractingTab =
index != TabModel.INVALID_TAB_INDEX && index < mStripTabs.length
? mStripTabs[index]
: null;
boolean clickedClose = clickedTab != null && clickedTab.checkCloseHitTest(x, y);
if (clickedClose) {
clickedTab.setClosePressed(true, fromMouse);
mRenderHost.requestRender();
}
if (!mScrollDelegate.isFinished()) {
mScrollDelegate.stopScroll();
mInteractingTab = null;
}
// If event is from primary button click, set flag to use during drag.
if (MotionEventUtils.isPrimaryButton(buttons) && !clickedClose && clickedTab != null) {
mOnDownWithButtonPrimary = true;
}
}
/**
* Called on long press touch event.
*
* @param time The current time of the app in ms.
* @param x The x coordinate of the position of the press event.
* @param y The y coordinate of the position of the press event.
*/
public void onLongPress(long time, float x, float y) {
StripLayoutView stripView = getViewAtPositionX(x, true);
if (stripView == null || stripView instanceof StripLayoutTab) {
StripLayoutTab clickedTab = stripView != null ? (StripLayoutTab) stripView : null;
if (clickedTab != null && clickedTab.checkCloseHitTest(x, y)) {
clickedTab.setClosePressed(false, false);
mRenderHost.requestRender();
showTabMenu(clickedTab);
} else {
resetResizeTimeout(false);
startDragOrReorderTab(time, x, y, clickedTab);
}
} else if (ChromeFeatureList.isEnabled(ChromeFeatureList.TAB_STRIP_GROUP_CONTEXT_MENU)
&& ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
showTabGroupContextMenu((StripLayoutGroupTitle) stripView);
}
}
private void showTabGroupContextMenu(StripLayoutGroupTitle groupTitle) {
if (mTabGroupContextMenuCoordinator == null) {
mTabGroupContextMenuCoordinator =
new TabGroupContextMenuCoordinator(
() -> mModel,
mTabGroupModelFilter,
mActionConfirmationManager,
mTabCreator,
mWindowAndroid,
TabGroupSyncFeatures.isTabGroupSyncEnabled(mModel.getProfile()));
}
// Popup menu requires screen coordinates for anchor view. Get absolute position for title.
Rect tabGroupTitleRect = new Rect();
groupTitle.getDrawBoundsOnScreen(tabGroupTitleRect, mWindowRectSupplier);
mTabGroupContextMenuCoordinator.showMenu(
new RectProvider(tabGroupTitleRect), groupTitle.getRootId());
}
private void startDragOrReorderTab(long time, float x, float y, StripLayoutTab clickedTab) {
// Allow the user to drag the selected tab out of the tab toolbar.
if (clickedTab != null) {
boolean res = startDragAndDropTab(clickedTab, new PointF(x, y));
// If tab drag did not succeed, fallback to reorder within strip.
if (!res) {
startReorderTab(time, x, x);
}
} else {
// Broadcast to start moving the window instance as the user has long pressed on the
// open space of the tab strip.
// TODO(crbug.com/358191015): Decouple the move window broadcast from this method and
// maybe move to #onLongPress when `stripView` is null.
sendMoveWindowBroadcast(mToolbarContainerView, x, y);
}
}
/**
* Called on hover enter event.
*
* @param x The x coordinate of the position of the hover enter event.
*/
public void onHoverEnter(float x, float y) {
StripLayoutTab hoveredTab = getTabAtPosition(x);
// Hovered into a tab on the strip.
if (hoveredTab != null) {
updateLastHoveredTab(hoveredTab);
// Check whether the close button on the hovered tab is being hovered on.
hoveredTab.setCloseHovered(hoveredTab.checkCloseHitTest(x, y));
} else {
// Check whether new tab button or model selector button is being hovered.
updateCompositorButtonHoverState(x, y);
}
mUpdateHost.requestUpdate();
}
/**
* Called on hover move event.
*
* @param x The x coordinate of the position of the hover move event.
*/
public void onHoverMove(float x, float y) {
// Check whether new tab button or model selector button is being hovered.
updateCompositorButtonHoverState(x, y);
StripLayoutTab hoveredTab = getTabAtPosition(x);
// Hovered into a non-tab region within the strip.
if (hoveredTab == null) {
clearLastHoveredTab();
mUpdateHost.requestUpdate();
return;
}
// Hovered within the same tab that was last hovered into and close button hover state
// remains unchanged.
boolean isCloseHit = hoveredTab.checkCloseHitTest(x, y);
if (hoveredTab == mLastHoveredTab && hoveredTab.isCloseHovered() == isCloseHit) {
return;
} else if (hoveredTab == mLastHoveredTab) {
// Hovered within the same tab that was last hovered into, but close button hover state
// has changed.
hoveredTab.setCloseHovered(isCloseHit);
} else {
// Hovered from one tab to another tab on the strip.
clearLastHoveredTab();
updateLastHoveredTab(hoveredTab);
}
mUpdateHost.requestUpdate();
}
/** Called on hover exit event. */
public void onHoverExit() {
clearLastHoveredTab();
// Clear tab strip button (NTB and MSB) hover state.
clearCompositorButtonHoverStateIfNotClicked();
mUpdateHost.requestUpdate();
}
/** Called in post delay task in q#onDown to clear tab hover state. */
protected void clearTabHoverState() {
clearLastHoveredTab();
mUpdateHost.requestUpdate();
}
/** Check whether model selector button or new tab button is being hovered. */
private void updateCompositorButtonHoverState(float x, float y) {
boolean isModelSelectorHovered = false;
if (mModelSelectorButton != null) {
// Model selector button is being hovered.
isModelSelectorHovered = mModelSelectorButton.checkClickedOrHovered(x, y);
mModelSelectorButton.setHovered(isModelSelectorHovered);
}
// There's a delay in updating NTB's position/touch target when MSB initially appears on the
// strip, taking over NTB's position and moving NTB closer to the tabs. Consequently, hover
// highlights are observed on both NTB and MSB. To address this, this check is added to
// ensure only one button can be hovered at a time.
if (!isModelSelectorHovered) {
mNewTabButton.setHovered(mNewTabButton.checkClickedOrHovered(x, y));
} else {
mNewTabButton.setHovered(false);
}
}
/** Clear button hover state */
private void clearCompositorButtonHoverStateIfNotClicked() {
mNewTabButton.setHovered(false);
if (mModelSelectorButton != null) {
mModelSelectorButton.setHovered(false);
}
}
@VisibleForTesting
void setTabHoverCardView(StripTabHoverCardView tabHoverCardView) {
mTabHoverCardView = tabHoverCardView;
}
StripTabHoverCardView getTabHoverCardViewForTesting() {
return mTabHoverCardView;
}
void setLastHoveredTabForTesting(StripLayoutTab tab) {
mLastHoveredTab = tab;
}
StripLayoutTab getLastHoveredTab() {
return mLastHoveredTab;
}
void setTabGroupContextMenuCoordinatorForTesting(
TabGroupContextMenuCoordinator tabGroupContextMenuCoordinator) {
mTabGroupContextMenuCoordinator = tabGroupContextMenuCoordinator;
}
private void clearLastHoveredTab() {
if (mLastHoveredTab == null) return;
assert mTabHoverCardView != null : "Hover card view should not be null.";
// Clear close button hover state.
mLastHoveredTab.setCloseHovered(false);
// Remove the highlight from the last hovered tab.
updateHoveredTabAttachedState(mLastHoveredTab, false);
mTabHoverCardView.hide();
mLastHoveredTab = null;
}
@VisibleForTesting
void updateLastHoveredTab(StripLayoutTab hoveredTab) {
if (hoveredTab == null) return;
// Do nothing if attempting to update the hover state of a tab while a tab strip animation
// is running. This is to avoid applying the tab hover state during animations triggered for
// some actions on the strip, for example, resizing the strip after tab closure, that might
// cause the hover state to show / stick undesirably.
if (mRunningAnimator != null && mRunningAnimator.isRunning()) return;
// Do nothing if hovering into a drawn tab that is for example, hidden behind the model
// selector button.
if (isViewCompletelyHidden(hoveredTab)) return;
mLastHoveredTab = hoveredTab;
if (!mAnimationsDisabledForTesting) {
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
hoveredTab,
StripLayoutTab.OPACITY,
hoveredTab.getContainerOpacity(),
TAB_OPACITY_VISIBLE_FOREGROUND,
ANIM_HOVERED_TAB_CONTAINER_FADE_MS)
.start();
} else {
hoveredTab.setContainerOpacity(TAB_OPACITY_VISIBLE_FOREGROUND);
}
updateHoveredTabAttachedState(mLastHoveredTab, true);
// Show the tab hover card.
int hoveredTabIndex = findIndexForTab(mLastHoveredTab.getTabId());
mTabHoverCardView.show(
mModel.getTabAt(hoveredTabIndex),
isSelectedTab(mLastHoveredTab.getTabId()),
mLastHoveredTab.getDrawX(),
mLastHoveredTab.getWidth(),
mHeight);
}
private void updateHoveredTabAttachedState(StripLayoutTab tab, boolean hovered) {
if (tab == null) return;
// Do not update the attached state of a selected tab that is hovered on.
if (isSelectedTab(tab.getTabId())) return;
// If a tab is hovered on, detach its container.
tab.setFolioAttached(!hovered);
tab.setBottomMargin(
hovered ? FOLIO_DETACHED_BOTTOM_MARGIN_DP : FOLIO_ATTACHED_BOTTOM_MARGIN_DP);
}
private void handleNewTabClick() {
if (mModel == null) return;
if (!mModel.isIncognito()) mModel.commitAllTabClosures();
mTabCreator.launchNtp();
}
@Override
public void handleCloseButtonClick(final StripLayoutTab tab, long time) {
// Placeholder tabs are expected to have invalid tab ids.
if (tab == null || tab.isDying() || tab.getTabId() == Tab.INVALID_TAB_ID) return;
int tabId = tab.getTabId();
int rootId = getTabById(tabId).getRootId();
if (isLastTabInGroup(tabId) && !mIncognito) {
showDeleteGroupDialogAndProcessTabAction(
rootId,
/* draggingLastTabOffStrip= */ false,
/* closeTab= */ true,
() -> handleCloseTab(tab, time));
} else {
handleCloseTab(tab, time);
}
}
private void handleCloseTab(final StripLayoutTab tab, long time) {
mMultiStepTabCloseAnimRunning = false;
finishAnimationsAndPushTabUpdates();
// When a tab is closed #resizeStripOnTabClose will run animations for the new tab offset
// and tab x offsets. When there is only 1 tab remaining, we do not need to run those
// animations, so #resizeTabStrip() is used instead.
boolean runImprovedTabAnimations = mStripTabs.length > 1;
// 1. Set the dying state of the tab.
tab.setIsDying(true);
// 2. Start the tab closing animator with a listener to resize/move tabs after the closure.
AnimatorListener listener =
new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (runImprovedTabAnimations) {
// This removes any closed tabs from the tabModel.
finishAnimationsAndPushTabUpdates();
resizeStripOnTabClose(getTabById(tab.getTabId()));
} else {
mMultiStepTabCloseAnimRunning = false;
mNewTabButtonAnimRunning = false;
// Resize the tabs appropriately.
resizeTabStrip(true, false, false);
}
}
};
runTabRemovalAnimation(tab, listener);
// 3. If we're closing the selected tab, attempt to select the next expanded tab now. If
// none exists, we'll default to the normal auto-selection behavior (i.e. selecting the
// closest collapsed tab, or opening the GTS if none exist).
if (getSelectedTabId() == tab.getTabId()) {
int nextIndex = getNearbyExpandedTabIndex();
if (nextIndex != TabModel.INVALID_TAB_INDEX) {
TabModelUtils.setIndex(mModel, nextIndex);
}
}
}
private void runTabRemovalAnimation(StripLayoutTab tab, AnimatorListener listener) {
// 1. Setup the close animation.
List<Animator> tabClosingAnimators = new ArrayList<>();
tabClosingAnimators.add(
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
tab,
StripLayoutTab.Y_OFFSET,
tab.getOffsetY(),
tab.getHeight(),
ANIM_TAB_CLOSED_MS));
// 2. Start the animation.
mNewTabButtonAnimRunning = true;
mMultiStepTabCloseAnimRunning = true;
startAnimationList(tabClosingAnimators, listener);
}
private void resizeStripOnTabClose(Tab closedTab) {
List<Animator> tabStripAnimators = new ArrayList<>();
// 1. Add tabs expanding animators to expand remaining tabs to fill scrollable area.
List<Animator> tabExpandAnimators = computeAndUpdateTabWidth(true, true, closedTab);
if (tabExpandAnimators != null) tabStripAnimators.addAll(tabExpandAnimators);
// 2. Calculate new scroll offset and idealX for tab offset animation.
updateScrollOffsetLimits();
computeTabInitialPositions();
// 3. Animate the tabs sliding to their idealX.
for (int i = 0; i < mStripViews.length; ++i) {
final StripLayoutView view = mStripViews[i];
if (view.getDrawX() == view.getIdealX()) {
// Don't animate views that won't change location.
continue;
} else if (isViewCompletelyHidden(view) && willViewBeCompletelyHidden(view)) {
// Don't animate views that won't be seen by the user (i.e. not currently visible
// and won't be visible after moving) - just set the draw X immediately.
view.setDrawX(view.getIdealX());
continue;
}
CompositorAnimator drawXAnimator =
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
view,
StripLayoutView.X_OFFSET,
view.getDrawX() - view.getIdealX(),
0.f,
ANIM_TAB_DRAW_X_MS);
tabStripAnimators.add(drawXAnimator);
}
// 4. Add new tab button offset animation.
tabStripAnimators.add(getLastTabClosedNtbAnimator());
// 5. Add animation completion listener and start animations.
startAnimationList(
tabStripAnimators,
new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mMultiStepTabCloseAnimRunning = false;
mNewTabButtonAnimRunning = false;
}
});
}
@Override
public void handleTabClick(StripLayoutTab tab) {
if (tab == null || tab.isDying()) return;
int newIndex = TabModelUtils.getTabIndexById(mModel, tab.getTabId());
// Early return, since placeholder tabs are known to not have tab ids.
if (newIndex == Tab.INVALID_TAB_ID) return;
TabModelUtils.setIndex(mModel, newIndex);
}
/**
* Called on click. This is called before the onUpOrCancel event.
*
* @param time The current time of the app in ms.
* @param x The x coordinate of the position of the click.
* @param y The y coordinate of the position of the click.
* @param fromMouse Whether the event originates from a mouse.
* @param buttons State of all buttons that were pressed when onDown was invoked.
*/
public void click(long time, float x, float y, boolean fromMouse, int buttons) {
resetResizeTimeout(false);
if (mNewTabButton.click(x, y, fromMouse, buttons)) {
RecordUserAction.record("MobileToolbarNewTab");
mNewTabButton.handleClick(time);
return;
}
final StripLayoutView clickedView = getViewAtPositionX(x, true);
if (clickedView == null) return;
if (clickedView instanceof StripLayoutTab clickedTab) {
if (clickedTab.isDying()) return;
if (clickedTab.checkCloseHitTest(x, y)
|| (fromMouse && (buttons & MotionEvent.BUTTON_TERTIARY) != 0)) {
RecordUserAction.record("MobileToolbarCloseTab");
clickedTab.getCloseButton().handleClick(time);
} else {
RecordUserAction.record("MobileTabSwitched.TabletTabStrip");
recordTabSwitchTimeHistogram();
clickedTab.handleClick(time);
}
} else if (clickedView instanceof StripLayoutGroupTitle clickedGroupTitle) {
clickedGroupTitle.handleClick(time);
}
}
private void recordTabSwitchTimeHistogram() {
if (mTabScrollStartTime == null || mMostRecentTabScroll == null) return;
long endTime = SystemClock.elapsedRealtime();
long duration = endTime - mTabScrollStartTime;
long timeFromLastInteraction = endTime - mMostRecentTabScroll;
// Discard sample if last scroll was over the max allowed interval.
if (timeFromLastInteraction <= TAB_SWITCH_METRICS_MAX_ALLOWED_SCROLL_INTERVAL) {
RecordHistogram.recordMediumTimesHistogram(
"Android.TabStrip.TimeToSwitchTab", duration);
}
mTabScrollStartTime = null;
mMostRecentTabScroll = null;
}
/**
* Called on up or cancel touch events. This is called after the click and fling event if any.
*
* @param time The current time of the app in ms.
*/
public void onUpOrCancel(long time) {
// 1. Stop any reordering that is happening.
stopReorderMode();
// 2. Reset state
mInteractingTab = null;
mReorderState = REORDER_SCROLL_NONE;
if (mNewTabButton.onUpOrCancel() && mModel != null) {
if (!mModel.isIncognito()) mModel.commitAllTabClosures();
mTabCreator.launchNtp();
}
mIsStripScrollInProgress = false;
mOnDownWithButtonPrimary = false;
}
/**
* @return Whether or not the tabs are moving.
*/
public boolean isAnimatingForTesting() {
return (mRunningAnimator != null && mRunningAnimator.isRunning())
|| !mScrollDelegate.isFinished();
}
private void finishAnimations() {
// Force any outstanding animations to finish. Need to recurse as some animations (like the
// multi-step tab close animation) kick off another animation once the first ends.
while (mRunningAnimator != null && mRunningAnimator.isRunning()) {
mRunningAnimator.end();
}
mRunningAnimator = null;
}
@VisibleForTesting
void startAnimationList(@Nullable List<Animator> animationList, AnimatorListener listener) {
AnimatorSet set = new AnimatorSet();
set.playTogether(animationList);
if (listener != null) set.addListener(listener);
finishAnimations();
setAndStartRunningAnimator(set);
}
private void setAndStartRunningAnimator(Animator animator) {
mRunningAnimator = animator;
mRunningAnimator.addListener(
new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
// Clear any persisting tab hover state after tab strip animations have
// ended. This is to prevent the hover state from sticking after an action
// on the strip, including and not limited to tab closure and tab
// reordering.
clearTabHoverState();
}
});
mRunningAnimator.start();
}
/**
* Finishes any outstanding animations and propagates any related changes to the
* {@link TabModel}.
*/
public void finishAnimationsAndPushTabUpdates() {
if (mRunningAnimator == null) return;
// 1. Finish animations.
finishAnimations();
// 2. Figure out which tabs need to be closed.
ArrayList<StripLayoutTab> tabsToRemove = new ArrayList<StripLayoutTab>();
for (int i = 0; i < mStripTabs.length; i++) {
StripLayoutTab tab = mStripTabs[i];
if (tab.isDying()) tabsToRemove.add(tab);
}
if (tabsToRemove.isEmpty()) return;
// 3. Pass the close notifications to the model if the tab isn't already closing.
// Do this as a post task as if more tabs are added inside commit all tab closures that
// is a concurrent modification exception.
for (StripLayoutTab tab : tabsToRemove) tab.setIsClosed(true);
PostTask.postTask(
TaskTraits.UI_DEFAULT,
() -> {
for (StripLayoutTab tab : tabsToRemove) {
TabModelUtils.closeTabById(mModel, tab.getTabId(), true);
}
if (!tabsToRemove.isEmpty()) mUpdateHost.requestUpdate();
});
}
private void updateSpinners(long time) {
long diff = time - mLastSpinnerUpdate;
float degrees = diff * SPINNER_DPMS;
boolean tabsToLoad = false;
for (int i = 0; i < mStripTabs.length; i++) {
StripLayoutTab tab = mStripTabs[i];
// TODO(clholgat): Only update if the tab is visible.
if (tab.isLoading()) {
tab.addLoadingSpinnerRotation(degrees);
tabsToLoad = true;
}
}
mLastSpinnerUpdate = time;
if (tabsToLoad) {
mStripTabEventHandler.removeMessages(MESSAGE_UPDATE_SPINNER);
mStripTabEventHandler.sendEmptyMessageDelayed(
MESSAGE_UPDATE_SPINNER, SPINNER_UPDATE_DELAY_MS);
}
}
private void updateScrollOffsetPosition(float pos) {
float delta = mScrollDelegate.setClampedScrollOffset(pos);
if (mInReorderMode && mScrollDelegate.isFinished()) {
if (mReorderingForTabDrop) {
updateReorderPositionForTabDrop(mLastReorderX);
} else {
updateReorderPosition(delta);
}
}
}
@VisibleForTesting
void updateScrollOffsetLimits() {
boolean shouldShowTrailingMargins = mInReorderMode || mTabGroupMarginAnimRunning;
mScrollDelegate.updateScrollOffsetLimits(
mStripViews,
mWidth,
mLeftMargin,
mRightMargin,
mCachedTabWidth,
mTabOverlapWidth,
mGroupTitleOverlapWidth,
mStripStartMarginForReorder,
shouldShowTrailingMargins);
}
private List<Animator> computeAndUpdateTabOrders(boolean delayResize, boolean deferAnimations) {
final int count = mModel.getCount();
StripLayoutTab[] tabs = new StripLayoutTab[count];
for (int i = 0; i < count; i++) {
final Tab tab = mModel.getTabAt(i);
final int id = tab.getId();
final StripLayoutTab oldTab = findTabById(id);
tabs[i] = oldTab != null ? oldTab : createStripTab(id);
setAccessibilityDescription(tabs[i], tab);
}
int oldTabsLength = mStripTabs.length;
mStripTabs = tabs;
rebuildStripViews();
List<Animator> animationList = null;
// If multi-step animation is running, the resize will be handled elsewhere.
if (mStripTabs.length != oldTabsLength && !mMultiStepTabCloseAnimRunning) {
computeTabInitialPositions();
animationList = resizeTabStrip(true, delayResize, deferAnimations);
}
updateVisualTabOrdering();
return animationList;
}
private String buildGroupAccessibilityDescription(@NonNull StripLayoutGroupTitle groupTitle) {
final String contentDescriptionSeparator = " - ";
Resources res = mContext.getResources();
StringBuilder builder = new StringBuilder();
String groupDescription = groupTitle.getTitle();
if (TextUtils.isEmpty(groupDescription)) {
// TODO(crbug.com/349696415): Investigate if we can indeed remove this now that we
// default to showing "N tabs" for unnamed groups.
// "Unnamed group"
int titleRes = R.string.accessibility_tabstrip_group_identifier_unnamed;
groupDescription = res.getString(titleRes);
}
builder.append(groupDescription);
List<Tab> relatedTabs =
mTabGroupModelFilter.getRelatedTabListForRootId(groupTitle.getRootId());
int relatedTabsCount = relatedTabs.size();
if (relatedTabsCount > 0) {
// " - "
builder.append(contentDescriptionSeparator);
String firstTitle = relatedTabs.get(0).getTitle();
String tabsDescription;
if (relatedTabsCount == 1) {
// <title>
tabsDescription = firstTitle;
} else {
// <title> and <num> other tabs
int descriptionRes = R.string.accessibility_tabstrip_group_identifier_multiple_tabs;
tabsDescription = res.getString(descriptionRes, firstTitle, relatedTabsCount - 1);
}
builder.append(tabsDescription);
}
return builder.toString();
}
private void updateGroupAccessibilityDescription(StripLayoutGroupTitle groupTitle) {
if (groupTitle == null) return;
groupTitle.setAccessibilityDescription(buildGroupAccessibilityDescription(groupTitle));
}
@Override
public void releaseResourcesForGroupTitle(int rootId) {
mLayerTitleCache.removeGroupTitle(rootId);
}
@Override
public void rebuildResourcesForGroupTitle(StripLayoutGroupTitle groupTitle) {
updateGroupTitleBitmapIfNeeded(groupTitle);
}
private AnimatorListener getCollapseAnimatorListener(
StripLayoutGroupTitle collapsedGroupTitle) {
return new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (collapsedGroupTitle != null) collapsedGroupTitle.setBottomIndicatorWidth(0.f);
}
};
}
private ArrayList<StripLayoutTab> getGroupedTabs(int rootId) {
ArrayList<StripLayoutTab> groupedTabs = new ArrayList<>();
for (int i = 0; i < mStripTabs.length; ++i) {
final StripLayoutTab stripTab = mStripTabs[i];
final Tab tab = getTabById(stripTab.getTabId());
if (tab != null && tab.getRootId() == rootId) groupedTabs.add(stripTab);
}
return groupedTabs;
}
void collapseTabGroupForTesting(StripLayoutGroupTitle groupTitle, boolean isCollapsed) {
updateTabGroupCollapsed(groupTitle, isCollapsed, true);
}
@Override
public void handleGroupTitleClick(StripLayoutGroupTitle groupTitle) {
if (!ChromeFeatureList.sTabStripGroupCollapse.isEnabled()) return;
if (groupTitle == null) return;
int rootId = groupTitle.getRootId();
boolean isCollapsed = mTabGroupModelFilter.getTabGroupCollapsed(rootId);
assert isCollapsed == groupTitle.isCollapsed();
mTabGroupModelFilter.setTabGroupCollapsed(rootId, !isCollapsed);
RecordHistogram.recordBooleanHistogram("Android.TabStrip.TabGroupCollapsed", !isCollapsed);
}
private Animator updateTabCollapsed(StripLayoutTab tab, boolean isCollapsed, boolean animate) {
tab.setCollapsed(isCollapsed);
// The tab expand will be handled when the tab strip resizes, since we'll need to first
// update mCachedTabWidth.
if (!isCollapsed) return null;
// Set to the tab overlap width so the tab effectively takes up no space. If we instead
// animate to 0, the following tabs will unexpectedly be shifted as this tab takes up
// "negative" space.
if (!animate) {
tab.setWidth(mTabOverlapWidth);
return null;
}
return CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
tab,
StripLayoutTab.WIDTH,
tab.getWidth(),
mTabOverlapWidth,
ANIM_TAB_RESIZE_MS);
}
private void updateTabGroupCollapsed(
StripLayoutGroupTitle groupTitle, boolean isCollapsed, boolean animate) {
if (!ChromeFeatureList.sTabStripGroupCollapse.isEnabled()) return;
if (groupTitle.isCollapsed() == isCollapsed) return;
List<Animator> collapseAnimationList = null;
if (animate && !mAnimationsDisabledForTesting) collapseAnimationList = new ArrayList<>();
finishAnimations();
groupTitle.setCollapsed(isCollapsed);
for (StripLayoutTab tab : getGroupedTabs(groupTitle.getRootId())) {
if (collapseAnimationList != null) {
Animator animator = updateTabCollapsed(tab, isCollapsed, true);
if (animator != null) collapseAnimationList.add(animator);
} else {
updateTabCollapsed(tab, isCollapsed, false);
}
}
// Similar to bottom indicator collapse animation, the expansion animation should also begin
// from the padded width of the group title.
if (!isCollapsed) {
groupTitle.setBottomIndicatorWidth(groupTitle.getPaddedWidth());
}
List<Animator> resizeAnimationList = resizeTabStrip(animate, false, animate);
if (collapseAnimationList != null) {
StripLayoutGroupTitle collapsedGroupTitle = null;
if (isCollapsed) {
// Animate bottom indicator down to the group title padded width when collapsing,
// and then hide the remaining portion under the group title.
collapsedGroupTitle = groupTitle;
collapseAnimationList.add(
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
groupTitle,
StripLayoutGroupTitle.BOTTOM_INDICATOR_WIDTH,
groupTitle.getBottomIndicatorWidth(),
groupTitle.getPaddedWidth(),
ANIM_TAB_RESIZE_MS));
}
if (resizeAnimationList != null) collapseAnimationList.addAll(resizeAnimationList);
startAnimationList(
collapseAnimationList, getCollapseAnimatorListener(collapsedGroupTitle));
} else {
if (isCollapsed) {
groupTitle.setBottomIndicatorWidth(0.f);
}
}
// Select an adjacent expanded tab if the current selected tab is being collapsed, If all
// tabs are collapsed, open a ntp.
if (isCollapsed) {
Tab selectedTab = getTabById(getSelectedTabId());
if (selectedTab != null && selectedTab.getRootId() == groupTitle.getRootId()) {
int nextIndex = getNearbyExpandedTabIndex();
if (nextIndex != TabModel.INVALID_TAB_INDEX) {
TabModelUtils.setIndex(mModel, nextIndex);
} else {
mTabCreator.launchNtp();
}
}
}
}
/**
* Handles edge cases such as merging a selected tab into a collapsed tab group through GTS,
* followed by exiting GTS with a back gesture. The tab group containing the selected tab should
* be expanded.
*/
protected void expandGroupOnGtsExit() {
StripLayoutTab selectedTab = getSelectedStripTab();
if (selectedTab == null) {
return;
}
Tab tab = getTabById(selectedTab.getTabId());
if (tab != null && mTabGroupModelFilter.getTabGroupCollapsed(tab.getRootId())) {
mTabGroupModelFilter.deleteTabGroupCollapsed(tab.getRootId());
}
}
/**
* @return The index of the nearby expanded tab to the selected tab. Prioritizes tabs before the
* selected tab. If none are found, return an invalid index.
*/
private int getNearbyExpandedTabIndex() {
int index = getSelectedStripTabIndex();
for (int i = index - 1; i >= 0; --i) {
if (!mStripTabs[i].isCollapsed()) return i;
}
for (int i = index + 1; i < mStripTabs.length; ++i) {
if (!mStripTabs[i].isCollapsed()) return i;
}
return TabModel.INVALID_TAB_INDEX;
}
/**
* Called to refresh the group title bitmap when it may have changed (text, color, etc.).
*
* @param groupTitle The group title to refresh the bitmap for.
*/
private void updateGroupTitleBitmapIfNeeded(@NonNull StripLayoutGroupTitle groupTitle) {
if (groupTitle.isVisible()) {
mLayerTitleCache.getUpdatedGroupTitle(
groupTitle.getRootId(), groupTitle.getTitle(), mIncognito);
mRenderHost.requestRender();
}
}
private void updateGroupTitleTint(StripLayoutGroupTitle groupTitle) {
int colorId = mTabGroupModelFilter.getTabGroupColor(groupTitle.getRootId());
// If the color is invalid, temporarily assign a default placeholder color.
if (colorId == TabGroupColorUtils.INVALID_COLOR_ID) colorId = TabGroupColorId.GREY;
updateGroupTitleTint(groupTitle, colorId);
}
private void updateGroupTitleTint(
StripLayoutGroupTitle groupTitle, @TabGroupColorId int newColor) {
if (groupTitle == null) return;
groupTitle.updateTint(
ColorPickerUtils.getTabGroupColorPickerItemColor(mContext, newColor, mIncognito));
updateGroupTitleBitmapIfNeeded(groupTitle);
}
/**
* @param rootId The root ID of the relevant tab group.
* @param titleText The tab group's title text, if any. Null otherwise.
* @return The provided title text if it isn't empty. Otherwise, returns the default title.
*/
private String getDefaultGroupTitleTextIfEmpty(int rootId, @Nullable String titleText) {
if (TextUtils.isEmpty(titleText)) {
int numTabs = mTabGroupModelFilter.getRelatedTabCountForRootId(rootId);
titleText = TabGroupTitleEditor.getDefaultTitle(mContext, numTabs);
}
return titleText;
}
@VisibleForTesting
void updateGroupTitleText(int rootId) {
updateGroupTitleText(findGroupTitle(rootId));
}
private void updateGroupTitleText(StripLayoutGroupTitle groupTitle) {
if (groupTitle == null) return;
updateGroupTitleText(
groupTitle, mTabGroupModelFilter.getTabGroupTitle(groupTitle.getRootId()));
}
/**
* Sets a non-empty title text for the given group indicator. Also updates the title text
* bitmap, accessibility description, and tab/indicator sizes if necessary.
*
* @param groupTitle The {@link StripLayoutGroupTitle} that we're update the title text for.
* @param titleText The title text to apply. If empty, use a default title text.
*/
private void updateGroupTitleText(StripLayoutGroupTitle groupTitle, String titleText) {
assert groupTitle != null;
// 1. Update indicator text and width.
titleText = getDefaultGroupTitleTextIfEmpty(groupTitle.getRootId(), titleText);
int widthPx = mLayerTitleCache.getGroupTitleWidth(mIncognito, titleText);
float widthDp = widthPx / mContext.getResources().getDisplayMetrics().density;
float oldWidth = groupTitle.getWidth();
groupTitle.updateTitle(titleText, widthDp);
updateGroupAccessibilityDescription(groupTitle);
// 2. Update title text bitmap if needed.
updateGroupTitleBitmapIfNeeded(groupTitle);
// 3. Handle indicator size change if needed.
if (groupTitle.getWidth() != oldWidth) {
if (groupTitle.isVisible()) {
// If on-screen, this may result in the ideal tab width changing.
resizeTabStrip(false, false, false);
} else {
// If off-screen, request an update so we re-calculate tab initial positions and the
// minimum scroll offset.
mUpdateHost.requestUpdate();
}
}
}
private StripLayoutGroupTitle findGroupTitle(int rootId) {
for (int i = 0; i < mStripGroupTitles.length; i++) {
final StripLayoutGroupTitle groupTitle = mStripGroupTitles[i];
if (groupTitle.getRootId() == rootId) return groupTitle;
}
return null;
}
private StripLayoutGroupTitle findOrCreateGroupTitle(int rootId) {
StripLayoutGroupTitle groupTitle = findGroupTitle(rootId);
return groupTitle == null ? createGroupTitle(rootId) : groupTitle;
}
private StripLayoutGroupTitle createGroupTitle(int rootId) {
// Delay setting the collapsed state, since mStripViews may not yet be up to date.
StripLayoutGroupTitle groupTitle =
new StripLayoutGroupTitle(mContext, /* delegate= */ this, mIncognito, rootId);
pushPropertiesToGroupTitle(groupTitle);
// Must pass in the group title instead of rootId, since the StripLayoutGroupTitle has not
// been added to mStripViews yet.
updateGroupTitleText(groupTitle);
updateGroupTitleTint(groupTitle);
return groupTitle;
}
private void pushPropertiesToGroupTitle(StripLayoutGroupTitle groupTitle) {
groupTitle.setDrawY(0);
groupTitle.setHeight(mHeight);
}
@VisibleForTesting
void rebuildStripViews() {
if (mTabGroupModelFilter != null
&& mTabStateInitialized
&& ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
copyTabsWithGroupTitles();
buildBottomIndicator();
} else {
copyTabs();
}
mUpdateHost.requestUpdate();
}
private int getTabGroupCount() {
Set<Integer> groupRootIds = new HashSet<>();
for (int i = 0; i < mStripTabs.length; ++i) {
final StripLayoutTab stripTab = mStripTabs[i];
final Tab tab = getTabById(stripTab.getTabId());
if (mTabGroupModelFilter.isTabInTabGroup(tab)
&& !groupRootIds.contains(tab.getRootId())) {
groupRootIds.add(tab.getRootId());
}
}
return groupRootIds.size();
}
private void buildBottomIndicator() {
if (mStripTabs.length == 0 || mTabResizeAnimRunning) {
return;
}
for (int i = 0; i < mStripGroupTitles.length; i++) {
StripLayoutGroupTitle groupTitle = mStripGroupTitles[i];
if (groupTitle == null
|| groupTitle.isCollapsed()
|| groupTitle.getRootId() == mTabGroupIdToHide) {
continue;
}
// Calculate the bottom indicator width.
float bottomIndicatorWidth =
calculateBottomIndicatorWidth(groupTitle, getNumOfTabsInGroup(groupTitle));
// Update the bottom indicator width.
if (groupTitle.getBottomIndicatorWidth() != bottomIndicatorWidth) {
groupTitle.setBottomIndicatorWidth(bottomIndicatorWidth);
}
}
}
/**
* @param groupTitle The tab group title indicator {@link StripLayoutGroupTitle}.
* @param numOfTabsInGroup Number of tabs in the tab group.
* @return The total width of the group title and the number of tabs associated with it.
*/
private float calculateBottomIndicatorWidth(
StripLayoutGroupTitle groupTitle, int numOfTabsInGroup) {
if (groupTitle == null || groupTitle.isCollapsed() || numOfTabsInGroup == 0) {
return 0.f;
}
float tabWidth = mCachedTabWidth - mTabOverlapWidth;
float totalTabWidth = tabWidth * numOfTabsInGroup - TAB_GROUP_BOTTOM_INDICATOR_WIDTH_OFFSET;
float bottomIndicatorWidth = groupTitle.getWidth() + totalTabWidth;
return bottomIndicatorWidth;
}
public int getNumOfTabsInGroup(StripLayoutGroupTitle stripLayoutGroupTitle) {
if (stripLayoutGroupTitle == null) {
return 0;
}
return mTabGroupModelFilter.getRelatedTabCountForRootId(stripLayoutGroupTitle.getRootId());
}
protected boolean isLastTabInGroup(int tabId) {
Tab tab = getTabById(tabId);
if (tab == null) {
return false;
}
return mTabGroupModelFilter.isTabInTabGroup(tab)
&& mTabGroupModelFilter.getRelatedTabCountForRootId(tab.getRootId()) == 1;
}
private void copyTabsWithGroupTitles() {
if (mStripTabs.length == 0) return;
int numGroups = getTabGroupCount();
// If we have tab group to hide due to running tab group delete dialog, then skip the tab
// group when rebuilding StripViews.
if (mTabGroupIdToHide != Tab.INVALID_TAB_ID && numGroups > 0) {
numGroups -= 1;
}
int groupTitleIndex = 0;
StripLayoutGroupTitle[] groupTitles = new StripLayoutGroupTitle[numGroups];
int numViews = mStripTabs.length + numGroups;
if (numViews != mStripViews.length) {
mStripViews = new StripLayoutView[numViews];
}
int viewIndex = 0;
// First view will be tab group title if first tab is grouped.
Tab firstTab = getTabById(mStripTabs[0].getTabId());
if (mTabGroupModelFilter.isTabInTabGroup(firstTab)) {
int rootId = firstTab.getRootId();
StripLayoutGroupTitle groupTitle = findOrCreateGroupTitle(rootId);
if (rootId != mTabGroupIdToHide) {
if (firstTab.getLaunchType() == TabLaunchType.FROM_SYNC_BACKGROUND) {
mLastSyncedGroupId = rootId;
}
groupTitles[groupTitleIndex++] = groupTitle;
mStripViews[viewIndex++] = groupTitle;
}
}
// Copy the StripLayoutTabs and create group titles where needed.
for (int i = 0; i < mStripTabs.length - 1; i++) {
final StripLayoutTab stripTab = mStripTabs[i];
mStripViews[viewIndex++] = stripTab;
Tab currTab = getTabById(stripTab.getTabId());
Tab nextTab = getTabById(mStripTabs[i + 1].getTabId());
int nextRootId = nextTab.getRootId();
boolean nextTabInGroup = mTabGroupModelFilter.isTabInTabGroup(nextTab);
boolean areRelatedTabs = currTab.getRootId() == nextRootId;
if (nextTabInGroup && !areRelatedTabs) {
StripLayoutGroupTitle groupTitle = findOrCreateGroupTitle(nextRootId);
if (nextRootId != mTabGroupIdToHide) {
if (nextTab.getLaunchType() == TabLaunchType.FROM_SYNC_BACKGROUND) {
mLastSyncedGroupId = nextRootId;
}
groupTitles[groupTitleIndex++] = groupTitle;
mStripViews[viewIndex++] = groupTitle;
}
}
}
// Final view will be the last tab.
assert viewIndex == mStripViews.length - 1 : "Did not find all tab groups.";
mStripViews[viewIndex] = mStripTabs[mStripTabs.length - 1];
int oldGroupCount = mStripGroupTitles.length;
mStripGroupTitles = groupTitles;
if (mStripGroupTitles.length != oldGroupCount) {
for (int i = 0; i < mStripGroupTitles.length; ++i) {
final StripLayoutGroupTitle groupTitle = mStripGroupTitles[i];
boolean isCollapsed =
mTabGroupModelFilter.getTabGroupCollapsed(groupTitle.getRootId());
updateTabGroupCollapsed(groupTitle, isCollapsed, false);
}
resizeTabStrip(true, false, false);
}
}
private void copyTabs() {
int numViews = mStripTabs.length;
if (numViews != mStripViews.length) {
mStripViews = new StripLayoutView[numViews];
}
for (int i = 0; i < mStripViews.length; i++) {
mStripViews[i] = mStripTabs[i];
}
}
private List<Animator> resizeTabStrip(boolean animate, boolean delay, boolean deferAnimations) {
List<Animator> animationList = null;
if (delay) {
resetResizeTimeout(true);
} else {
animationList = computeAndUpdateTabWidth(animate, deferAnimations, null);
}
return animationList;
}
private void updateVisualTabOrdering() {
if (mStripTabs.length != mStripTabsVisuallyOrdered.length) {
mStripTabsVisuallyOrdered = new StripLayoutTab[mStripTabs.length];
}
mStripStacker.createVisualOrdering(
getSelectedStripTabIndex(), mStripTabs, mStripTabsVisuallyOrdered);
}
private StripLayoutTab createPlaceholderStripTab() {
StripLayoutTab tab =
new StripLayoutTab(
mContext,
Tab.INVALID_TAB_ID,
this,
mTabLoadTrackerHost,
mUpdateHost,
mIncognito);
tab.setIsPlaceholder(true);
tab.setContainerOpacity(TAB_OPACITY_VISIBLE_FOREGROUND);
// TODO(crbug.com/40942588): Added placeholder a11y descriptions to prevent crash due
// to invalid a11y node. Replace with official strings when available.
String description = "Placeholder Tab";
String title = "Placeholder";
tab.setAccessibilityDescription(description, title, ResourcesCompat.ID_NULL);
pushPropertiesToTab(tab);
return tab;
}
@VisibleForTesting
StripLayoutTab createStripTab(int id) {
// TODO: Cache these
StripLayoutTab tab =
new StripLayoutTab(
mContext, id, this, mTabLoadTrackerHost, mUpdateHost, mIncognito);
if (isSelectedTab(id)) {
tab.setContainerOpacity(TAB_OPACITY_VISIBLE_FOREGROUND);
}
pushPropertiesToTab(tab);
return tab;
}
private void pushPropertiesToPlaceholder(StripLayoutTab placeholderTab, Tab tab) {
placeholderTab.setTabId(tab.getId());
placeholderTab.setIsPlaceholder(false);
placeholderTab.setContainerOpacity(TAB_OPACITY_HIDDEN);
setAccessibilityDescription(placeholderTab, tab);
}
private void pushPropertiesToTab(StripLayoutTab tab) {
// The close button is visible by default. If it should be hidden on tab creation, do not
// animate the fade-out. See (https://crbug.com/1342654).
boolean shouldShowCloseButton = mCachedTabWidth >= TAB_WIDTH_MEDIUM;
tab.setCanShowCloseButton(shouldShowCloseButton, false);
tab.setHeight(mHeight);
}
/**
* @param id The Tab id.
* @return The StripLayoutTab that corresponds to that tabid.
*/
@VisibleForTesting
public @Nullable StripLayoutTab findTabById(int id) {
if (mStripTabs == null) return null;
for (int i = 0; i < mStripTabs.length; i++) {
if (mStripTabs[i].getTabId() == id) return mStripTabs[i];
}
return null;
}
private int findIndexForTab(int id) {
if (mStripTabs == null || id == Tab.INVALID_TAB_ID) return TabModel.INVALID_TAB_INDEX;
for (int i = 0; i < mStripTabs.length; i++) {
final StripLayoutTab stripTab = mStripTabs[i];
if (stripTab.getTabId() == id) return i;
}
return TabModel.INVALID_TAB_INDEX;
}
private int findStripViewIndexForStripTab(int curIndexInStripTab) {
if (curIndexInStripTab == TabModel.INVALID_TAB_INDEX) {
return TabModel.INVALID_TAB_INDEX;
}
assert curIndexInStripTab < mStripTabs.length;
StripLayoutTab curTab = mStripTabs[curIndexInStripTab];
if (mStripViews == null || curTab == null) return TabModel.INVALID_TAB_INDEX;
for (int i = 0; i < mStripViews.length; i++) {
if (mStripViews[i] instanceof StripLayoutTab tab && curTab == tab) return i;
}
return TabModel.INVALID_TAB_INDEX;
}
private int getNumLiveTabs() {
int numLiveTabs = 0;
for (int i = 0; i < mStripTabs.length; i++) {
final StripLayoutTab tab = mStripTabs[i];
if (!tab.isClosed() && !tab.isDraggedOffStrip() && !tab.isCollapsed()) numLiveTabs++;
}
return numLiveTabs;
}
/**
* Computes and updates the tab width when resizing the tab strip.
*
* @param animate Whether to animate the update.
* @param deferAnimations Whether to defer animations.
* @param closedTab The tab that is closing. This value should be non-null, if the resize is
* caused by tab closing.
* @return A list of animators for the tab width update.
*/
private List<Animator> computeAndUpdateTabWidth(
boolean animate, boolean deferAnimations, Tab closedTab) {
// Skip updating the tab width when the tab strip width is unavailable.
if (mWidth == 0) {
return null;
}
// Remove any queued resize messages.
mStripTabEventHandler.removeMessages(MESSAGE_RESIZE);
int numTabs = Math.max(getNumLiveTabs(), 1);
// 1. Compute the width of the available space for all tabs.
float stripWidth = mWidth - mLeftMargin - mRightMargin;
for (int i = 0; i < mStripGroupTitles.length; i++) {
final StripLayoutGroupTitle groupTitle = mStripGroupTitles[i];
stripWidth -= (groupTitle.getWidth() - mGroupTitleOverlapWidth);
}
// 2. Compute additional width we gain from overlapping the tabs.
float overlapWidth = mTabOverlapWidth * (numTabs - 1);
// 3. Calculate the optimal tab width.
float optimalTabWidth = (stripWidth + overlapWidth) / numTabs;
// 4. Calculate the realistic tab width.
mCachedTabWidth = MathUtils.clamp(optimalTabWidth, mMinTabWidth, mMaxTabWidth);
mHalfTabWidth = (mCachedTabWidth - mTabOverlapWidth) * REORDER_OVERLAP_SWITCH_PERCENTAGE;
// 5. Prepare animations and propagate width to all tabs.
finishAnimationsAndPushTabUpdates();
ArrayList<Animator> resizeAnimationList = null;
if (animate && !mAnimationsDisabledForTesting) resizeAnimationList = new ArrayList<>();
for (int i = 0; i < mStripTabs.length; i++) {
StripLayoutTab tab = mStripTabs[i];
if (tab.isClosed()) tab.setWidth(mTabOverlapWidth);
if (tab.isDying() || tab.isCollapsed()) continue;
if (resizeAnimationList != null) {
if (mCachedTabWidth > 0f && tab.getWidth() == mCachedTabWidth) {
// No need to create an animator to animate to the width we're already at.
continue;
}
CompositorAnimator animator =
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
tab,
StripLayoutTab.WIDTH,
tab.getWidth(),
mCachedTabWidth,
ANIM_TAB_RESIZE_MS);
resizeAnimationList.add(animator);
} else {
mStripTabs[i].setWidth(mCachedTabWidth);
}
}
// Return early if there is no animation to run.
if (resizeAnimationList == null) {
buildBottomIndicator();
mUpdateHost.requestUpdate();
return null;
}
// 6. Animate bottom indicator when tab width change.
for (int i = 0; i < mStripGroupTitles.length; i++) {
StripLayoutGroupTitle groupTitle = mStripGroupTitles[i];
if (groupTitle == null) {
continue;
}
if (groupTitle.isCollapsed()) {
continue;
}
float bottomIndicatorStartWidth = groupTitle.getBottomIndicatorWidth();
float bottomIndicatorEndWidth;
// When a grouped tab is closed, the bottom indicator end width needs to subtract the
// width of the closed tab.
if (closedTab != null && closedTab.getRootId() == groupTitle.getRootId()) {
bottomIndicatorEndWidth =
calculateBottomIndicatorWidth(
groupTitle, getNumOfTabsInGroup(groupTitle) - 1);
} else {
bottomIndicatorEndWidth =
calculateBottomIndicatorWidth(groupTitle, getNumOfTabsInGroup(groupTitle));
}
if (bottomIndicatorEndWidth > 0f
&& bottomIndicatorStartWidth == bottomIndicatorEndWidth) {
// No need to create an animator to animate to the width we're already at.
continue;
}
resizeAnimationList.add(
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
groupTitle,
StripLayoutGroupTitle.BOTTOM_INDICATOR_WIDTH,
bottomIndicatorStartWidth,
bottomIndicatorEndWidth,
ANIM_TAB_RESIZE_MS));
}
if (deferAnimations) return resizeAnimationList;
startAnimationList(resizeAnimationList, getTabResizeAnimatorListener());
return null;
}
private AnimatorListener getTabResizeAnimatorListener() {
return new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
mTabResizeAnimRunning = true;
}
@Override
public void onAnimationEnd(Animator animation) {
mTabResizeAnimRunning = false;
}
};
}
private void updateStrip() {
// TODO(dtrainor): Remove this once tabCreated() is refactored to be called even from
// restore.
if (mTabStateInitialized
&& (mStripTabs == null || mModel.getCount() != mStripTabs.length)) {
computeAndUpdateTabOrders(false, false);
}
// 1. Update the scroll offset limits
updateScrollOffsetLimits();
// 2. Calculate the ideal tab positions
computeTabInitialPositions();
// 3. Calculate the tab stacking and ensure that tabs are sized correctly.
mStripStacker.setViewOffsets(
mStripViews, mMultiStepTabCloseAnimRunning, mGroupTitleSliding, mCachedTabWidth);
// 4. Calculate which tabs are visible.
float stripWidth = getVisibleRightBound() - getVisibleLeftBound();
mStripStacker.performOcclusionPass(mStripViews, getVisibleLeftBound(), stripWidth);
// 5. Create render list.
createRenderList();
// 6. Figure out where to put the new tab button. If a tab is being closed, the new tab
// button position will be updated with the tab resize and drawX animations.
if (!mNewTabButtonAnimRunning) updateNewTabButtonState();
// 7. Invalidate the accessibility provider in case the visible virtual views have changed.
mRenderHost.invalidateAccessibilityProvider();
// 8. Hide close buttons if tab width gets lower than 156dp.
updateCloseButtons();
// 9. Show dividers between inactive tabs.
updateTabContainersAndDividers();
// 10. Update the touchable rect.
updateTouchableRect();
}
private float getTabPositionStart() {
// Shift all of the tabs over by the the left margin because we're
// no longer base lined at 0
if (!LocalizationUtils.isLayoutRtl()) {
return mScrollDelegate.getScrollOffset() + mLeftMargin + mStripStartMarginForReorder;
} else {
return mWidth
- mCachedTabWidth
- mScrollDelegate.getScrollOffset()
- mRightMargin
- mStripStartMarginForReorder;
}
}
private void computeTabInitialPositions() {
float tabPosition = getTabPositionStart();
for (int i = 0; i < mStripViews.length; i++) {
final StripLayoutView view = mStripViews[i];
float delta;
if (view instanceof StripLayoutTab tab) {
if (tab.isClosed()) continue;
// idealX represents where a tab should be placed in the tab strip.
view.setIdealX(tabPosition);
delta =
tab.isDying()
? mCachedTabWidth - mTabOverlapWidth
: (tab.getWidth() - mTabOverlapWidth) * tab.getWidthWeight();
if (mInReorderMode || mTabGroupMarginAnimRunning) {
delta += tab.getTrailingMargin();
}
} else {
// Offset to "undo" the tab overlap width as that doesn't apply to non-tab views.
// Also applies the desired overlap with the previous tab.
float drawXOffset = mGroupTitleDrawXOffset;
// Adjust for RTL.
if (LocalizationUtils.isLayoutRtl()) {
drawXOffset = mCachedTabWidth - view.getWidth() - drawXOffset;
}
if (!mGroupTitleSliding) {
view.setIdealX(tabPosition + drawXOffset);
}
delta = view.getWidth() - mGroupTitleOverlapWidth;
}
delta = MathUtils.flipSignIf(delta, LocalizationUtils.isLayoutRtl());
tabPosition += delta;
}
}
private int getVisibleViewCount(StripLayoutView[] views) {
int renderCount = 0;
for (int i = 0; i < views.length; ++i) {
if (views[i].isVisible()) renderCount++;
}
return renderCount;
}
private void populateVisibleViews(StripLayoutView[] allViews, StripLayoutView[] viewsToRender) {
int renderIndex = 0;
for (int i = 0; i < allViews.length; ++i) {
final StripLayoutView view = allViews[i];
if (view.isVisible()) viewsToRender[renderIndex++] = view;
}
}
private void createRenderList() {
// 1. Figure out how many tabs will need to be rendered.
int tabRenderCount = getVisibleViewCount(mStripTabsVisuallyOrdered);
int groupTitleRenderCount = getVisibleViewCount(mStripGroupTitles);
// 2. Reallocate the render list if necessary.
if (mStripTabsToRender.length != tabRenderCount) {
mStripTabsToRender = new StripLayoutTab[tabRenderCount];
}
if (mStripGroupTitlesToRender.length != groupTitleRenderCount) {
mStripGroupTitlesToRender = new StripLayoutGroupTitle[groupTitleRenderCount];
}
// 3. Populate it with the visible tabs.
populateVisibleViews(mStripTabsVisuallyOrdered, mStripTabsToRender);
populateVisibleViews(mStripGroupTitles, mStripGroupTitlesToRender);
}
private float adjustNewTabButtonOffsetIfFull(float offset) {
if (!isTabStripFull()) {
// Move NTB close to tabs by 4 dp when tab strip is not full.
boolean isLtr = !LocalizationUtils.isLayoutRtl();
offset += MathUtils.flipSignIf(NEW_TAB_BUTTON_X_OFFSET_TOWARDS_TABS, isLtr);
}
return offset;
}
private CompositorAnimator getLastTabClosedNtbAnimator() {
// TODO(crbug.com/338332428): Unify with the stacker methods.
float viewsWidth =
getNumLiveTabs() * (mCachedTabWidth - mTabOverlapWidth) + mTabOverlapWidth;
for (int i = 0; i < mStripViews.length; ++i) {
final StripLayoutView view = mStripViews[i];
if (!(view instanceof StripLayoutTab)) viewsWidth += view.getWidth();
}
boolean rtl = LocalizationUtils.isLayoutRtl();
float offset = getTabPositionStart() + MathUtils.flipSignIf(viewsWidth, rtl);
if (rtl) offset += mCachedTabWidth - mNewTabButtonWidth;
offset = adjustNewTabButtonOffsetIfFull(offset);
CompositorAnimator animator =
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
mNewTabButton,
StripLayoutView.DRAW_X,
mNewTabButton.getDrawX(),
offset,
ANIM_TAB_RESIZE_MS);
return animator;
}
private void updateNewTabButtonState() {
// 1. The NTB is faded out upon entering reorder mode and hidden when the model is empty.
boolean isEmpty = mStripTabs.length == 0;
mNewTabButton.setVisible(!isEmpty);
if (isEmpty) return;
// 2. Get offset from strip stacker.
// Note: This method anchors the NTB to either a static position at the end of the strip OR
// right next to the final tab in the strip. This only WAI if the final view in the strip is
// guaranteed to be a tab. If this changes (e.g. we allow empty tab groups), then this will
// need to be updated.
float offset =
mStripStacker.computeNewTabButtonOffset(
mStripTabs,
mTabOverlapWidth,
mLeftMargin,
mRightMargin,
mWidth,
mNewTabButtonWidth);
offset = adjustNewTabButtonOffsetIfFull(offset);
// 3. Hide the new tab button if it's not visible on the screen.
boolean isRtl = LocalizationUtils.isLayoutRtl();
if ((isRtl && offset + mNewTabButtonWidth < getVisibleLeftBound())
|| (!isRtl && offset > getVisibleRightBound())) {
mNewTabButton.setVisible(false);
return;
}
mNewTabButton.setVisible(true);
// 4. Position the new tab button.
mNewTabButton.setDrawX(offset);
}
/**
* @param tab The tab to make fully visible.
* @return Scroll delta to make the tab fully visible.
*/
private float calculateDeltaToMakeTabVisible(StripLayoutTab tab) {
if (tab == null) return 0.f;
// 1. Calculate offsets to fully show the tab at the start and end of the strip.
final boolean isRtl = LocalizationUtils.isLayoutRtl();
final float tabWidth = mCachedTabWidth - mTabOverlapWidth;
// TODO(wenyufu): Account for offsetX{Left,Right} result too much offset. Is this expected?
final float startOffset = (isRtl ? mRightFadeWidth : mLeftFadeWidth);
final float endOffset = (isRtl ? mLeftFadeWidth : mRightFadeWidth);
final float scrollOffset = mScrollDelegate.getScrollOffset();
final float tabPosition = tab.getIdealX() - scrollOffset + mLeftMargin;
final float optimalStart = startOffset - tabPosition;
final float optimalEnd = mWidth - endOffset - tabWidth - tabPosition - mTabOverlapWidth;
// 2. Return the scroll delta to make the given tab fully visible with the least scrolling.
// This will result in the tab being at either the start or end of the strip.
final float deltaToOptimalStart = optimalStart - scrollOffset;
final float deltaToOptimalEnd = optimalEnd - scrollOffset;
// 3. If the delta to the optimal start is negative and the delta to the optimal end is
// positive, the given index is already completely in the visible area of the strip.
if ((deltaToOptimalStart < 0) && (deltaToOptimalEnd > 0)) return 0.f;
return Math.abs(deltaToOptimalStart) < Math.abs(deltaToOptimalEnd)
? deltaToOptimalStart
: deltaToOptimalEnd;
}
/**
* @param index The index of the tab to make fully visible.
* @return Scroll delta to make the tab at the given index fully visible.
*/
private float calculateDeltaToMakeIndexVisible(int index) {
if (index == TabModel.INVALID_TAB_INDEX) return 0.f;
return calculateDeltaToMakeTabVisible(mStripTabs[index]);
}
void setTabAtPositionForTesting(StripLayoutTab tab) {
mTabAtPositionForTesting = tab;
}
int getTabIndexForTabDrop(float x) {
float halfTabWidth = mCachedTabWidth / 2;
for (int i = 0; i < mStripTabs.length; i++) {
final StripLayoutTab stripTab = mStripTabs[i];
if (LocalizationUtils.isLayoutRtl()) {
if (x > stripTab.getTouchTargetRight() - halfTabWidth) return i;
} else {
if (x < stripTab.getTouchTargetLeft() + halfTabWidth) return i;
}
}
return mStripTabs.length;
}
int getTabDropId() {
if (!mReorderingForTabDrop || mInteractingTab == null) return Tab.INVALID_TAB_ID;
Tab tab = getTabById(mInteractingTab.getTabId());
return mTabGroupModelFilter.isTabInTabGroup(tab) ? tab.getId() : Tab.INVALID_TAB_ID;
}
void mergeToGroupForTabDropIfNeeded(int destTabId, int draggedTabId, int index) {
if (destTabId == Tab.INVALID_TAB_ID) return;
Tab destTab = getTabById(destTabId);
StripLayoutGroupTitle groupTitle = findGroupTitle(destTab.getRootId());
// Animate bottom indicator when merging a new tab into group.
if (groupTitle != null) {
List<Animator> animators = new ArrayList<>();
animators.add(
getBottomIndicatorAnimatorForMergeOrMoveOutOfGroup(groupTitle, false, false));
startAnimationList(animators, null);
}
mTabGroupModelFilter.mergeTabsToGroup(draggedTabId, destTabId, true);
mModel.moveTab(draggedTabId, index);
}
StripLayoutTab getTabAtPosition(float x) {
return (StripLayoutTab) getViewAtPositionX(x, false);
}
StripLayoutView getViewAtPositionX(float x, boolean includeGroupTitles) {
if (mTabAtPositionForTesting != null) {
return mTabAtPositionForTesting;
}
for (int i = 0; i < mStripViews.length; ++i) {
final StripLayoutView view = mStripViews[i];
float leftEdge;
float rightEdge;
if (view instanceof StripLayoutTab tab) {
leftEdge = tab.getTouchTargetLeft();
rightEdge = tab.getTouchTargetRight();
if (mInReorderMode) {
if (LocalizationUtils.isLayoutRtl()) {
leftEdge -= tab.getTrailingMargin();
} else {
rightEdge += tab.getTrailingMargin();
}
}
} else {
if (!includeGroupTitles) continue;
leftEdge = view.getDrawX();
rightEdge = leftEdge + view.getWidth();
}
if (view.isVisible() && leftEdge <= x && x <= rightEdge) {
return view;
}
}
return null;
}
/**
* @param tab The StripLayoutTab to look for.
* @return The index of the tab in the visual ordering.
*/
public int visualIndexOfTabForTesting(StripLayoutTab tab) {
for (int i = 0; i < mStripTabsVisuallyOrdered.length; i++) {
if (mStripTabsVisuallyOrdered[i] == tab) {
return i;
}
}
return -1;
}
/**
* @param tab The StripLayoutTab you're looking at.
* @return Whether or not this tab is the foreground tab.
*/
public boolean isForegroundTabForTesting(StripLayoutTab tab) {
return tab == mStripTabsVisuallyOrdered[mStripTabsVisuallyOrdered.length - 1];
}
@VisibleForTesting
void updateTabAttachState(
StripLayoutTab tab, boolean attached, @Nullable ArrayList<Animator> animationList) {
float startValue =
attached ? FOLIO_DETACHED_BOTTOM_MARGIN_DP : FOLIO_ATTACHED_BOTTOM_MARGIN_DP;
float intermediateValue = FOLIO_ANIM_INTERMEDIATE_MARGIN_DP;
float endValue =
attached ? FOLIO_ATTACHED_BOTTOM_MARGIN_DP : FOLIO_DETACHED_BOTTOM_MARGIN_DP;
if (animationList == null) {
tab.setBottomMargin(endValue);
tab.setFolioAttached(attached);
return;
}
ArrayList<Animator> attachAnimationList = new ArrayList<>();
CompositorAnimator dropAnimation =
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
tab,
StripLayoutTab.BOTTOM_MARGIN,
startValue,
intermediateValue,
ANIM_FOLIO_DETACH_MS,
Interpolators.EMPHASIZED_ACCELERATE);
CompositorAnimator riseAnimation =
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
tab,
StripLayoutTab.BOTTOM_MARGIN,
intermediateValue,
endValue,
ANIM_FOLIO_DETACH_MS,
Interpolators.EMPHASIZED_DECELERATE);
dropAnimation.addListener(
new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
tab.setFolioAttached(attached);
}
});
attachAnimationList.add(dropAnimation);
attachAnimationList.add(riseAnimation);
AnimatorSet set = new AnimatorSet();
set.playSequentially(attachAnimationList);
animationList.add(set);
}
public boolean getInReorderModeForTesting() {
return mInReorderMode;
}
public float getStripStartMarginForReorderForTesting() {
return mStripStartMarginForReorder;
}
public void startReorderModeAtIndexForTesting(int index) {
StripLayoutTab tab = mStripTabs[index];
updateStrip();
startReorderTab(INVALID_TIME, 0f, tab.getDrawX() + (tab.getWidth() / 2));
}
public void stopReorderModeForTesting() {
stopReorderMode();
}
@VisibleForTesting
void startReorderTab(long time, float currentX, float startX) {
if (mInReorderMode) return;
RecordUserAction.record("MobileToolbarStartReorderTab");
// 1. Only start reorder mode if we have a valid (non-null, non-dying, non-placeholder) tab
// and if the tab state is initialized.
mInteractingTab = mActiveClickedTab == null ? getTabAtPosition(startX) : mActiveClickedTab;
if (mInteractingTab == null
|| mInteractingTab.isDying()
|| mInteractingTab.getTabId() == Tab.INVALID_TAB_ID
|| !mTabStateInitialized) {
return;
}
mInteractingTab.setIsReordering(true);
// 2. Set mInReorderMode to true before selecting this tab to prevent unnecessary triggering
// of #bringSelectedTabToVisibleArea for edge tabs when the tab strip is full.
mInReorderMode = true;
// 3. Select this tab so that it is always in the foreground.
TabModelUtils.setIndex(
mModel, TabModelUtils.getTabIndexById(mModel, mInteractingTab.getTabId()));
// 4. Set initial state.
ArrayList<Animator> animationList = updateStripForReorder(startX);
// 5. Lift the container off the toolbar and perform haptic feedback.
Tab tab = getTabById(mInteractingTab.getTabId());
updateTabAttachState(mInteractingTab, false, animationList);
performHapticFeedback(tab);
// 6. Kick-off animations and request an update.
if (animationList != null) {
startAnimationList(animationList, getTabGroupMarginAnimatorListener());
}
mUpdateHost.requestUpdate();
}
void updateStripForExternalTabDrop(float startX) {
// 1. StartX indicates the position where the tab drag entered the destination tab strip.
// Adjust by a half tab-width so that we target the nearest tab gap.
startX = adjustXForTabDrop(startX);
// 2. Mark the "interacting" tab. This is not the DnD dragged tab, but rather the tab in the
// strip that is currently being hovered by the DnD drag.
StripLayoutTab hoveredTab = getTabAtPosition(startX);
if (hoveredTab == null) hoveredTab = mStripTabs[mStripTabs.length - 1];
mInteractingTab = hoveredTab;
// 3. Set initial state.
mInReorderMode = true;
mReorderingForTabDrop = true;
ArrayList<Animator> animationList = updateStripForReorder(startX);
// 4. Add a tab group margin to the "interacting" tab to indicate where the tab will be
// inserted should the drag be dropped.
setTrailingMarginForTab(mInteractingTab, mTabMarginWidth, animationList);
// 5. Kick-off animations and request an update.
if (animationList != null) {
startAnimationList(animationList, getTabGroupMarginAnimatorListener());
}
mUpdateHost.requestUpdate();
}
private ArrayList<Animator> updateStripForReorder(float startX) {
// 1. Set initial state parameters.
finishAnimationsAndPushTabUpdates();
ArrayList<Animator> animationList =
mAnimationsDisabledForTesting ? null : new ArrayList<>();
mLastReorderScrollTime = INVALID_TIME;
mHoverStartTime = INVALID_TIME;
mHoverStartOffset = 0;
mReorderState = REORDER_SCROLL_NONE;
mLastReorderX = startX;
mTabMarginWidth = mCachedTabWidth / 2;
mHoveringOverGroup = false;
// 2. Fade-out model selector and new tab buttons.
setCompositorButtonsVisible(false);
// 3. The selected tab will already be visible, so update tab group and background
// container. For Tab Group Indicators, skip background container highlight and autoscroll
// to match desktop behavior.
if (!ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
Tab tab = getTabById(mInteractingTab.getTabId());
if (mTabGroupModelFilter.isTabInTabGroup(tab)) {
setTabGroupBackgroundContainersVisible(tab.getRootId(), true);
}
computeAndUpdateTabGroupMargins(true, animationList);
} else {
computeAndUpdateStartAndEndMargins(true, animationList);
}
return animationList;
}
private void stopReorderMode() {
if (!mInReorderMode) return;
ArrayList<Animator> animationList = null;
if (!mAnimationsDisabledForTesting) animationList = new ArrayList<>();
// 1. Reset the state variables.
mReorderState = REORDER_SCROLL_NONE;
mInReorderMode = false;
// 2. Clear any drag offset.
finishAnimationsAndPushTabUpdates();
if (mInteractingTab != null) {
if (animationList != null) {
animationList.add(
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
mInteractingTab,
StripLayoutView.X_OFFSET,
mInteractingTab.getOffsetX(),
0f,
ANIM_TAB_MOVE_MS));
} else {
mInteractingTab.setOffsetX(0f);
}
}
// 3. Reset the background tabs and fade-in the new tab & model selector buttons.
setBackgroundTabContainersVisible(false);
setCompositorButtonsVisible(true);
// 4. Clear any tab group margins.
resetTabGroupMargins(animationList);
// 5. Reattach the folio container to the toolbar.
if (mInteractingTab != null) {
mInteractingTab.setIsReordering(false);
// Skip reattachment for tab drop to avoid exposing bottom indicator underneath the tab
// container.
if (!mReorderingForTabDrop || !mInteractingTab.getFolioAttached()) {
updateTabAttachState(mInteractingTab, true, animationList);
}
}
// 6. Reset the tab drop state. Must occur after the rest of the state is reset, since some
// logic depends on these values.
mReorderingForTabDrop = false;
mLastTrailingMargin = 0;
// 7. Request an update.
startAnimationList(animationList, getTabGroupMarginAnimatorListener());
mUpdateHost.requestUpdate();
}
/**
* Sets the trailing margin for the current tab. Update bottom indicator width for Tab Group
* Indicators and animates if necessary.
*
* @param tab The tab to update.
* @param trailingMargin The given tab's new trailing margin.
* @param animationList The list to add the animation to, or {@code null} if not animating.
* @return Whether or not the trailing margin for the given tab actually changed.
*/
private boolean setTrailingMarginForTab(
StripLayoutTab tab, float trailingMargin, @Nullable List<Animator> animationList) {
if (tab.getTrailingMargin() != trailingMargin) {
StripLayoutGroupTitle groupTitle = findGroupTitle(getStripTabRootId(tab));
if (animationList != null) {
animationList.add(
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
tab,
StripLayoutTab.TRAILING_MARGIN,
tab.getTrailingMargin(),
trailingMargin,
ANIM_TAB_SLIDE_OUT_MS));
if (groupTitle != null) {
float defaultWidth =
calculateBottomIndicatorWidth(
groupTitle, getNumOfTabsInGroup(groupTitle));
float startWidth = groupTitle.getBottomIndicatorWidth();
float endWidth =
trailingMargin == 0 ? defaultWidth : defaultWidth + trailingMargin;
animationList.add(
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
groupTitle,
StripLayoutGroupTitle.BOTTOM_INDICATOR_WIDTH,
startWidth,
endWidth,
ANIM_TAB_SLIDE_OUT_MS));
}
} else {
tab.setTrailingMargin(trailingMargin);
if (groupTitle != null) {
float defaultWidth =
calculateBottomIndicatorWidth(
groupTitle, getNumOfTabsInGroup(groupTitle));
float endWidth =
trailingMargin == 0 ? defaultWidth : defaultWidth + trailingMargin;
groupTitle.setBottomIndicatorWidth(endWidth);
}
}
return true;
}
return false;
}
/** See {@link ScrollDelegate#autoScrollForTabGroupMargins} */
private void autoScrollForTabGroupMargins(
int numMarginsToSlide, float startMarginDelta, List<Animator> animationList) {
autoScrollForTabGroupMargins(
numMarginsToSlide, startMarginDelta, /* resetOffset= */ false, animationList);
}
/** See {@link ScrollDelegate#autoScrollForTabGroupMargins} */
private void autoScrollForTabGroupMargins(
int numMarginsToSlide,
float startMarginDelta,
boolean resetOffset,
List<Animator> animationList) {
boolean isVisibleAreaFilled = mCachedTabWidth != mMaxTabWidth;
mScrollDelegate.autoScrollForTabGroupMargins(
mUpdateHost.getAnimationHandler(),
resetOffset,
numMarginsToSlide,
mTabMarginWidth,
startMarginDelta,
mStripStartMarginForReorder,
isVisibleAreaFilled,
animationList);
}
private AnimatorListener getTabGroupMarginAnimatorListener() {
return new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
mTabGroupMarginAnimRunning = true;
}
@Override
public void onAnimationEnd(Animator animation) {
mTabGroupMarginAnimRunning = false;
}
};
}
private void computeAndUpdateTabGroupMargins(
boolean autoScroll, ArrayList<Animator> animationList) {
// 1. Update the trailing margins for each tab.
boolean pastInteractingTab = false;
int numMarginsToSlide = 0;
for (int i = 0; i < mStripTabs.length - 1; i++) {
final StripLayoutTab stripTab = mStripTabs[i];
if (stripTab == mInteractingTab) pastInteractingTab = true;
// 1.a. Calculate the current tab's trailing margin.
float trailingMargin = 0f;
Tab currTab = getTabById(stripTab.getTabId());
Tab nextTab = getTabById(mStripTabs[i + 1].getTabId());
if (notRelatedAndEitherTabInGroup(currTab, nextTab)) {
trailingMargin = mTabMarginWidth;
}
// 1.b. Attempt to update the current tab's trailing margin.
float oldMargin = stripTab.getTrailingMargin();
boolean didChangeTrailingMargin =
setTrailingMarginForTab(stripTab, trailingMargin, animationList);
if (didChangeTrailingMargin && !pastInteractingTab) {
numMarginsToSlide += oldMargin < trailingMargin ? 1 : -1;
}
if (stripTab == mInteractingTab) mLastTrailingMargin = trailingMargin;
}
// 2. Set the starting and trailing margin for the tab strip.
boolean firstTabIsInGroup =
mTabGroupModelFilter.isTabInTabGroup(getTabById(mStripTabs[0].getTabId()));
boolean lastTabIsInGroup =
mTabGroupModelFilter.isTabInTabGroup(
getTabById(mStripTabs[mStripTabs.length - 1].getTabId()));
float startMargin =
firstTabIsInGroup && !ChromeFeatureList.sTabStripGroupIndicators.isEnabled()
? mTabMarginWidth
: 0f;
float startMarginDelta = startMargin - mStripStartMarginForReorder;
mStripStartMarginForReorder = startMargin;
mStripTabs[mStripTabs.length - 1].setTrailingMargin(
(lastTabIsInGroup || mReorderingForTabDrop) ? mTabMarginWidth : 0f);
// 3. Adjust the scroll offset accordingly to prevent the interacting tab from shifting away
// from where the user long-pressed.
if (autoScroll) {
autoScrollForTabGroupMargins(numMarginsToSlide, startMarginDelta, animationList);
}
// 4. Begin slide-out and scroll animation. Update tab positions.
if (animationList == null) computeTabInitialPositions();
}
private void computeAndUpdateStartAndEndMargins(
boolean autoScroll, List<Animator> animationList) {
// 1. Set the starting and trailing margin for the tab strip.
boolean firstTabIsInGroup =
mTabGroupModelFilter.isTabInTabGroup(getTabById(mStripTabs[0].getTabId()));
boolean lastTabIsInGroup =
mTabGroupModelFilter.isTabInTabGroup(
getTabById(mStripTabs[mStripTabs.length - 1].getTabId()));
float startMargin =
firstTabIsInGroup ? mHalfTabWidth * REORDER_OVERLAP_SWITCH_PERCENTAGE : 0f;
float startMarginDelta = startMargin - mStripStartMarginForReorder;
mStripStartMarginForReorder = startMargin;
mStripTabs[mStripTabs.length - 1].setTrailingMargin(
(lastTabIsInGroup || mReorderingForTabDrop)
? calculateTabGroupThreshold(mStripTabs.length - 1, true, true)
: 0f);
// 2. Adjust the scroll offset accordingly to prevent the interacting tab from shifting away
// from where the user long-pressed.
if (autoScroll) {
autoScrollForTabGroupMargins(0, startMarginDelta, animationList);
}
}
private void resetTabGroupMargins(@Nullable ArrayList<Animator> animationList) {
assert !mInReorderMode;
// 1. Update the trailing margins for each tab.
boolean pastInteractingTab = false;
int numMarginsToSlide = 0;
for (int i = 0; i < mStripTabs.length; i++) {
final StripLayoutTab stripTab = mStripTabs[i];
if (stripTab == mInteractingTab) pastInteractingTab = true;
boolean didChangeTrailingMargin = setTrailingMarginForTab(stripTab, 0f, animationList);
if (didChangeTrailingMargin && !pastInteractingTab) numMarginsToSlide--;
}
// 2. Adjust the scroll offset accordingly to prevent the interacting tab from shifting away
// from where the user long-pressed.
autoScrollForTabGroupMargins(
numMarginsToSlide,
-mStripStartMarginForReorder,
/* resetOffset= */ true,
animationList);
mStripStartMarginForReorder = 0f;
}
private void setCompositorButtonsVisible(boolean visible) {
float endOpacity = visible ? 1.f : 0.f;
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
mNewTabButton,
CompositorButton.OPACITY,
mNewTabButton.getOpacity(),
endOpacity,
ANIM_BUTTONS_FADE_MS)
.start();
if (mModelSelectorButton != null) {
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
mModelSelectorButton,
CompositorButton.OPACITY,
mModelSelectorButton.getOpacity(),
endOpacity,
ANIM_BUTTONS_FADE_MS)
.start();
}
}
private void setBackgroundTabContainerVisible(StripLayoutTab tab, boolean visible) {
if (mReorderingForTabDrop || tab != mInteractingTab) {
float opacity = visible ? TAB_OPACITY_VISIBLE_BACKGROUND : TAB_OPACITY_HIDDEN;
tab.setContainerOpacity(opacity);
updateTabAttachState(tab, !visible, null);
}
}
private void setBackgroundTabContainersVisible(boolean visible) {
for (int i = 0; i < mStripTabs.length; i++) {
final StripLayoutTab tab = mStripTabs[i];
setBackgroundTabContainerVisible(tab, visible);
}
}
private void setTabGroupBackgroundContainersVisible(int groupId, boolean visible) {
for (int i = 0; i < mStripTabs.length; i++) {
final StripLayoutTab tab = mStripTabs[i];
if (getTabById(tab.getTabId()).getRootId() == groupId) {
setBackgroundTabContainerVisible(tab, visible);
}
}
}
/**
* This method checks whether or not interacting tab has met the conditions to be moved out of
* its tab group. It moves tab out of group if so and returns the new index for the interacting
* tab.
*
* @param offset The distance the interacting tab has been dragged from its ideal x-position.
* @param curIndex The index of the interacting tab.
* @param towardEnd True if the interacting tab is being dragged toward the end of the strip.
* @return The new index for the interacting tab if it has been removed from its tab group and
* the INVALID_TAB_INDEX otherwise.
*/
private int maybeMoveOutOfGroup(float offset, int curIndex, boolean towardEnd) {
// If past threshold, un-dim hovered group and trigger reorder.
if (Math.abs(offset) > mTabMarginWidth * REORDER_OVERLAP_SWITCH_PERCENTAGE) {
final int tabId = mInteractingTab.getTabId();
setTabGroupBackgroundContainersVisible(getTabById(tabId).getRootId(), false);
mTabGroupModelFilter.moveTabOutOfGroupInDirection(tabId, towardEnd);
RecordUserAction.record("MobileToolbarReorderTab.TabRemovedFromGroup");
return curIndex;
}
return TabModel.INVALID_TAB_INDEX;
}
/**
* This method checks whether or not interacting tab has met the conditions to be moved out of
* its tab group for Tab Group Indicators. It moves tab out of group if so and returns the new
* index for the interacting tab.
*
* @param offset The distance the interacting tab has been dragged from its ideal x-position.
* @param curIndex The index of the interacting tab.
* @param towardEnd True if the interacting tab is being dragged toward the end of the strip.
* @param threshold the drag distance threshold to determine whether a tab is moving out of the
* tab group.
* @param interactingGroupTitle The title of the tab group the tab is dragging past, which
* occurs when a tab is being dragged to merge into or move out of the tab group through
* group title.
* @return The new index for the interacting tab if it has been removed from its tab group and
* the INVALID_TAB_INDEX otherwise.
*/
private int maybeMoveOutOfGroupForTabGroupIndicators(
float offset,
int curIndex,
boolean towardEnd,
float threshold,
StripLayoutGroupTitle interactingGroupTitle) {
// If past threshold, trigger reorder.
if (Math.abs(offset) > threshold) {
final int tabId = mInteractingTab.getTabId();
int rootId = getTabById(tabId).getRootId();
// Get the target group title.
Tab destinationTab = getTabById(mStripTabs[curIndex].getTabId());
StripLayoutGroupTitle targetGroupTitle = findGroupTitle(destinationTab.getRootId());
// Run indicator animations.
if (targetGroupTitle != null) {
runIndicatorAnimationForMergeOrMoveOutOfGroup(
targetGroupTitle, interactingGroupTitle, curIndex, true, towardEnd);
}
if ((isLastTabInGroup(tabId))
&& mTabGroupIdToHide == Tab.INVALID_TAB_ID
&& !mIncognito) {
// When dragging the last tab out of group on strip, the tab group delete dialog
// will show and we will hide the indicators for the interacting tab group until the
// user confirms the next action. e.g delete tab group when user confirms the
// delete, or restore indicators back on strip when user cancel the delete.
showDeleteGroupDialogAndProcessTabAction(
rootId,
/* draggingLastTabOffStrip= */ false,
/* closeTab= */ false,
() -> {
mTabGroupModelFilter.moveTabOutOfGroupInDirection(tabId, towardEnd);
RecordUserAction.record("MobileToolbarReorderTab.TabRemovedFromGroup");
});
} else if (getNumOfTabsInGroup(targetGroupTitle) > 1) {
mTabGroupModelFilter.moveTabOutOfGroupInDirection(tabId, towardEnd);
RecordUserAction.record("MobileToolbarReorderTab.TabRemovedFromGroup");
}
return curIndex;
}
return TabModel.INVALID_TAB_INDEX;
}
@VisibleForTesting
boolean isTabRemoveDialogSkipped() {
if (mPrefService == null) {
mPrefService = UserPrefs.get(mModel.getProfile());
}
return mPrefService.getBoolean(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_REMOVE);
}
void setPrefServiceForTesting(PrefService prefService) {
mPrefService = prefService;
}
/**
* This method checks whether or not interacting tab has met the conditions to be merged into a
* neighbouring tab group. It merges tab to group if so and returns the new index for the
* interacting tab.
*
* @param offset The distance the interacting tab has been dragged from its ideal x-position.
* @param curIndex The index of the interacting tab.
* @param towardEnd True if the interacting tab is being dragged toward the end of the strip.
* @return The new index for the interacting tab if it has been moved into a neighboring tab
* group and the INVALID_TAB_INDEX otherwise.
*/
private int maybeMergeToGroup(float offset, int curIndex, boolean towardEnd) {
// 1. Only attempt to merge if hovering a group for a valid amount of time.
if (!mHoveringOverGroup) return TabModel.INVALID_TAB_INDEX;
// 2. Set initial hover variables if we have not yet started or if we have moved too far
// from the initial hover. Since we have just started a new hover, do not trigger a
// reorder.
if (mHoverStartTime == INVALID_TIME
|| Math.abs(mHoverStartOffset - offset) > DROP_INTO_GROUP_MAX_OFFSET) {
mHoverStartTime = mLastUpdateTime;
mHoverStartOffset = offset;
return TabModel.INVALID_TAB_INDEX;
}
// 3. If we have not yet hovered for the required amount of time, keep waiting and do not
// trigger a reorder.
if (mLastUpdateTime - mHoverStartTime < DROP_INTO_GROUP_MS) {
mUpdateHost.requestUpdate();
return TabModel.INVALID_TAB_INDEX;
}
// 4. We have hovered for the required time, so trigger a reorder.
int direction = towardEnd ? 1 : -1;
StripLayoutTab destTab = mStripTabs[curIndex + direction];
float effectiveWidth = mCachedTabWidth - mTabOverlapWidth;
float flipThreshold = effectiveWidth * REORDER_OVERLAP_SWITCH_PERCENTAGE;
float minFlipOffset = mTabMarginWidth + flipThreshold;
int numTabsToSkip =
1 + (int) Math.floor((Math.abs(offset) - minFlipOffset) / effectiveWidth);
mTabGroupModelFilter.mergeTabsToGroup(mInteractingTab.getTabId(), destTab.getTabId(), true);
RecordUserAction.record("MobileToolbarReorderTab.TabAddedToGroup");
return towardEnd ? curIndex + 1 + numTabsToSkip : curIndex - numTabsToSkip;
}
/**
* This method merges the interacting tab into a neighboring tab group for Tab Group Indicators
* and returns the new index for interacting tab.
*
* @param offset The distance the interacting tab has been dragged from its ideal x-position.
* @param curIndex The index of the interacting tab.
* @param towardEnd True if the interacting tab is being dragged toward the end of the strip.
* @param threshold the drag distance threshold to determine whether a tab is merging into the
* tab group.
* @param interactingGroupTitle The title of the tab group the tab is dragging past, which
* occurs when a tab is being dragged to merge into or move out of the tab group through
* group title.
* @return The new index for the interacting tab if it has been moved into a neighboring tab
* group and the INVALID_TAB_INDEX otherwise.
*/
@VisibleForTesting
protected int maybeMergeToGroupForTabGroupIndicators(
float offset,
int curIndex,
boolean towardEnd,
float threshold,
StripLayoutGroupTitle interactingGroupTitle) {
if (Math.abs(offset) < threshold) {
return TabModel.INVALID_TAB_INDEX;
}
// Trigger a reorder
int direction = towardEnd ? 1 : -1;
StripLayoutTab destTab = mStripTabs[curIndex + direction];
// Get the target group title.
Tab destinationTab = getTabById(destTab.getTabId());
StripLayoutGroupTitle targetGroupTitle = findGroupTitle(destinationTab.getRootId());
// Run indicator animations.
if (targetGroupTitle != null) {
runIndicatorAnimationForMergeOrMoveOutOfGroup(
targetGroupTitle, interactingGroupTitle, curIndex, false, towardEnd);
}
mTabGroupModelFilter.mergeTabsToGroup(mInteractingTab.getTabId(), destTab.getTabId(), true);
RecordUserAction.record("MobileToolbarReorderTab.TabAddedToGroup");
return curIndex;
}
private AnimatorListener getGroupTitleSlidingAnimatorListener() {
return new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
mGroupTitleSliding = true;
}
@Override
public void onAnimationEnd(Animator animation) {
mGroupTitleSliding = false;
}
};
}
private void runIndicatorAnimationForMergeOrMoveOutOfGroup(
StripLayoutGroupTitle targetGroupTitle,
StripLayoutGroupTitle interactingGroupTitle,
int curIndex,
boolean isMovingOutOfGroup,
boolean towardEnd) {
List<Animator> animators = new ArrayList();
// Add the group title swapping animation if the tab is merging into or moving out of tab
// group through group title.
boolean throughGroupTitle = interactingGroupTitle != null;
AnimatorListener groupTitleAnimListener = null;
if (throughGroupTitle) {
animators.add(
getReorderStripViewAnimatorForTabGroupIndicator(
mInteractingTab.getTabId(), curIndex, towardEnd, true));
groupTitleAnimListener = getGroupTitleSlidingAnimatorListener();
}
// Add bottom indicator animation.
animators.add(
getBottomIndicatorAnimatorForMergeOrMoveOutOfGroup(
targetGroupTitle, isMovingOutOfGroup, throughGroupTitle));
startAnimationList(animators, groupTitleAnimListener);
}
private Animator getBottomIndicatorAnimatorForMergeOrMoveOutOfGroup(
StripLayoutGroupTitle groupTitle,
boolean isMovingOutOfGroup,
boolean throughGroupTitle) {
// Calculate the initial width and the target width for the bottom indicator.
float tabWidth = mCachedTabWidth - mTabOverlapWidth;
float startWidth =
calculateBottomIndicatorWidth(groupTitle, getNumOfTabsInGroup(groupTitle));
float endWidth = isMovingOutOfGroup ? startWidth - tabWidth : startWidth + tabWidth;
// Bottom indicator animation.
int animDuration = throughGroupTitle ? ANIM_TAB_MOVE_MS : ANIM_TAB_SLIDE_OUT_MS;
Animator animator =
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
groupTitle,
StripLayoutGroupTitle.BOTTOM_INDICATOR_WIDTH,
startWidth,
endWidth,
animDuration);
return animator;
}
private int updateHoveringOverGroup(float offset, int curIndex, boolean towardEnd) {
boolean hoveringOverGroup = Math.abs(offset) > mTabMarginWidth - mTabOverlapWidth;
// 1. Check if hover state has changed.
if (mHoveringOverGroup != hoveringOverGroup) {
// 1.a. Reset hover variables.
mHoveringOverGroup = hoveringOverGroup;
mHoverStartTime = INVALID_TIME;
mHoverStartOffset = 0;
// 1.b. Set tab group dim as necessary.
int groupId =
getTabById(mStripTabs[curIndex + (towardEnd ? 1 : -1)].getTabId()).getRootId();
setTabGroupBackgroundContainersVisible(groupId, mHoveringOverGroup);
}
// 2. If we are hovering, attempt to merge to the hovered group.
if (mHoveringOverGroup) {
return maybeMergeToGroup(offset, curIndex, towardEnd);
}
// 3. Default to not triggering a reorder.
return TabModel.INVALID_TAB_INDEX;
}
/**
* This method determines the new index for the interacting tab, based on whether or not it has
* met the conditions to be moved past a neighboring tab group.
*
* @param offset The distance the interacting tab has been dragged from its ideal x-position.
* @param curIndex The index of the interacting tab.
* @param towardEnd True if the interacting tab is being dragged toward the end of the strip.
* @return The new index for the interacting tab if it should be moved past the neighboring tab
* group and the INVALID_TAB_INDEX otherwise.
*/
private int maybeMovePastGroup(float offset, int curIndex, boolean towardEnd) {
int direction = towardEnd ? 1 : -1;
int groupId = getTabById(mStripTabs[curIndex + direction].getTabId()).getRootId();
int numTabsToSkip = mTabGroupModelFilter.getRelatedTabCountForRootId(groupId);
float effectiveTabWidth = mCachedTabWidth - mTabOverlapWidth;
float threshold = (numTabsToSkip * effectiveTabWidth) + mTabMarginWidth + mTabOverlapWidth;
// If past threshold, un-dim hovered group and trigger reorder.
if (Math.abs(offset) > threshold) {
setTabGroupBackgroundContainersVisible(groupId, false);
int destIndex = towardEnd ? curIndex + 1 + numTabsToSkip : curIndex - numTabsToSkip;
return destIndex;
}
return TabModel.INVALID_TAB_INDEX;
}
/**
* This method determines the new index for the interacting tab, based on whether or not it has
* met the conditions to be moved past a neighboring collapsed tab group.
*/
private int maybeMovePastCollapsedGroup(
StripLayoutGroupTitle groupTitle, float offset, int curIndex, boolean towardEnd) {
int groupId = groupTitle.getRootId();
int numTabsToSkip = mTabGroupModelFilter.getRelatedTabCountForRootId(groupId);
float threshold = groupTitle.getWidth() * REORDER_OVERLAP_SWITCH_PERCENTAGE;
// Animate group title moving to new position. mStripViews will be rebuilt when we receive
// the #didMoveTab event from the TabModel.
if (Math.abs(offset) > threshold) {
int destIndex = towardEnd ? curIndex + 1 + numTabsToSkip : curIndex - numTabsToSkip;
final float tabWidth = mCachedTabWidth - mTabOverlapWidth;
final float startOffset =
MathUtils.flipSignIf(tabWidth, (!towardEnd) ^ LocalizationUtils.isLayoutRtl());
// TODO(crbug.com/338130577): We intentionally start this outside of the
// "RunningAnimator" pattern so it doesn't finish early due to the subsequent
// #didMoveTab event. Fix this when we update #reorderTab to handle non-tab views.
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
groupTitle,
StripLayoutView.X_OFFSET,
startOffset,
0,
ANIM_TAB_MOVE_MS)
.start();
return destIndex;
}
return TabModel.INVALID_TAB_INDEX;
}
private boolean notRelatedAndEitherTabInGroup(Tab curTab, Tab adjTab) {
assert curTab != null && adjTab != null;
return !(curTab.getRootId() == adjTab.getRootId())
&& (mTabGroupModelFilter.isTabInTabGroup(curTab)
|| mTabGroupModelFilter.isTabInTabGroup(adjTab));
}
/**
* @param id The id of the selected tab.
* @return The outline color if the selected tab will show its Tab Group Indicator outline.
* {@code Color.TRANSPARENT} otherwise.
*/
protected @ColorInt int getSelectedOutlineGroupTint(int id, boolean shouldShowOutline) {
if (!shouldShowOutline) return Color.TRANSPARENT;
Tab tab = getTabById(id);
if (tab == null) return Color.TRANSPARENT;
StripLayoutGroupTitle groupTitle = findGroupTitle(tab.getRootId());
if (groupTitle == null) return Color.TRANSPARENT;
return groupTitle.getTint();
}
/**
* This method decides whether to show tab outline for Tab Group Indicators by checking whether
* its the selected tab and tab container state.
*
* @param stripLayoutTab The current {@link StripLayoutTab}.
* @return whether to show tab outline.
*/
protected boolean shouldShowTabOutline(StripLayoutTab stripLayoutTab) {
if (!ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
return false;
}
// Placeholder tabs on startup have invalid tab id, resulting in a null tab, if so, return
// early.
Tab tab = getTabById(stripLayoutTab.getTabId());
if (tab == null
|| !mTabGroupModelFilter.isTabInTabGroup(tab)
|| tab.getRootId() == mTabGroupIdToHide) {
return false;
}
// Show tab outline when tab is in group with folio attached and 1. tab is selected or 2.
// tab is in foreground (e.g. the previously selected tab in destination strip).
return stripLayoutTab.getFolioAttached()
&& (getSelectedStripTab() == stripLayoutTab
|| stripLayoutTab.getContainerOpacity() == TAB_OPACITY_VISIBLE_FOREGROUND);
}
private void updateReorderPosition(float deltaX) {
if (!mInReorderMode || mInteractingTab == null || mReorderingForTabDrop) return;
int curIndex = findIndexForTab(mInteractingTab.getTabId());
if (curIndex == TabModel.INVALID_TAB_INDEX) return;
// 1. Compute drag position.
final float tabWidth = mCachedTabWidth - mTabOverlapWidth;
float offset = mInteractingTab.getOffsetX() + deltaX;
boolean towardEnd = (offset >= 0) ^ LocalizationUtils.isLayoutRtl();
// 2. Determine grouped state.
Tab curTab = mModel.getTabAt(curIndex);
Tab adjTab = mModel.getTabAt(curIndex + (towardEnd ? 1 : -1));
boolean isInGroup = mTabGroupModelFilter.isTabInTabGroup(curTab);
boolean mayDragInOrOutOfGroup =
adjTab == null ? isInGroup : notRelatedAndEitherTabInGroup(curTab, adjTab);
// 3. Check if we should swap tabs. Track the move threshold, destination index, and
// interacting group title.
final float moveThreshold;
int destIndex = TabModel.INVALID_TAB_INDEX;
StripLayoutGroupTitle interactingGroupTitle = null;
if (mayDragInOrOutOfGroup) {
if (ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
moveThreshold = calculateTabGroupThreshold(curIndex, isInGroup, towardEnd);
if (isInGroup) {
// 3.a. Tab is in a group. Maybe drag out of group.
interactingGroupTitle = getInteractingGroupTitle(curIndex, towardEnd);
destIndex =
maybeMoveOutOfGroupForTabGroupIndicators(
offset,
curIndex,
towardEnd,
moveThreshold,
interactingGroupTitle);
} else {
StripLayoutGroupTitle adjTitle = findGroupTitle(adjTab.getRootId());
if (adjTitle != null && adjTitle.isCollapsed()) {
// 3.b. Tab is not in a group. Adjacent group is collapsed. Maybe reorder
// past the collapsed group.
interactingGroupTitle = adjTitle;
destIndex =
maybeMovePastCollapsedGroup(adjTitle, offset, curIndex, towardEnd);
} else {
// 3.c. Tab is not in a group. Adjacent group is not collapsed. Maybe merge
// to group.
interactingGroupTitle = getInteractingGroupTitle(curIndex, towardEnd);
destIndex =
maybeMergeToGroupForTabGroupIndicators(
offset,
curIndex,
towardEnd,
moveThreshold,
interactingGroupTitle);
}
}
} else {
if (isInGroup) {
// 3.d. Tab is in a group. Maybe drag out of group.
destIndex = maybeMoveOutOfGroup(offset, curIndex, towardEnd);
} else {
// 3.e. Tab is not in a group. Maybe merge to tab group.
destIndex = updateHoveringOverGroup(offset, curIndex, towardEnd);
// 3.f. Tab is not in a group. Maybe drag past group.
if (destIndex == TabModel.INVALID_TAB_INDEX) {
destIndex = maybeMovePastGroup(offset, curIndex, towardEnd);
}
}
}
} else {
// 3.g. Tab is not interacting with tab groups. Reorder as normal.
moveThreshold = REORDER_OVERLAP_SWITCH_PERCENTAGE * tabWidth;
boolean pastLeftThreshold = offset < -moveThreshold;
boolean pastRightThreshold = offset > moveThreshold;
boolean isNotRightMost = curIndex < mStripTabs.length - 1;
boolean isNotLeftMost = curIndex > 0;
if (LocalizationUtils.isLayoutRtl()) {
boolean oldLeft = pastLeftThreshold;
pastLeftThreshold = pastRightThreshold;
pastRightThreshold = oldLeft;
}
if (pastRightThreshold && isNotRightMost) {
destIndex = curIndex + 2;
} else if (pastLeftThreshold && isNotLeftMost) {
destIndex = curIndex - 1;
}
}
// 3. If we should swap tabs, make the swap.
if (destIndex != TabModel.INVALID_TAB_INDEX) {
// 3. a. Reset internal state.
mHoveringOverGroup = false;
// 3.b. Move the tab to its new position.
reorderTab(mInteractingTab.getTabId(), curIndex, destIndex, true);
mModel.moveTab(mInteractingTab.getTabId(), destIndex);
// 3.c. Re-compute tab group margins. Skip if group indicators are enabled instead.
float oldIdealX = mInteractingTab.getIdealX();
float oldOffset = mScrollDelegate.getScrollOffset();
if (!ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
computeAndUpdateTabGroupMargins(false, null);
} else {
// Update strip start and end margins to create more space for first tab or last tab
// to drag out of group.
if ((curIndex == 0 || curIndex >= mStripTabs.length - 2)
&& mTabGroupModelFilter.isTabInTabGroup(
getTabById(mInteractingTab.getTabId()))) {
computeAndUpdateStartAndEndMargins(false, null);
}
// Manually reset last tab's trailing margin after the tab group is removed.
if (mStripTabs.length > 1) {
mStripTabs[mStripTabs.length - 2].setTrailingMargin(0f);
}
}
// 3.d. Since we just moved the tab we're dragging, adjust its offset so it stays in
// the same apparent position.
boolean shouldFlip = LocalizationUtils.isLayoutRtl() ^ towardEnd;
if (mayDragInOrOutOfGroup) {
// Account for group title offset.
if (ChromeFeatureList.sTabStripGroupIndicators.isEnabled()
&& interactingGroupTitle != null) {
float groupTitleWidth = interactingGroupTitle.getWidth();
offset += MathUtils.flipSignIf((groupTitleWidth), shouldFlip);
} else {
offset -= (mInteractingTab.getIdealX() - oldIdealX);
// When the strip is scrolling, deltaX is already accounted for by idealX. This
// is because idealX uses the scroll offset which has already been adjusted by
// deltaX.
if (mLastReorderScrollTime != 0) offset -= deltaX;
// Tab group margins can affect minScrollOffset. When a dragged tab is near the
// strip's edge, the scrollOffset being clamped can affect the apparent
// position.
offset -=
MathUtils.flipSignIf(
(mScrollDelegate.getScrollOffset() - oldOffset),
LocalizationUtils.isLayoutRtl());
}
} else {
offset += MathUtils.flipSignIf(tabWidth, shouldFlip);
}
// 3.e. Update our curIndex as we have just moved the tab.
curIndex = destIndex > curIndex ? destIndex - 1 : destIndex;
// 3.f. Update visual tab ordering.
updateVisualTabOrdering();
}
// 4. Limit offset based on tab position. First tab can't drag left, last tab can't drag
// right. For Tab Group Indicators, since tab group margins were removed, we need to allow
// dragging left by the drag threshold when first tab is in group, and the same applies for
// dragging right when last tab is in group.
// TODO(crbug.com/331854162): Refactor to set mStripStartMarginForReorder and the final
// tab's trailing margin.
boolean indicatorsEnabled = ChromeFeatureList.sTabStripGroupIndicators.isEnabled();
boolean isRtl = LocalizationUtils.isLayoutRtl();
float limit;
if (curIndex == 0) {
limit =
(indicatorsEnabled && mStripViews[0] instanceof StripLayoutGroupTitle)
? calculateTabGroupThreshold(0, true, false)
: mStripStartMarginForReorder;
offset = isRtl ? Math.min(limit, offset) : Math.max(-limit, offset);
}
if (curIndex == mStripTabs.length - 1) {
offset =
LocalizationUtils.isLayoutRtl()
? Math.max(-mStripTabs[curIndex].getTrailingMargin(), offset)
: Math.min(mStripTabs[curIndex].getTrailingMargin(), offset);
}
// 5. Set the new offset.
mInteractingTab.setOffsetX(offset);
}
/**
* This method prompts a confirmation dialog for deleting the tab group and handles the user
* response.
*
* @param confirmationCallback The callback method to close the last tab or move the last tab
* out of the group when the user confirms the tab group deletion.
* @param dragTabOffStrip Whether the tab is being dragged off tab strip.
* @param closeTab Whether this method is triggered from tab closing.
*/
private void showConfirmationDialogAndHandleResponse(
Runnable confirmationCallback, boolean dragTabOffStrip, boolean closeTab) {
// Clear any drag and drop in progress to display the dialog.
if (!isTabRemoveDialogSkipped()) {
if (mToolbarContainerView != null) {
mToolbarContainerView.cancelDragAndDrop();
}
}
// Do not run callback if the call is from tab drag and drop, tab group will be restored
// if drop is not handled. If the tab drop is handled, the tab group will be deleted
// when the tab is re-parented, so no action is needed here.
boolean shouldRunIfImmediateContinue = closeTab || !dragTabOffStrip;
// Show the delete group dialog for either removing or closing the last tab in the group.
if (closeTab) {
mActionConfirmationManager.processCloseTabAttempt(
(@ConfirmationResult Integer result) -> {
handleUserConfirmation(
result, confirmationCallback, shouldRunIfImmediateContinue);
});
} else {
mActionConfirmationManager.processRemoveTabAttempt(
(@ConfirmationResult Integer result) -> {
handleUserConfirmation(
result, confirmationCallback, shouldRunIfImmediateContinue);
});
}
}
/**
* This method handles the user response for the tab group delete dialog.
*
* @param result The integer value representing the user's response on whether to proceed with
* deleting the group.
* @param confirmationCallback The callback method to close the last tab or move the last tab
* out of the group when the user confirms the tab group deletion.
* @param shouldRunIfImmediateContinue Whether to run the callback method when dialog is
* skipped.
*/
private void handleUserConfirmation(
@ConfirmationResult Integer result,
Runnable confirmationCallback,
boolean shouldRunIfImmediateContinue) {
mTabGroupIdToHide = Tab.INVALID_TAB_ID;
if (result == ConfirmationResult.CONFIRMATION_NEGATIVE) {
rebuildStripViews();
} else if (result == ConfirmationResult.CONFIRMATION_POSITIVE) {
confirmationCallback.run();
} else {
if (shouldRunIfImmediateContinue) {
confirmationCallback.run();
}
}
}
/**
* This method determines whether this tab drag is interacting with tab group title indicator.
*
* @param curIndexInStripTab The index of the interacting tab in mStripTabs.
* @param towardEnd True if the interacting tab is being dragged toward the end of the strip.
* @return Whether this tab drag is interacting with tab group title indicator.
*/
private StripLayoutGroupTitle getInteractingGroupTitle(
int curIndexInStripTab, boolean towardEnd) {
int curIndexInStripView = findStripViewIndexForStripTab(curIndexInStripTab);
if (curIndexInStripView == TabModel.INVALID_TAB_INDEX) {
return null;
}
if (towardEnd) {
if (curIndexInStripView == mStripViews.length - 1) {
return null;
}
// The drag is interacting with group title when 1. curTab is not is group and 2. the
// next view is a group title.
return !isStripTabInTabGroup(mStripTabs[curIndexInStripTab])
&& mStripViews[curIndexInStripView + 1]
instanceof StripLayoutGroupTitle groupTitle
? groupTitle
: null;
} else {
if (curIndexInStripView == 0) {
return null;
}
// The drag is interacting with group title when the previous view is a group title.
return mStripViews[curIndexInStripView - 1] instanceof StripLayoutGroupTitle groupTitle
? groupTitle
: null;
}
}
/**
* This method determines the drag threshold for either merge into or move out of a tab group
* for Tab Group Indicators.
*
* @param curIndexInStripTab The index of the interacting tab in mStripTabs.
* @param isInGroup Whether the current tab is in a tab group.
* @param towardEnd True if the interacting tab is being dragged toward the end of the strip.
* @return The drag threshold float for either merge into or move out of a tab group for Tab
* Group Indicators.
*/
@VisibleForTesting
protected float calculateTabGroupThreshold(
int curIndexInStripTab, boolean isInGroup, boolean towardEnd) {
int curIndexInStripView = findStripViewIndexForStripTab(curIndexInStripTab);
float dragOutThreshold = mHalfTabWidth * REORDER_OVERLAP_SWITCH_PERCENTAGE;
float dragInThreshold = mHalfTabWidth;
assert curIndexInStripView != TabModel.INVALID_TAB_INDEX;
if (isInGroup) {
if (curIndexInStripView > 0 && !towardEnd) {
return dragOutThreshold + mStripViews[curIndexInStripView - 1].getWidth();
} else {
return dragOutThreshold;
}
} else {
return dragInThreshold;
}
}
private float adjustXForTabDrop(float x) {
if (LocalizationUtils.isLayoutRtl()) {
return x + (mCachedTabWidth / 2);
} else {
return x - (mCachedTabWidth / 2);
}
}
void updateReorderPositionForTabDrop(float x) {
if (mTabGroupMarginAnimRunning) return;
// 1. Adjust by a half tab-width so that we target the nearest tab gap.
x = adjustXForTabDrop(x);
// 2. Clear previous "interacting" tab if inserting at the start of the strip.
boolean inStartGap =
LocalizationUtils.isLayoutRtl()
? x > mStripTabs[0].getTouchTargetRight()
: x < mStripTabs[0].getTouchTargetLeft();
if (inStartGap && mInteractingTab != null) {
float delta = mTabMarginWidth - mStripStartMarginForReorder;
mStripStartMarginForReorder = mTabMarginWidth;
if (delta != 0.f) {
mScrollDelegate.setReorderMinScrollOffset(
mScrollDelegate.getReorderExtraMinScrollOffset() + delta);
mScrollDelegate.setClampedScrollOffset(mScrollDelegate.getScrollOffset() - delta);
}
finishAnimations();
ArrayList<Animator> animationList = new ArrayList<>();
setTrailingMarginForTab(mInteractingTab, mLastTrailingMargin, animationList);
mInteractingTab = null;
startAnimationList(animationList, getTabGroupMarginAnimatorListener());
// 2.a. Early-out if we just entered the start gap.
return;
}
// 3. Otherwise, update drop indicator if necessary.
StripLayoutTab hoveredTab = getTabAtPosition(x);
if (hoveredTab != null && hoveredTab != mInteractingTab) {
finishAnimations();
// 3.a. Reset the state for the previous "interacting" tab.
ArrayList<Animator> animationList = new ArrayList<>();
if (mInteractingTab != null) {
setTrailingMarginForTab(mInteractingTab, mLastTrailingMargin, animationList);
Tab tab = getTabById(mInteractingTab.getTabId());
if (mTabGroupModelFilter.isTabInTabGroup(tab)
&& !ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
setTabGroupBackgroundContainersVisible(tab.getRootId(), false);
}
}
// 3.b. Set state for the new "interacting" tab.
mLastTrailingMargin = hoveredTab.getTrailingMargin();
setTrailingMarginForTab(hoveredTab, mTabMarginWidth, animationList);
Tab tab = getTabById(hoveredTab.getTabId());
if (mTabGroupModelFilter.isTabInTabGroup(tab)
&& !ChromeFeatureList.sTabStripGroupIndicators.isEnabled()) {
setTabGroupBackgroundContainersVisible(tab.getRootId(), true);
}
mInteractingTab = hoveredTab;
// 3.c. Animate.
startAnimationList(animationList, getTabGroupMarginAnimatorListener());
}
}
private void reorderTab(int id, int oldIndex, int newIndex, boolean animate) {
StripLayoutTab tab = findTabById(id);
if (tab == null || oldIndex == newIndex) return;
// 1. If the tab is already at the right spot, don't do anything.
int index = findIndexForTab(id);
if (index == newIndex) return;
// 2. Check if it's the tab we are dragging, but we have an old source index. Ignore in
// this case because we probably just already moved it.
if (mInReorderMode && index != oldIndex && tab == mInteractingTab) return;
// 3. Animate if necessary.
if (animate && !mAnimationsDisabledForTesting) {
final boolean towardEnd = oldIndex <= newIndex;
final float flipWidth = mCachedTabWidth - mTabOverlapWidth;
final int direction = towardEnd ? 1 : -1;
final float animationLength =
MathUtils.flipSignIf(direction * flipWidth, LocalizationUtils.isLayoutRtl());
finishAnimationsAndPushTabUpdates();
ArrayList<Animator> slideAnimationList = new ArrayList<>();
for (int i = oldIndex + direction; towardEnd == i < newIndex; i += direction) {
StripLayoutTab slideTab = mStripTabs[i];
CompositorAnimator animator =
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
slideTab,
StripLayoutView.X_OFFSET,
animationLength,
0f,
ANIM_TAB_MOVE_MS);
slideAnimationList.add(animator);
// When the reorder is triggered by an autoscroll, the first frame will not show the
// sliding tabs with the correct offset. To fix this, we manually set the correct
// starting offset. See https://crbug.com/1342811.
slideTab.setOffsetX(animationLength);
}
startAnimationList(slideAnimationList, null);
}
// 4. Swap the tabs.
moveElement(mStripTabs, index, newIndex);
if (!mMovingGroup) {
// When tab groups are moved, each tab is moved one-by-one. During this process, the
// invariant that tab groups must be contiguous is temporarily broken, so we suppress
// rebuilding until the entire group is moved. See https://crbug.com/329318567.
// TODO(crbug.com/329335086): Investigate reordering (with #moveElement) instead of
// rebuilding here.
rebuildStripViews();
}
}
/**
* This method reorders the StripLayoutView when tab drag is interacting with group title for
* Tab Group Indicators.
*
* @param id The tabId of the interacting tab.
* @param oldIndex The starting index of the reorder.
* @param towardEnd True if the interacting tab is being dragged toward the end of the strip.
* @param animate Whether to animate the view swapping.
*/
private Animator getReorderStripViewAnimatorForTabGroupIndicator(
int id, int oldIndex, boolean towardEnd, boolean animate) {
int direction = towardEnd ? 1 : -1;
int oldIndexInStripView = findStripViewIndexForStripTab(oldIndex);
assert oldIndexInStripView != TabModel.INVALID_TAB_INDEX;
boolean isLeftMost = oldIndexInStripView == 0;
boolean isRightMost = oldIndexInStripView >= mStripViews.length - 1;
StripLayoutTab tab = findTabById(id);
if (tab == null || (isLeftMost && !towardEnd) || (isRightMost && towardEnd)) return null;
int newIndexInStripView = oldIndexInStripView + direction;
// 1. If the view is already at the right spot, don't do anything.
int index = findIndexForTab(id);
int curIndexInStripView = findStripViewIndexForStripTab(index);
assert curIndexInStripView != TabModel.INVALID_TAB_INDEX;
if (curIndexInStripView == newIndexInStripView) return null;
// 2. Check if it's the view we are dragging, but we have an old source index. Ignore in
// this case because we probably just already moved it.
if (mInReorderMode
&& curIndexInStripView != oldIndexInStripView
&& tab == mInteractingTab) {
return null;
}
CompositorAnimator animator = null;
// 3. Animate if necessary.
if (animate && !mAnimationsDisabledForTesting) {
final float flipWidth = mCachedTabWidth - mTabOverlapWidth;
final float animationLength =
MathUtils.flipSignIf(direction * flipWidth, !LocalizationUtils.isLayoutRtl());
finishAnimationsAndPushTabUpdates();
StripLayoutView slideView = mStripViews[newIndexInStripView];
animator =
CompositorAnimator.ofFloatProperty(
mUpdateHost.getAnimationHandler(),
slideView,
StripLayoutView.DRAW_X,
slideView.getDrawX(),
slideView.getDrawX() + animationLength,
ANIM_TAB_MOVE_MS);
}
// 4. Swap the views.
if (!isRightMost && towardEnd) {
newIndexInStripView += 1;
}
moveElement(mStripViews, curIndexInStripView, newIndexInStripView);
return animator;
}
private void handleReorderAutoScrolling(long time) {
if (!mInReorderMode) return;
// 1. Track the delta time since the last auto scroll.
final float deltaSec =
mLastReorderScrollTime == INVALID_TIME
? 0.f
: (time - mLastReorderScrollTime) / 1000.f;
mLastReorderScrollTime = time;
// When we are reordering for tab drop, we are not offsetting the interacting tab. Instead,
// we are adding a visual indicator (a gap between tabs) to indicate where the tab will be
// added. As such, we need to base this on the most recent x-position of the drag, rather
// than the interacting tab's drawX.
final float x =
mReorderingForTabDrop
? adjustXForTabDrop(mLastReorderX)
: mInteractingTab.getDrawX();
// 2. Calculate the gutters for accelerating the scroll speed.
// Speed: MAX MIN MIN MAX
// |-------|======|--------------------|======|-------|
final float dragRange = REORDER_EDGE_SCROLL_START_MAX_DP - REORDER_EDGE_SCROLL_START_MIN_DP;
final float leftMinX = REORDER_EDGE_SCROLL_START_MIN_DP + mLeftMargin;
final float leftMaxX = REORDER_EDGE_SCROLL_START_MAX_DP + mLeftMargin;
final float rightMinX =
mWidth - mLeftMargin - mRightMargin - REORDER_EDGE_SCROLL_START_MIN_DP;
final float rightMaxX =
mWidth - mLeftMargin - mRightMargin - REORDER_EDGE_SCROLL_START_MAX_DP;
// 3. See if the current draw position is in one of the gutters and figure out how far in.
// Note that we only allow scrolling in each direction if the user has already manually
// moved that way.
float dragSpeedRatio = 0.f;
if ((mReorderState & REORDER_SCROLL_LEFT) != 0 && x < leftMinX) {
dragSpeedRatio = -(leftMinX - Math.max(x, leftMaxX)) / dragRange;
} else if ((mReorderState & REORDER_SCROLL_RIGHT) != 0 && x + mCachedTabWidth > rightMinX) {
dragSpeedRatio = (Math.min(x + mCachedTabWidth, rightMaxX) - rightMinX) / dragRange;
}
dragSpeedRatio = MathUtils.flipSignIf(dragSpeedRatio, LocalizationUtils.isLayoutRtl());
if (dragSpeedRatio != 0.f) {
// 4.a. We're in a gutter. Update the scroll offset.
float dragSpeed = REORDER_EDGE_SCROLL_MAX_SPEED_DP * dragSpeedRatio;
updateScrollOffsetPosition(mScrollDelegate.getScrollOffset() + (dragSpeed * deltaSec));
mUpdateHost.requestUpdate();
} else {
// 4.b. We're not in a gutter. Reset the scroll delta time tracker.
mLastReorderScrollTime = INVALID_TIME;
}
}
@VisibleForTesting
Tab getTabById(int tabId) {
return mModel.getTabById(tabId);
}
private int getSelectedTabId() {
if (mModel == null) return Tab.INVALID_TAB_ID;
int index = mModel.index();
if (index == TabModel.INVALID_TAB_INDEX) return Tab.INVALID_TAB_ID;
Tab tab = mModel.getTabAt(index);
if (tab == null) return Tab.INVALID_TAB_ID;
return tab.getId();
}
@VisibleForTesting
int getSelectedStripTabIndex() {
return mTabStateInitialized
? findIndexForTab(getSelectedTabId())
: mActiveTabIndexOnStartup;
}
private StripLayoutTab getSelectedStripTab() {
int index = getSelectedStripTabIndex();
return index >= 0 && index < mStripTabs.length ? mStripTabs[index] : null;
}
private boolean isSelectedTab(int id) {
return id != Tab.INVALID_TAB_ID && id == getSelectedTabId();
}
private void resetResizeTimeout(boolean postIfNotPresent) {
final boolean present = mStripTabEventHandler.hasMessages(MESSAGE_RESIZE);
if (present) mStripTabEventHandler.removeMessages(MESSAGE_RESIZE);
if (present || postIfNotPresent) {
mStripTabEventHandler.sendEmptyMessageAtTime(MESSAGE_RESIZE, RESIZE_DELAY_MS);
}
}
protected void scrollTabToView(long time, boolean requestUpdate) {
bringSelectedTabToVisibleArea(time, true);
if (requestUpdate) mUpdateHost.requestUpdate();
}
@SuppressLint("HandlerLeak")
private class StripTabEventHandler extends Handler {
@Override
public void handleMessage(Message m) {
switch (m.what) {
case MESSAGE_RESIZE:
computeAndUpdateTabWidth(true, false, null);
mUpdateHost.requestUpdate();
break;
case MESSAGE_UPDATE_SPINNER:
mUpdateHost.requestUpdate();
break;
default:
assert false : "StripTabEventHandler got unknown message " + m.what;
}
}
}
private class TabLoadTrackerCallbackImpl implements TabLoadTrackerCallback {
@Override
public void loadStateChanged(int id) {
mUpdateHost.requestUpdate();
}
}
private static <T> void moveElement(T[] array, int oldIndex, int newIndex) {
if (oldIndex <= newIndex) {
moveElementUp(array, oldIndex, newIndex);
} else {
moveElementDown(array, oldIndex, newIndex);
}
}
private static <T> void moveElementUp(T[] array, int oldIndex, int newIndex) {
assert oldIndex <= newIndex;
if (oldIndex == newIndex || oldIndex + 1 == newIndex) return;
T elem = array[oldIndex];
for (int i = oldIndex; i < newIndex - 1; i++) {
array[i] = array[i + 1];
}
array[newIndex - 1] = elem;
}
private static <T> void moveElementDown(T[] array, int oldIndex, int newIndex) {
assert oldIndex >= newIndex;
if (oldIndex == newIndex) return;
T elem = array[oldIndex];
for (int i = oldIndex - 1; i >= newIndex; i--) {
array[i + 1] = array[i];
}
array[newIndex] = elem;
}
/**
* Sets the current scroll offset of the TabStrip.
*
* @param offset The offset to set the TabStrip's scroll state to.
*/
public void setScrollOffsetForTesting(float offset) {
mScrollDelegate.setScrollOffset(offset);
updateStrip();
}
float getMinTabWidthForTesting() {
return mMinTabWidth;
}
/**
* Displays the tab menu below the anchor tab.
*
* @param anchorTab The tab the menu will be anchored to
*/
@VisibleForTesting
void showTabMenu(StripLayoutTab anchorTab) {
// 1. Bring the anchor tab to the foreground.
int tabIndex = TabModelUtils.getTabIndexById(mModel, anchorTab.getTabId());
TabModelUtils.setIndex(mModel, tabIndex);
// 2. Anchor the popupMenu to the view associated with the tab
View tabView = TabModelUtils.getCurrentTab(mModel).getView();
mTabMenu.setAnchorView(tabView);
// 3. Set the vertical offset to align the tab menu with bottom of the tab strip
int tabHeight = mManagerHost.getHeight();
int verticalOffset =
-(tabHeight - (int) mContext.getResources().getDimension(R.dimen.tab_strip_height));
mTabMenu.setVerticalOffset(verticalOffset);
// 4. Set the horizontal offset to align the tab menu with the right side of the tab
int horizontalOffset =
Math.round(
(anchorTab.getDrawX() + anchorTab.getWidth())
* mContext.getResources().getDisplayMetrics().density)
- mTabMenu.getWidth();
// Cap the horizontal offset so that the tab menu doesn't get drawn off screen.
horizontalOffset = Math.max(horizontalOffset, 0);
mTabMenu.setHorizontalOffset(horizontalOffset);
mTabMenu.show();
}
private void setScrollForScrollingTabStacker(float delta, boolean shouldAnimate, long time) {
if (delta == 0.f) return;
shouldAnimate = shouldAnimate && !mAnimationsDisabledForTesting;
mScrollDelegate.startScroll(time, delta, shouldAnimate);
}
/** Scrolls to the selected tab if it's not fully visible. */
private void bringSelectedTabToVisibleArea(long time, boolean animate) {
if (mWidth == 0) return;
int index = getSelectedStripTabIndex();
StripLayoutTab selectedLayoutTab =
index >= 0 && index < mStripTabs.length ? mStripTabs[index] : null;
if (selectedLayoutTab == null || isSelectedTabCompletelyVisible(selectedLayoutTab)) {
return;
}
float delta = calculateDeltaToMakeIndexVisible(index);
setScrollForScrollingTabStacker(delta, animate, time);
}
private boolean isSelectedTabCompletelyVisible(StripLayoutTab selectedTab) {
return selectedTab.isVisible()
&& selectedTab.getDrawX() > getVisibleLeftBound() + mLeftFadeWidth
&& selectedTab.getDrawX() + selectedTab.getWidth()
< getVisibleRightBound() - mRightFadeWidth;
}
/**
* Determines whether a drawn view is completely outside of the visible area of the tab strip.
*
* @param view The {@link StripLayoutView} whose visibility is determined.
* @return {@code true} if the view is completely hidden, {@code false} otherwise.
*/
@VisibleForTesting
boolean isViewCompletelyHidden(StripLayoutView view) {
return !view.isVisible() || isViewCompletelyHiddenAt(view.getDrawX(), view.getWidth());
}
/**
* Determines whether a view will be completely outside of the visible area of the tab strip
* once it reaches its ideal position.
*
* @param view The {@link StripLayoutView} whose visibility will be determined.
* @return {@code true} if the view will be completely hidden, {@code false} otherwise.
*/
private boolean willViewBeCompletelyHidden(StripLayoutView view) {
return isViewCompletelyHiddenAt(view.getIdealX(), view.getWidth());
}
private boolean isViewCompletelyHiddenAt(float viewX, float viewWidth) {
// Check if the tab is outside the visible bounds to the left...
return viewX + viewWidth <= getVisibleLeftBound() + mLeftFadeWidth
// ... or to the right.
|| viewX >= getVisibleRightBound() - mRightFadeWidth;
}
/**
* To prevent accidental tab closures, when the close button of a tab is very close to the edge
* of the tab strip, we hide the close button. The threshold for hiding is different based on
* the length of the fade at the end of the strip.
*
* @param start Whether its the start of the tab strip.
* @return The distance threshold from the edge of the tab strip to hide the close button.
*/
private float getCloseBtnVisibilityThreshold(boolean start) {
if (start) {
// TODO(zheliooo): Add unit tests to cover start tab cases for testTabSelected in
// StripLayoutHelperTest.
return CLOSE_BTN_VISIBILITY_THRESHOLD_START;
} else {
return LocalizationUtils.isLayoutRtl() ? mLeftFadeWidth : mRightFadeWidth;
}
}
/**
* @return true if the tab menu is showing
*/
public boolean isTabMenuShowingForTesting() {
return mTabMenu.isShowing();
}
/**
* @param menuItemId The id of the menu item to click
*/
public void clickTabMenuItemForTesting(int menuItemId) {
mTabMenu.performItemClick(menuItemId);
}
/**
* @return The width of the tab strip.
*/
float getWidthForTesting() {
return mWidth;
}
/**
* @return The width of a tab.
*/
float getCachedTabWidthForTesting() {
return mCachedTabWidth;
}
/**
* @return The strip's minimum scroll offset.
*/
float getMinimumScrollOffsetForTesting() {
return mScrollDelegate.getMinScrollOffsetForTesting(); // IN-TEST
}
/**
* @return The strip's additional minimum scroll offset for reorder mode.
*/
float getReorderExtraMinScrollOffsetForTesting() {
return mScrollDelegate.getReorderExtraMinScrollOffset();
}
/**
* @return The scroller.
*/
StackScroller getScrollerForTesting() {
return mScrollDelegate.getScrollerForTesting(); // IN-TEST
}
/**
* @return An array containing the StripLayoutTabs.
*/
StripLayoutTab[] getStripLayoutTabsForTesting() {
return mStripTabs;
}
/** Set the value of mStripTabs for testing */
void setStripLayoutTabsForTesting(StripLayoutTab[] stripTabs) {
this.mStripTabs = stripTabs;
}
/**
* @return An array containing the StripLayoutViews.
*/
StripLayoutView[] getStripLayoutViewsForTesting() {
return mStripViews;
}
/**
* @return The amount tabs overlap.
*/
float getTabOverlapWidthForTesting() {
return mTabOverlapWidth;
}
/**
* @return The currently interacting tab.
*/
StripLayoutTab getInteractingTabForTesting() {
return mInteractingTab;
}
/** Disables animations for testing purposes. */
public void disableAnimationsForTesting() {
mAnimationsDisabledForTesting = true;
}
Animator getRunningAnimatorForTesting() {
return mRunningAnimator;
}
/**
* @return Whether group title sliding animation is running.
*/
boolean getGroupTitleSlidingForTesting() {
return mGroupTitleSliding;
}
void setRunningAnimatorForTesting(Animator animator) {
mRunningAnimator = animator;
}
protected boolean isMultiStepCloseAnimationsRunningForTesting() {
return mMultiStepTabCloseAnimRunning;
}
protected float getLastReorderXForTesting() {
return mLastReorderX;
}
protected void setInReorderModeForTesting(boolean inReorderMode) {
mInReorderMode = inReorderMode;
}
private void setAccessibilityDescription(StripLayoutTab stripTab, Tab tab) {
if (tab != null) setAccessibilityDescription(stripTab, tab.getTitle(), tab.isHidden());
}
/**
* Set the accessibility description of a {@link StripLayoutTab}.
*
* @param stripTab The StripLayoutTab to set the accessibility description.
* @param title The title of the tab.
* @param isHidden Current visibility state of the Tab.
*/
private void setAccessibilityDescription(
StripLayoutTab stripTab, @Nullable String title, boolean isHidden) {
if (stripTab == null) return;
@StringRes int resId;
if (mIncognito) {
resId =
isHidden
? R.string.accessibility_tabstrip_incognito_identifier
: R.string.accessibility_tabstrip_incognito_identifier_selected;
} else {
resId =
isHidden
? R.string.accessibility_tabstrip_identifier
: R.string.accessibility_tabstrip_identifier_selected;
}
if (!stripTab.needsAccessibilityDescriptionUpdate(title, resId)) {
// The resulting accessibility description would be the same as the current description,
// so skip updating it to avoid having to read resources unnecessarily.
return;
}
// Separator used to separate the different parts of the content description.
// Not for sentence construction and hence not localized.
final String contentDescriptionSeparator = ", ";
final StringBuilder builder = new StringBuilder();
if (!TextUtils.isEmpty(title)) {
builder.append(title);
builder.append(contentDescriptionSeparator);
}
builder.append(mContext.getResources().getString(resId));
stripTab.setAccessibilityDescription(builder.toString(), title, resId);
}
private void performHapticFeedback(Tab tab) {
View tabView = tab.getView();
if (tabView == null) return;
tabView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
}
protected void clearTabDragState() {
// If the dragged tab was re-parented, it will have triggered a #computeAndUpdateTabOrders
// call and will no longer be present in the list of tabs. If this is not the case, attempt
// to return the dragged tab to its original position.
StripLayoutTab selectedTab = getSelectedStripTab();
if (selectedTab != null
&& findTabById(selectedTab.getTabId()) != null
&& selectedTab.isDraggedOffStrip()) {
// Rebuild tab groups to unhide the interacting tab group as tab is restored back on tab
// strip.
if (ChromeFeatureList.sTabStripGroupIndicators.isEnabled()
&& isTabRemoveDialogSkipped()
&& isLastTabInGroup(selectedTab.getTabId())) {
mTabGroupIdToHide = Tab.INVALID_TAB_ID;
rebuildStripViews();
}
dragActiveClickedTabOntoStrip(LayoutManagerImpl.time(), 0.0f, false);
}
mLastOffsetX = 0.f;
mActiveClickedTab = null;
}
StripLayoutTab getActiveClickedTabForTesting() {
return mActiveClickedTab;
}
@VisibleForTesting
boolean startDragAndDropTab(
@NonNull StripLayoutTab clickedTab, @NonNull PointF dragStartPointF) {
if (mTabDragSource == null || !mTabStateInitialized) return false;
// In addition to reordering, one can drag and drop the tab beyond the strip layout view.
Tab tabBeingDragged = getTabById(clickedTab.getTabId());
boolean dragStarted = false;
if (tabBeingDragged != null) {
dragStarted =
mTabDragSource.startTabDragAction(
mToolbarContainerView,
tabBeingDragged,
dragStartPointF,
clickedTab.getDrawX(),
clickedTab.getWidth());
if (dragStarted) {
mActiveClickedTab = clickedTab;
mLastOffsetX = 0.f;
}
}
return dragStarted;
}
void setLastOffsetXForTesting(float lastOffsetX) {
mLastOffsetX = lastOffsetX;
}
float getLastOffsetXForTesting() {
return mLastOffsetX;
}
void prepareForTabDrop(
long time,
float currX,
float lastX,
boolean isSourceStrip,
boolean draggedTabIncognito) {
if (isSourceStrip) {
dragActiveClickedTabOntoStrip(time, lastX, true);
} else if (mIncognito == draggedTabIncognito) {
updateStripForExternalTabDrop(currX);
}
}
void clearForTabDrop(long time, boolean isSourceStrip, boolean draggedTabIncognito) {
if (isSourceStrip) {
dragActiveClickedTabOutOfStrip(time);
} else if (mIncognito == draggedTabIncognito) {
onUpOrCancel(time);
}
}
private void dragActiveClickedTabOntoStrip(long time, float x, boolean startReorder) {
StripLayoutTab draggedTab = getSelectedStripTab();
assert draggedTab != null;
finishAnimationsAndPushTabUpdates();
draggedTab.setIsDraggedOffStrip(false);
if (startReorder) {
// If we're reordering, bring the tab to the correct position so we can begin reordering
// immediately.
draggedTab.setOffsetX(mLastOffsetX);
draggedTab.setOffsetY(0);
mLastOffsetX = 0.f;
resizeTabStrip(false, false, false);
startReorderTab(time, x, x);
} else {
// Else, animate the tab translating back up onto the tab strip.
draggedTab.setWidth(0.f);
List<Animator> animationList = resizeTabStrip(true, false, true);
if (animationList != null) runTabAddedAnimator(animationList, draggedTab);
}
}
/**
* This method checks if the tab group delete dialog should be shown and temporarily hides the
* tab group that may be deleted upon user confirmation.
*
* @param rootId The root id of the interacting tab.
* @param draggingLastTabOffStrip Whether the last tab in group is being dragged off strip.
* @param closeTab The tab being closed.
* @param confirmationCallback The callback method to close the tab or move the tab out of group
* when user confirms the tab group deletion.
*/
private void showDeleteGroupDialogAndProcessTabAction(
int rootId,
boolean draggingLastTabOffStrip,
boolean closeTab,
Runnable confirmationCallback) {
if (mTabGroupIdToHide == Tab.INVALID_TAB_ID) {
// Hide the tab group and rebuild tab strip view.
mTabGroupIdToHide = rootId;
rebuildStripViews();
// Show confirmation dialog and handle user response.
showConfirmationDialogAndHandleResponse(
confirmationCallback, draggingLastTabOffStrip, closeTab);
}
}
private void dragActiveClickedTabOutOfStrip(long time) {
StripLayoutTab draggedTab = getSelectedStripTab();
assert draggedTab != null;
int tabId = draggedTab.getTabId();
Tab tab = getTabById(tabId);
// Show group delete dialog when the last tab in group is being dragged off tab strip.
boolean draggingLastTabInGroup = isLastTabInGroup(tabId);
if (draggingLastTabInGroup && !mIncognito) {
showDeleteGroupDialogAndProcessTabAction(
tab.getRootId(),
/* draggingLastTabOffStrip= */ true,
/* closeTab= */ false,
() -> {
mTabGroupModelFilter.moveTabOutOfGroupInDirection(tabId, false);
});
}
// Store reorder state, then exit reorder mode.
mLastOffsetX = draggedTab.getOffsetX();
onUpOrCancel(time);
finishAnimationsAndPushTabUpdates();
// Skip hiding dragged tab container when tab group delete dialog is showing.
if (!draggingLastTabInGroup || isTabRemoveDialogSkipped()) {
// Immediately hide the dragged tab container, as if it were being translated off like a
// closed tab.
draggedTab.setIsDraggedOffStrip(true);
draggedTab.setDrawX(draggedTab.getIdealX());
draggedTab.setDrawY(mHeight);
draggedTab.setOffsetY(mHeight);
mMultiStepTabCloseAnimRunning = true;
// Resize the tab strip accordingly.
resizeStripOnTabClose(getTabById(draggedTab.getTabId()));
}
}
void sendMoveWindowBroadcast(View view, float startXInView, float startYInView) {
if (!TabUiFeatureUtilities.isTabDragAsWindowEnabled()) return;
if (mWindowAndroid.getActivity().get() == null) return;
// The start position is in the view coordinate system and related to the top left position
// of the toolbar container view. Convert it to the screen coordinate system for window drag
// start position.
int[] topLeftLocation = new int[2];
view.getLocationOnScreen(topLeftLocation);
float startXInScreen = topLeftLocation[0] + startXInView;
float startYInScreen = topLeftLocation[1] + startYInView;
int taskId = ApplicationStatus.getTaskId(mWindowAndroid.getActivity().get());
// Prepare the move window intent for the Android system to initiate move and take over the
// user input events. The intent is ignored when not handled with no impact to existing
// Android platforms.
Intent intent = new Intent();
intent.setPackage(view.getContext().getPackageName());
intent.setAction("com.android.systemui.MOVE_WINDOW");
intent.putExtra("MOVE_WINDOW_TASK_ID", taskId);
intent.putExtra("MOVE_WINDOW_START_X", startXInScreen);
intent.putExtra("MOVE_WINDOW_START_Y", startYInScreen);
mWindowAndroid.sendBroadcast(intent);
}
}