// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/base_grid_view_controller.h"
#import <optional>
#import "base/apple/foundation_util.h"
#import "base/check_op.h"
#import "base/debug/dump_without_crashing.h"
#import "base/ios/block_types.h"
#import "base/ios/ios_util.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/notreached.h"
#import "base/numerics/safe_conversions.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/browser/commerce/ui_bundled/price_card/price_card_data_source.h"
#import "ios/chrome/browser/commerce/ui_bundled/price_card/price_card_item.h"
#import "ios/chrome/browser/shared/public/commands/tab_grid_commands.h"
#import "ios/chrome/browser/shared/public/commands/tab_group_confirmation_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/tabs/model/inactive_tabs/features.h"
#import "ios/chrome/browser/ui/menu/menu_histograms.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_collection_drag_drop_handler.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_collection_drag_drop_metrics.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/base_grid_mediator_items_provider.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/base_grid_view_controller+Testing.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/base_grid_view_controller+subclassing.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_cell.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_constants.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_empty_view.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_header.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_item_identifier.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_layout.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_view_controller_mutator.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/group_grid_cell.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/suggested_actions/suggested_actions_delegate.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/suggested_actions/suggested_actions_grid_cell.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/suggested_actions/suggested_actions_view_controller.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_context_menu/tab_context_menu_provider.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/transitions/legacy_grid_transition_layout.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/transitions/tab_grid_transition_item.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_group_item.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_switcher_item.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_utils.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/public/provider/chrome/browser/modals/modals_api.h"
#import "ios/public/provider/chrome/browser/raccoon/raccoon_api.h"
#import "ios/web/public/web_state_id.h"
#import "ui/base/l10n/l10n_util.h"
using base::apple::ObjCCast;
using base::apple::ObjCCastStrict;
class ScopedScrollingTimeLogger {
public:
ScopedScrollingTimeLogger() : start_(base::TimeTicks::Now()) {}
~ScopedScrollingTimeLogger() {
base::TimeDelta duration = base::TimeTicks::Now() - start_;
base::UmaHistogramTimes("IOS.TabSwitcher.TimeSpentScrolling", duration);
}
private:
base::TimeTicks start_;
};
namespace {
NSString* const kCellIdentifier = @"GridCellIdentifier";
NSString* const kGroupCellIdentifier = @"GroupGridCellIdentifier";
// Returns the accessibility identifier to set on a GridCell when positioned at
// the given index.
NSString* GridCellAccessibilityIdentifier(NSUInteger index) {
return [NSString stringWithFormat:@"%@%ld", kGridCellIdentifierPrefix, index];
}
// Returns the accessibility identifier to set on a GroupGridCell when
// positioned at the given index.
NSString* GroupGridCellAccessibilityIdentifier(NSUInteger index) {
return [NSString
stringWithFormat:@"%@%ld", kGroupGridCellIdentifierPrefix, index];
}
} // namespace
@interface BaseGridViewController () <GridCellDelegate,
GroupGridCellDelegate,
SuggestedActionsViewControllerDelegate,
UICollectionViewDropDelegate,
UIPointerInteractionDelegate>
// A collection view of items in a grid format.
@property(nonatomic, weak) UICollectionView* collectionView;
// The collection view's data source.
@property(nonatomic, strong) GridDiffableDataSource* diffableDataSource;
// Identifier of the selected item. This should only be used for lookup or
// equality checks, in that it is usually not possible to fetch its images
// (favicon, snapshot). Use the GridItemIdentifier from the data source that
// matches instead.
@property(nonatomic, strong) GridItemIdentifier* selectedItemIdentifier;
// Index of the selected item.
@property(nonatomic, readonly) NSUInteger selectedIndex;
// ID of the last item to be inserted. This is used to track if the active tab
// was newly created when building the animation layout for transitions.
@property(nonatomic, assign) web::WebStateID lastInsertedItemID;
// Animator to show or hide the empty state.
@property(nonatomic, strong) UIViewPropertyAnimator* emptyStateAnimator;
// The layout for the tab grid.
@property(nonatomic, strong) GridLayout* gridLayout;
// The view controller that holds the view of the suggested search actions.
@property(nonatomic, strong)
SuggestedActionsViewController* suggestedActionsViewController;
// Grid cells for which pointer interactions have been added. Pointer
// interactions should only be added to displayed cells (not transition cells).
// This is only expected to get as large as the number of reusable grid cells in
// memory.
@property(nonatomic, strong) NSHashTable<GridCell*>* pointerInteractionCells;
// YES while batch updates and the batch update completion are being performed.
@property(nonatomic) BOOL updating;
// YES while the grid has the suggested actions section.
@property(nonatomic) BOOL showingSuggestedActions;
// YES if the dragged tab moved to a new index.
@property(nonatomic, assign) BOOL dragEndAtNewIndex;
// Tracks if a drop action initiated in this grid is in progress.
@property(nonatomic) BOOL localDragActionInProgress;
// Tracks if the items are in a batch action, which are the "Close All" or
// "Undo" the close all.
@property(nonatomic) BOOL isClosingAllOrUndoRunning;
@end
@implementation BaseGridViewController {
// Tracks when the grid view is scrolling. Create a new instance to start
// timing and reset to stop and log the associated time histogram.
std::optional<ScopedScrollingTimeLogger> _scopedScrollingTimeLogger;
// The cell registration for grid cells.
UICollectionViewCellRegistration* _gridCellRegistration;
// The cell registration for grid group cells.
UICollectionViewCellRegistration* _groupGridCellRegistration;
// The cell registration for the Suggested Actions cell.
UICollectionViewCellRegistration* _suggestedActionsCellRegistration;
// The supplementary view registration for the grid header.
UICollectionViewSupplementaryRegistration* _gridHeaderRegistration;
// Latest dragged item identifier. This property is set when the item is
// long pressed which does not always result in a drag action.
GridItemIdentifier* _draggedItemIdentifier;
// Current mode of the Tab Grid. Should be set through consumer protocol.
TabGridMode _mode;
}
- (instancetype)init {
if ((self = [super init])) {
_dropAnimationInProgress = NO;
_localDragActionInProgress = NO;
_notSelectedTabCellOpacity = 1.0;
_mode = TabGridMode::kNormal;
// Register for VoiceOver notifications.
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(voiceOverStatusDidChange)
name:UIAccessibilityVoiceOverStatusDidChangeNotification
object:nil];
// Register for Dynamic Type notifications.
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(preferredContentSizeCategoryDidChange)
name:UIContentSizeCategoryDidChangeNotification
object:nil];
}
return self;
}
#pragma mark - UIViewController
- (void)loadView {
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
GridLayout* gridLayout = [[GridLayout alloc] init];
self.gridLayout = gridLayout;
UICollectionView* collectionView =
[[UICollectionView alloc] initWithFrame:CGRectZero
collectionViewLayout:gridLayout];
// If this stays as the default `YES`, then cells aren't highlighted
// immediately on touch, but after a short delay.
collectionView.delaysContentTouches = NO;
collectionView.alwaysBounceVertical = YES;
[self createRegistrations];
__weak __typeof(self) weakSelf = self;
GridDiffableDataSource* diffableDataSource =
[[UICollectionViewDiffableDataSource alloc]
initWithCollectionView:collectionView
cellProvider:^UICollectionViewCell*(
UICollectionView* innerCollectionView,
NSIndexPath* indexPath,
GridItemIdentifier* itemIdentifier) {
return [weakSelf cellForItemAtIndexPath:indexPath
itemIdentifier:itemIdentifier];
}];
self.diffableDataSource = diffableDataSource;
gridLayout.diffableDataSource = diffableDataSource;
diffableDataSource.supplementaryViewProvider = ^UICollectionReusableView*(
UICollectionView* innerCollectionView, NSString* elementKind,
NSIndexPath* indexPath) {
return [weakSelf headerForSectionAtIndexPath:indexPath];
};
collectionView.dataSource = diffableDataSource;
GridSnapshot* snapshot = [[GridSnapshot alloc] init];
[snapshot appendSectionsWithIdentifiers:@[ kGridOpenTabsSectionIdentifier ]];
[diffableDataSource applySnapshotUsingReloadData:snapshot];
// UICollectionViewDropPlaceholder uses a GridCell and needs the class to be
// registered.
[collectionView registerClass:[GridCell class]
forCellWithReuseIdentifier:kCellIdentifier];
[collectionView registerClass:[GroupGridCell class]
forCellWithReuseIdentifier:kGroupCellIdentifier];
collectionView.delegate = self;
collectionView.backgroundView = [[UIView alloc] init];
collectionView.backgroundColor = [UIColor clearColor];
collectionView.backgroundView.accessibilityIdentifier =
kGridBackgroundIdentifier;
// CollectionView, in contrast to TableView, doesn’t inset the
// cell content to the safe area guide by default. We will just manage the
// collectionView contentInset manually to fit in the safe area instead.
collectionView.contentInsetAdjustmentBehavior =
UIScrollViewContentInsetAdjustmentNever;
collectionView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag;
self.collectionView = collectionView;
self.view = collectionView;
// A single selection collection view's default behavior is to momentarily
// deselect the selected cell on touch down then select the new cell on touch
// up. In this tab grid, the selection ring should stay visible on the
// selected cell on touch down. Multiple selection disables the deselection
// behavior. Multiple selection will not actually be possible since
// `-collectionView:shouldSelectItemAtIndexPath:` returns NO.
collectionView.allowsMultipleSelection = YES;
collectionView.dragDelegate = self;
collectionView.dropDelegate = self;
self.collectionView.dragInteractionEnabled =
[self shouldEnableDrapAndDropInteraction];
self.pointerInteractionCells = [NSHashTable<GridCell*> weakObjectsHashTable];
[self updateTabsSectionHeaderType];
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self contentWillAppearAnimated:animated];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
// Dismisses the confirmation dialog for tab group if it's displayed.
[self.tabGroupConfirmationHandler dismissTabGroupConfirmation];
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
[coordinator
animateAlongsideTransition:^(
id<UIViewControllerTransitionCoordinatorContext> context) {
[self.collectionView.collectionViewLayout invalidateLayout];
}
completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
if (ios::provider::IsRaccoonEnabled()) {
for (UICollectionViewCell* cell in self.collectionView.visibleCells) {
[self setHoverEffectToCell:cell];
}
}
[self.collectionView setNeedsLayout];
[self.collectionView layoutIfNeeded];
}];
}
#pragma mark - Public
- (BOOL)isScrolledToTop {
return IsScrollViewScrolledToTop(self.collectionView);
}
- (BOOL)isScrolledToBottom {
return IsScrollViewScrolledToBottom(self.collectionView);
}
- (BOOL)isGridScrollsToTopEnabled {
return self.collectionView.scrollsToTop;
}
- (void)setGridScrollsToTopEnabled:(BOOL)gridScrollsToTopEnabled {
self.collectionView.scrollsToTop = gridScrollsToTopEnabled;
}
- (void)setEmptyStateView:(UIView<GridEmptyView>*)emptyStateView {
if (_emptyStateView) {
[_emptyStateView removeFromSuperview];
}
_emptyStateView = emptyStateView;
emptyStateView.scrollViewContentInsets =
self.collectionView.adjustedContentInset;
emptyStateView.translatesAutoresizingMaskIntoConstraints = NO;
[self.collectionView.backgroundView addSubview:emptyStateView];
id<LayoutGuideProvider> safeAreaGuide =
self.collectionView.backgroundView.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:@[
[self.collectionView.backgroundView.centerYAnchor
constraintEqualToAnchor:emptyStateView.centerYAnchor],
[safeAreaGuide.leadingAnchor
constraintEqualToAnchor:emptyStateView.leadingAnchor],
[safeAreaGuide.trailingAnchor
constraintEqualToAnchor:emptyStateView.trailingAnchor],
[emptyStateView.topAnchor
constraintGreaterThanOrEqualToAnchor:safeAreaGuide.topAnchor],
[emptyStateView.bottomAnchor
constraintLessThanOrEqualToAnchor:safeAreaGuide.bottomAnchor],
]];
}
- (BOOL)isGridEmpty {
return [self numberOfTabs] == 0;
}
- (BOOL)isContainedGridEmpty {
return YES;
}
- (void)setTabGridMode:(TabGridMode)mode {
if (_mode == mode) {
return;
}
TabGridMode previousMode = _mode;
_mode = mode;
self.collectionView.dragInteractionEnabled =
[self shouldEnableDrapAndDropInteraction];
self.emptyStateView.tabGridMode = _mode;
if (mode == TabGridMode::kSearch && self.suggestedActionsDelegate) {
if (!self.suggestedActionsViewController) {
self.suggestedActionsViewController =
[[SuggestedActionsViewController alloc] initWithDelegate:self];
}
}
[self updateTabsSectionHeaderType];
[self updateSuggestedActionsSection];
// Reconfigure all tabs.
GridSnapshot* snapshot = self.diffableDataSource.snapshot;
[self updateSnapshotForModeUpdate:snapshot];
[snapshot reconfigureItemsWithIdentifiers:snapshot.itemIdentifiers];
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
[self.gridLayout invalidateLayout];
NSUInteger selectedIndex = self.selectedIndex;
if (previousMode != TabGridMode::kSelection && mode == TabGridMode::kNormal &&
selectedIndex != NSNotFound &&
static_cast<NSInteger>(selectedIndex) < [self numberOfTabs]) {
// Scroll to the selected item here, so the action of reloading and
// scrolling happens at once.
[self.collectionView
scrollToItemAtIndexPath:[self indexPathForTabIndex:selectedIndex]
atScrollPosition:UICollectionViewScrollPositionTop
animated:NO];
}
if (mode == TabGridMode::kNormal) {
// After transition from other modes to the normal mode, the selection
// border doesn't show around the selected item, because reloading
// operations lose the selected items. The collection view needs to be
// updated with the selected item again for it to appear correctly.
[self updateSelectedCollectionViewItemRingAndBringIntoView:NO];
self.searchText = nil;
} else if (mode == TabGridMode::kSelection) {
// The selected state is not visible in TabGridMode::kSelection mode, but
// VoiceOver surfaces it. Deselects all the collection view items.
// The selection will be reinstated when moving off of
// TabGridMode::kSelection.
NSArray<NSIndexPath*>* indexPathsForSelectedItems =
[self.collectionView indexPathsForSelectedItems];
for (NSIndexPath* itemIndexPath in indexPathsForSelectedItems) {
[self.collectionView deselectItemAtIndexPath:itemIndexPath animated:NO];
}
}
}
- (void)setSearchText:(NSString*)searchText {
_searchText = searchText;
_suggestedActionsViewController.searchText = searchText;
[self updateTabsSectionHeaderType];
[self updateSuggestedActionsSection];
}
- (BOOL)isSelectedCellVisible {
// The collection view's selected item may not have updated yet, so use the
// selected index.
NSUInteger selectedIndex = self.selectedIndex;
if (selectedIndex == NSNotFound) {
return NO;
}
NSIndexPath* selectedIndexPath = [self indexPathForTabIndex:selectedIndex];
return [self.collectionView.indexPathsForVisibleItems
containsObject:selectedIndexPath];
}
- (void)setContentInsets:(UIEdgeInsets)contentInsets {
// Set the vertical insets on the collection view…
self.collectionView.contentInset =
UIEdgeInsetsMake(contentInsets.top, 0, contentInsets.bottom, 0);
// … and the horizontal insets on the layout sections.
// This is a workaround, as setting the horizontal insets on the collection
// view isn't honored by the layout when computing the item sizes (items are
// too big in landscape iPhones with a notch or Dynamic Island).
self.gridLayout.sectionInsets = NSDirectionalEdgeInsetsMake(
0, contentInsets.left, 0, contentInsets.right);
_contentInsets = contentInsets;
}
- (LegacyGridTransitionLayout*)transitionLayout {
[self.collectionView layoutIfNeeded];
NSMutableArray<LegacyGridTransitionItem*>* items =
[[NSMutableArray alloc] init];
LegacyGridTransitionActiveItem* activeItem;
LegacyGridTransitionItem* selectionItem;
NSInteger tabSectionIndex = [self.diffableDataSource
indexForSectionIdentifier:kGridOpenTabsSectionIdentifier];
for (NSIndexPath* path in self.collectionView.indexPathsForVisibleItems) {
if (path.section != tabSectionIndex) {
continue;
}
UICollectionViewCell* collectionViewCell =
[self.collectionView cellForItemAtIndexPath:path];
if (![collectionViewCell isKindOfClass:[GridCell class]]) {
// TODO(crbug.com/334885429): Update once the transition animation for the
// group cells is available.
continue;
}
GridCell* cell = ObjCCastStrict<GridCell>(collectionViewCell);
UICollectionViewLayoutAttributes* attributes =
[self.collectionView layoutAttributesForItemAtIndexPath:path];
// Normalize frame to window coordinates. The attributes class applies this
// change to the other properties such as center, bounds, etc.
attributes.frame = [self.collectionView convertRect:attributes.frame
toView:nil];
if ([cell.itemIdentifier isEqual:self.selectedItemIdentifier]) {
GridTransitionCell* activeCell =
[GridTransitionCell transitionCellFromCell:cell];
activeItem =
[LegacyGridTransitionActiveItem itemWithCell:activeCell
center:attributes.center
size:attributes.size];
// If the active item is the last inserted item, it needs to be animated
// differently.
if (cell.itemIdentifier.tabSwitcherItem.identifier ==
self.lastInsertedItemID) {
activeItem.isAppearing = YES;
}
selectionItem = [LegacyGridTransitionItem
itemWithCell:[GridCell transitionSelectionCellFromCell:cell]
center:attributes.center];
} else {
UIView* cellSnapshot = [cell snapshotViewAfterScreenUpdates:YES];
LegacyGridTransitionItem* item =
[LegacyGridTransitionItem itemWithCell:cellSnapshot
center:attributes.center];
[items addObject:item];
}
}
return [LegacyGridTransitionLayout layoutWithInactiveItems:items
activeItem:activeItem
selectionItem:selectionItem];
}
- (TabGridTransitionItem*)transitionItemForActiveCell {
[self.collectionView layoutIfNeeded];
NSIndexPath* selectedItemIndexPath =
[self indexPathForTabIndex:self.selectedIndex];
if (![self.collectionView.indexPathsForVisibleItems
containsObject:selectedItemIndexPath]) {
return nil;
}
UICollectionViewCell* collectionViewCell =
[self.collectionView cellForItemAtIndexPath:selectedItemIndexPath];
if ([collectionViewCell isKindOfClass:[GroupGridCell class]]) {
// TODO(crbug.com/40942154): Handle once the annimations are available for
// group cells.
return nil;
}
GridCell* cell = ObjCCastStrict<GridCell>(collectionViewCell);
UICollectionViewLayoutAttributes* attributes = [self.collectionView
layoutAttributesForItemAtIndexPath:selectedItemIndexPath];
// Removes the cell header height from the orignal frame.
CGRect attributesFrame = attributes.frame;
attributesFrame.origin.y += kGridCellHeaderHeight;
attributesFrame.size.height -= kGridCellHeaderHeight;
// Normalize frame to window coordinates. The attributes class applies this
// change to the other properties such as center, bounds, etc.
CGRect frameInWindow = [self.collectionView convertRect:attributesFrame
toView:nil];
return [TabGridTransitionItem itemWithView:cell originalFrame:frameInWindow];
}
- (void)contentWillAppearAnimated:(BOOL)animated {
self.gridLayout.animatesItemUpdates = YES;
// Selection is invalid if there are no items.
if ([self shouldShowEmptyState]) {
[self animateEmptyStateIn];
return;
}
[self updateSelectedCollectionViewItemRingAndBringIntoView:YES];
[self removeEmptyStateAnimated:NO];
self.lastInsertedItemID = web::WebStateID();
}
- (void)prepareForDismissal {
// Stop animating the collection view to prevent the insertion animation from
// interfering with the tab presentation animation.
self.gridLayout.animatesItemUpdates = NO;
}
- (void)centerVisibleCellsToPoint:(CGPoint)center
translationCompletion:(CGFloat)translationCompletion
withScale:(CGFloat)scale {
// Make sure to layout the collection view to ensure that the correct cells
// are displayed.
[self.collectionView layoutIfNeeded];
for (UIView* cell in self.collectionView.visibleCells) {
CGPoint transformedOrigin = [self.collectionView convertPoint:center
fromView:self.view];
CGFloat dX =
(transformedOrigin.x - cell.center.x) * (1 - translationCompletion);
CGFloat dY =
(transformedOrigin.y - cell.center.y) * (1 - translationCompletion);
CGAffineTransform transform = CGAffineTransformMakeTranslation(dX, dY);
transform = CGAffineTransformScale(transform, scale, scale);
cell.transform = transform;
}
}
- (void)resetVisibleCellsCenterAndScale {
for (UIView* cell in self.collectionView.visibleCells) {
cell.transform = CGAffineTransformIdentity;
}
}
#pragma mark - UICollectionView Diffable Data Source Helpers
// Configures the grid header for the given section.
- (void)configureGridHeader:(GridHeader*)gridHeader
forSectionIdentifier:(NSString*)sectionIdentifier {
if ([sectionIdentifier isEqualToString:kGridOpenTabsSectionIdentifier]) {
gridHeader.title = l10n_util::GetNSString(
IDS_IOS_TABS_SEARCH_OPEN_TABS_SECTION_HEADER_TITLE);
NSString* resultsCount = [@([self numberOfTabs]) stringValue];
gridHeader.value =
l10n_util::GetNSStringF(IDS_IOS_TABS_SEARCH_OPEN_TABS_COUNT,
base::SysNSStringToUTF16(resultsCount));
} else if ([sectionIdentifier
isEqualToString:kSuggestedActionsSectionIdentifier]) {
gridHeader.title =
l10n_util::GetNSString(IDS_IOS_TABS_SEARCH_SUGGESTED_ACTIONS);
}
}
#pragma mark - UICollectionViewDelegate
// Selection events will be signaled through the model layer and handled in
// the TabCollectionConsumer -selectItemWithID: method.
- (BOOL)collectionView:(UICollectionView*)collectionView
shouldSelectItemAtIndexPath:(NSIndexPath*)indexPath {
return NO;
}
// Selection events will be signaled through the model layer and handled in
// the TabCollectionConsumer -selectItemWithID: method.
- (BOOL)collectionView:(UICollectionView*)collectionView
shouldDeselectItemAtIndexPath:(NSIndexPath*)indexPath {
return NO;
}
- (void)collectionView:(UICollectionView*)collectionView
performPrimaryActionForItemAtIndexPath:(NSIndexPath*)indexPath {
[self tappedItemAtIndexPath:indexPath];
}
- (UIContextMenuConfiguration*)collectionView:(UICollectionView*)collectionView
contextMenuConfigurationForItemAtIndexPath:(NSIndexPath*)indexPath
point:(CGPoint)point {
// Context menu shouldn't appear in the selection mode.
if (_mode == TabGridMode::kSelection) {
return nil;
}
NSString* sectionIdentifier =
[self.diffableDataSource sectionIdentifierForIndex:indexPath.section];
// No context menu on suggested actions or inactive tabs section.
if ([sectionIdentifier isEqualToString:kSuggestedActionsSectionIdentifier] ||
[sectionIdentifier isEqualToString:kInactiveTabButtonSectionIdentifier]) {
return nil;
}
[self.delegate gridViewControllerDidRequestContextMenu:self];
UICollectionViewCell* collectionViewCell =
[self.collectionView cellForItemAtIndexPath:indexPath];
// GroupGridCell case.
if ([collectionViewCell isKindOfClass:[GroupGridCell class]]) {
return [self.menuProvider
contextMenuConfigurationForTabGroupCell:ObjCCastStrict<GroupGridCell>(
collectionViewCell)
menuScenario:
kMenuScenarioHistogramTabGroupGridEntry];
}
// GridCell case.
GridCell* cell = ObjCCastStrict<GridCell>(collectionViewCell);
MenuScenarioHistogram scenario = [self scenarioForContextMenu];
return [self.menuProvider contextMenuConfigurationForTabCell:cell
menuScenario:scenario];
}
- (void)collectionView:(UICollectionView*)collectionView
didEndDisplayingCell:(UICollectionViewCell*)cell
forItemAtIndexPath:(NSIndexPath*)indexPath {
if ([cell isKindOfClass:[GridCell class]]) {
// Stop animation of GridCells when removing them from the collection view.
// This is important to prevent cells from animating indefinitely. This is
// safe because the animation state of GridCells is set in
// `configureCell:withItem:atIndex:` whenever a cell is used.
[ObjCCastStrict<GridCell>(cell) hideActivityIndicator];
}
}
#pragma mark - UIPointerInteractionDelegate
- (UIPointerRegion*)pointerInteraction:(UIPointerInteraction*)interaction
regionForRequest:(UIPointerRegionRequest*)request
defaultRegion:(UIPointerRegion*)defaultRegion {
return defaultRegion;
}
- (UIPointerStyle*)pointerInteraction:(UIPointerInteraction*)interaction
styleForRegion:(UIPointerRegion*)region {
UIPointerLiftEffect* effect = [UIPointerLiftEffect
effectWithPreview:[[UITargetedPreview alloc]
initWithView:interaction.view]];
return [UIPointerStyle styleWithEffect:effect shape:nil];
}
#pragma mark - UICollectionViewDragDelegate
- (void)collectionView:(UICollectionView*)collectionView
dragSessionWillBegin:(id<UIDragSession>)session {
self.dragEndAtNewIndex = NO;
self.localDragActionInProgress = YES;
if (!_draggedItemIdentifier) {
CHECK_EQ(_mode, TabGridMode::kSelection);
base::UmaHistogramEnumeration(kUmaGridViewDragDropMultiSelectEvent,
DragDropItem::kDragBegin);
[self.delegate gridViewControllerDragSessionWillBeginForTab:self];
return;
}
switch (_draggedItemIdentifier.type) {
case GridItemType::kInactiveTabsButton:
NOTREACHED();
case GridItemType::kTab: {
base::UmaHistogramEnumeration(kUmaGridViewDragDropTabsEvent,
DragDropItem::kDragBegin);
[self.delegate gridViewControllerDragSessionWillBeginForTab:self];
break;
}
case GridItemType::kGroup: {
base::UmaHistogramEnumeration(kUmaGridViewDragDropGroupsEvent,
DragDropItem::kDragBegin);
[self.delegate gridViewControllerDragSessionWillBeginForTabGroup:self];
break;
}
case GridItemType::kSuggestedActions:
NOTREACHED();
}
}
- (void)collectionView:(UICollectionView*)collectionView
dragSessionDidEnd:(id<UIDragSession>)session {
self.localDragActionInProgress = NO;
DragDropItem dragEvent = self.dragEndAtNewIndex
? DragDropItem::kDragEndAtNewIndex
: DragDropItem::kDragEndAtSameIndex;
// If a drop animation is in progress and the drag didn't end at a new index,
// that means the item has been dropped outside of its collection view.
if (_dropAnimationInProgress && !_dragEndAtNewIndex) {
dragEvent = DragDropItem::kDragEndInOtherCollection;
}
if (_draggedItemIdentifier) {
switch (_draggedItemIdentifier.type) {
case GridItemType::kInactiveTabsButton:
NOTREACHED();
case GridItemType::kTab:
base::UmaHistogramEnumeration(kUmaGridViewDragDropTabsEvent, dragEvent);
break;
case GridItemType::kGroup:
base::UmaHistogramEnumeration(kUmaGridViewDragDropGroupsEvent,
dragEvent);
break;
case GridItemType::kSuggestedActions:
NOTREACHED();
}
} else {
CHECK_EQ(_mode, TabGridMode::kSelection);
base::UmaHistogramEnumeration(kUmaGridViewDragDropMultiSelectEvent,
dragEvent);
}
// Used to let the Taptic Engine return to its idle state.
// To preserve power, the Taptic Engine remains in a prepared state for only a
// short period of time (on the order of seconds). If for some reason the
// interactive move / reordering session is not completely finished, the
// unfinished `UIFeedbackGenerator` may result in a crash.
[self.collectionView endInteractiveMovement];
[self.delegate gridViewControllerDragSessionDidEnd:self];
[self.dragDropHandler dragSessionDidEnd];
}
- (NSArray<UIDragItem*>*)collectionView:(UICollectionView*)collectionView
itemsForBeginningDragSession:(id<UIDragSession>)session
atIndexPath:(NSIndexPath*)indexPath {
if (self.dragDropHandler == nil) {
// Don't support dragging items if the drag&drop handler is not set.
return @[];
}
if (_mode == TabGridMode::kSearch) {
// TODO(crbug.com/40824160): Enable dragging items from search results.
return @[];
}
NSString* sectionIdentifier =
[self.diffableDataSource sectionIdentifierForIndex:indexPath.section];
if ([sectionIdentifier isEqualToString:kSuggestedActionsSectionIdentifier] ||
[sectionIdentifier isEqualToString:kInactiveTabButtonSectionIdentifier]) {
// Return an empty array because the suggested actions cell or the inactive
// tabs button should not be dragged.
return @[];
}
GridItemIdentifier* draggedItem =
[self.diffableDataSource itemIdentifierForIndexPath:indexPath];
if (_mode != TabGridMode::kSelection) {
UIDragItem* dragItem;
_draggedItemIdentifier = draggedItem;
switch (_draggedItemIdentifier.type) {
case GridItemType::kInactiveTabsButton:
NOTREACHED();
case GridItemType::kTab:
dragItem = [self.dragDropHandler
dragItemForItem:_draggedItemIdentifier.tabSwitcherItem];
break;
case GridItemType::kGroup:
dragItem = [self.dragDropHandler
dragItemForTabGroupItem:_draggedItemIdentifier.tabGroupItem];
break;
case GridItemType::kSuggestedActions:
NOTREACHED();
}
if (!dragItem) {
return @[];
}
return @[ dragItem ];
}
// Make sure that the long pressed cell is selected before initiating a drag
// from it.
[self.mutator addToSelectionItemID:draggedItem];
[self reconfigureItem:draggedItem];
return [self.dragDropHandler allSelectedDragItems];
}
- (NSArray<UIDragItem*>*)collectionView:(UICollectionView*)collectionView
itemsForAddingToDragSession:(id<UIDragSession>)session
atIndexPath:(NSIndexPath*)indexPath
point:(CGPoint)point {
// TODO(crbug.com/40695113): Allow multi-select.
// Prevent more items from getting added to the drag session.
return @[];
}
- (UIDragPreviewParameters*)collectionView:(UICollectionView*)collectionView
dragPreviewParametersForItemAtIndexPath:(NSIndexPath*)indexPath {
if (indexPath.section ==
[self.diffableDataSource
indexForSectionIdentifier:kSuggestedActionsSectionIdentifier]) {
// Return nil so that the suggested actions cell doesn't superpose the
// dragged cell.
return nil;
}
UICollectionViewCell* collectionViewCell =
[self.collectionView cellForItemAtIndexPath:indexPath];
if ([collectionViewCell isKindOfClass:[GroupGridCell class]]) {
return nil;
}
GridCell* gridCell = ObjCCastStrict<GridCell>(collectionViewCell);
return gridCell.dragPreviewParameters;
}
#pragma mark - UICollectionViewDropDelegate
- (BOOL)collectionView:(UICollectionView*)collectionView
canHandleDropSession:(id<UIDropSession>)session {
if (self.dragDropHandler == nil) {
// Don't support dropping items if the drag&drop handler is not set.
return NO;
}
// Prevent dropping tabs into grid while displaying search results.
return (_mode != TabGridMode::kSearch);
}
- (UICollectionViewDropProposal*)
collectionView:(UICollectionView*)collectionView
dropSessionDidUpdate:(id<UIDropSession>)session
withDestinationIndexPath:(NSIndexPath*)destinationIndexPath {
if ([[self.diffableDataSource
sectionIdentifierForIndex:destinationIndexPath.section]
isEqualToString:kInactiveTabButtonSectionIdentifier]) {
// Disallow dropping in the inactive tab section.
return [[UICollectionViewDropProposal alloc]
initWithDropOperation:UIDropOperationForbidden
intent:UICollectionViewDropIntentUnspecified];
}
// This is how the explicit forbidden icon or (+) copy icon is shown. Move has
// no explicit icon.
UIDropOperation dropOperation = [self.dragDropHandler
dropOperationForDropSession:session
toIndex:destinationIndexPath.item];
return [[UICollectionViewDropProposal alloc]
initWithDropOperation:dropOperation
intent:
UICollectionViewDropIntentInsertAtDestinationIndexPath];
}
- (void)collectionView:(UICollectionView*)collectionView
performDropWithCoordinator:
(id<UICollectionViewDropCoordinator>)coordinator {
NSArray<id<UICollectionViewDropItem>>* items = coordinator.items;
for (id<UICollectionViewDropItem> item in items) {
// Append to the end of the collection, unless drop index is specified.
// The sourceIndexPath is nil if the drop item is not from the same
// collection view. Set the destinationIndex to reflect the addition of an
// item.
NSInteger numberOfTabs = [self numberOfTabs];
NSUInteger destinationIndex =
item.sourceIndexPath ? numberOfTabs - 1 : numberOfTabs;
if (coordinator.destinationIndexPath) {
destinationIndex =
base::checked_cast<NSUInteger>(coordinator.destinationIndexPath.item);
}
self.dragEndAtNewIndex = YES;
NSIndexPath* dropIndexPath = [self indexPathForTabIndex:destinationIndex];
// Drop synchronously if local object is available.
if (item.dragItem.localObject) {
_dropAnimationInProgress = YES;
[self.delegate gridViewControllerDropAnimationWillBegin:self];
__weak __typeof(self) weakSelf = self;
[[coordinator dropItem:item.dragItem toItemAtIndexPath:dropIndexPath]
addCompletion:^(UIViewAnimatingPosition finalPosition) {
[weakSelf.delegate gridViewControllerDropAnimationDidEnd:weakSelf];
weakSelf.dropAnimationInProgress = NO;
}];
// The sourceIndexPath is non-nil if the drop item is from this same
// collection view.
[self.dragDropHandler dropItem:item.dragItem
toIndex:destinationIndex
fromSameCollection:(item.sourceIndexPath != nil)];
} else {
// Drop asynchronously if local object is not available.
UICollectionViewDropPlaceholder* placeholder =
[[UICollectionViewDropPlaceholder alloc]
initWithInsertionIndexPath:dropIndexPath
reuseIdentifier:kCellIdentifier];
placeholder.cellUpdateHandler = ^(UICollectionViewCell* placeholderCell) {
GridCell* gridCell = ObjCCastStrict<GridCell>(placeholderCell);
gridCell.theme = self.theme;
};
placeholder.previewParametersProvider =
^UIDragPreviewParameters*(UICollectionViewCell* placeholderCell) {
GridCell* gridCell = ObjCCastStrict<GridCell>(placeholderCell);
return gridCell.dragPreviewParameters;
};
id<UICollectionViewDropPlaceholderContext> context =
[coordinator dropItem:item.dragItem toPlaceholder:placeholder];
[self.dragDropHandler dropItemFromProvider:item.dragItem.itemProvider
toIndex:destinationIndex
placeholderContext:context];
}
}
[self.delegate gridViewControllerDragSessionDidEnd:self];
}
- (void)collectionView:(UICollectionView*)collectionView
dropSessionDidEnter:(id<UIDropSession>)session {
if (IsPinnedTabsEnabled()) {
if (_draggedItemIdentifier &&
_draggedItemIdentifier.type == GridItemType::kGroup) {
// Don't notify the delegate if the dragged item is a local tab group.
return;
}
// Notify the delegate that a drag cames from another app.
[self.delegate gridViewControllerDragSessionWillBeginForTab:self];
}
}
- (void)collectionView:(UICollectionView*)collectionView
dropSessionDidEnd:(id<UIDropSession>)session {
if (IsPinnedTabsEnabled()) {
// Notify the delegate that a drag ends from another app.
[self.delegate gridViewControllerDropAnimationDidEnd:self];
}
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
[self.delegate gridViewControllerScrollViewDidScroll:self];
}
- (void)scrollViewWillBeginDragging:(UIScrollView*)scrollView {
base::RecordAction(base::UserMetricsAction("MobileTabGridUserScrolled"));
_scopedScrollingTimeLogger = ScopedScrollingTimeLogger();
}
- (void)scrollViewDidEndDragging:(UIScrollView*)scrollView
willDecelerate:(BOOL)decelerate {
if (!decelerate) {
_scopedScrollingTimeLogger.reset();
}
}
- (void)scrollViewDidEndDecelerating:(UIScrollView*)scrollView {
_scopedScrollingTimeLogger.reset();
}
- (void)scrollViewDidScrollToTop:(UIScrollView*)scrollView {
base::RecordAction(base::UserMetricsAction("MobileTabGridUserScrolledToTop"));
}
- (void)scrollViewDidChangeAdjustedContentInset:(UIScrollView*)scrollView {
self.emptyStateView.scrollViewContentInsets = scrollView.contentInset;
}
#pragma mark - GridCellDelegate
- (void)closeButtonTappedForCell:(GridCell*)cell {
// Record when a tab is closed via the X.
base::RecordAction(
base::UserMetricsAction("MobileTabGridCloseControlTapped"));
if (_mode == TabGridMode::kSearch) {
base::RecordAction(
base::UserMetricsAction("MobileTabGridCloseControlTappedDuringSearch"));
}
[self.mutator closeItemWithIdentifier:cell.itemIdentifier];
}
#pragma mark - GroupGridCellDelegate
- (void)closeButtonTappedForGroupCell:(GroupGridCell*)cell {
base::RecordAction(
base::UserMetricsAction("MobileTabGridCloseTabGroupControlTapped"));
[self.mutator closeItemWithIdentifier:cell.itemIdentifier];
}
#pragma mark - SuggestedActionsViewControllerDelegate
- (void)suggestedActionsViewController:
(SuggestedActionsViewController*)viewController
fetchHistoryResultsCountWithCompletion:(void (^)(size_t))completion {
[self.suggestedActionsDelegate
fetchSearchHistoryResultsCountForText:self.searchText
completion:completion];
}
- (void)didSelectSearchHistoryInSuggestedActionsViewController:
(SuggestedActionsViewController*)viewController {
base::RecordAction(
base::UserMetricsAction("TabsSearch.SuggestedActions.SearchHistory"));
[self.tabGridHandler showHistoryForText:self.searchText];
}
- (void)didSelectSearchRecentTabsInSuggestedActionsViewController:
(SuggestedActionsViewController*)viewController {
base::RecordAction(
base::UserMetricsAction("TabsSearch.SuggestedActions.RecentTabs"));
[self.tabGridHandler showRecentTabsForText:self.searchText];
}
- (void)didSelectSearchWebInSuggestedActionsViewController:
(SuggestedActionsViewController*)viewController {
base::RecordAction(
base::UserMetricsAction("TabsSearch.SuggestedActions.SearchOnWeb"));
[self.tabGridHandler showWebSearchForText:self.searchText];
}
#pragma mark - TabCollectionConsumer
- (void)populateItems:(NSArray<GridItemIdentifier*>*)items
selectedItemIdentifier:(GridItemIdentifier*)selectedItemIdentifier {
CHECK(!HasDuplicateGroupsAndTabsIdentifiers(items));
// Call self.view to ensure that the collection view is created.
[self view];
CHECK(self.diffableDataSource);
self.selectedItemIdentifier = selectedItemIdentifier;
GridSnapshot* snapshot = [[GridSnapshot alloc] init];
// Open Tabs section.
[snapshot appendSectionsWithIdentifiers:@[ kGridOpenTabsSectionIdentifier ]];
[snapshot appendItemsWithIdentifiers:items];
// Give subclasses the opportunity to contribute to the snapshot.
[self addAdditionalItemsToSnapshot:snapshot];
// Optional Suggested Actions section.
if (self.showingSuggestedActions) {
[snapshot
appendSectionsWithIdentifiers:@[ kSuggestedActionsSectionIdentifier ]];
GridItemIdentifier* itemIdentifier =
[GridItemIdentifier suggestedActionsIdentifier];
[snapshot appendItemsWithIdentifiers:@[ itemIdentifier ]];
}
[snapshot reconfigureItemsWithIdentifiers:items];
[self.diffableDataSource applySnapshot:snapshot
animatingDifferences:YES
completion:nil];
[self updateSelectedCollectionViewItemRingAndBringIntoView:NO];
[self updateVisibleCellIdentifiers];
if ([self shouldShowEmptyState]) {
[self animateEmptyStateIn];
} else {
[self removeEmptyStateAnimated:YES];
}
if (_mode == TabGridMode::kSearch) {
if (_searchText.length) {
[self updateSearchResultsHeader];
}
[self.collectionView
setContentOffset:CGPointMake(
-self.collectionView.adjustedContentInset.left,
-self.collectionView.adjustedContentInset.top)
animated:NO];
}
}
- (void)insertItem:(GridItemIdentifier*)item
beforeItemID:(GridItemIdentifier*)nextItemIdentifier
selectedItemIdentifier:(GridItemIdentifier*)selectedItemIdentifier {
if (_mode == TabGridMode::kSearch) {
// Prevent inserting items while viewing search results.
return;
}
__weak __typeof(self) weakSelf = self;
[self
performModelAndViewUpdates:^(GridSnapshot* snapshot) {
[weakSelf
applyModelAndViewUpdatesForInsertionOfItem:item
beforeItemID:nextItemIdentifier
selectedItemIdentifier:selectedItemIdentifier
snapshot:snapshot];
}
completion:^{
[weakSelf
modelAndViewUpdatesForInsertionDidCompleteForItemIdentifier:item];
}];
}
- (void)removeItemWithIdentifier:(GridItemIdentifier*)removedItem
selectedItemIdentifier:(GridItemIdentifier*)selectedItemIdentifier {
NSIndexPath* removedItemIndexPath =
[self.diffableDataSource indexPathForItemIdentifier:removedItem];
// Do not remove if not showing the item (i.e. showing search results).
if (!removedItemIndexPath) {
[self selectItemWithIdentifier:selectedItemIdentifier];
return;
}
__weak __typeof(self) weakSelf = self;
[self
performModelAndViewUpdates:^(GridSnapshot* snapshot) {
[weakSelf applyModelAndViewUpdatesForRemovalOfItemWithID:removedItem
selectedItemIdentifier:
selectedItemIdentifier
snapshot:snapshot];
}
completion:^{
[weakSelf modelAndViewUpdatesForRemovalDidCompleteForItemWithID:
removedItem.tabSwitcherItem.identifier];
}];
if (_mode == TabGridMode::kSearch && _searchText.length) {
[self updateSearchResultsHeader];
}
}
- (void)selectItemWithIdentifier:(GridItemIdentifier*)selectedItemIdentifier {
if ([self.selectedItemIdentifier isEqual:selectedItemIdentifier]) {
return;
}
self.selectedItemIdentifier = selectedItemIdentifier;
[self updateSelectedCollectionViewItemRingAndBringIntoView:NO];
}
- (void)replaceItem:(GridItemIdentifier*)item
withReplacementItem:(GridItemIdentifier*)replacementItem {
CHECK((item.type != GridItemType::kSuggestedActions) &&
(replacementItem.type != GridItemType::kSuggestedActions));
NSIndexPath* existingItemIndexPath =
[self.diffableDataSource indexPathForItemIdentifier:item];
if (!existingItemIndexPath) {
return;
}
BOOL replacementItemIsEqualToItem = [replacementItem isEqual:item];
// Consistency check: `replacementItem` is either equal to item or not in the
// collection view.
CHECK(replacementItemIsEqualToItem ||
![self.diffableDataSource indexPathForItemIdentifier:replacementItem]);
GridSnapshot* snapshot = self.diffableDataSource.snapshot;
if (replacementItemIsEqualToItem) {
[snapshot reconfigureItemsWithIdentifiers:@[ item ]];
} else {
// Add the new item before the existing item.
[snapshot insertItemsWithIdentifiers:@[ replacementItem ]
beforeItemWithIdentifier:item];
[snapshot deleteItemsWithIdentifiers:@[ item ]];
}
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
}
- (void)moveItem:(GridItemIdentifier*)item
beforeItem:(GridItemIdentifier*)nextItemIdentifier {
if (_mode == TabGridMode::kSearch) {
// Prevent moving items while viewing search results.
return;
}
if (!item) {
return;
}
__weak __typeof(self) weakSelf = self;
[self
performModelAndViewUpdates:^(GridSnapshot* snapshot) {
[weakSelf applyModelAndViewUpdatesForMoveOfItem:item
beforeItem:nextItemIdentifier
snapshot:snapshot];
}
completion:^{
[weakSelf modelAndViewUpdatesForMoveDidComplete];
}];
}
- (void)bringItemIntoView:(GridItemIdentifier*)item animated:(BOOL)animated {
NSIndexPath* indexPath =
[self.diffableDataSource indexPathForItemIdentifier:item];
[self.collectionView
scrollToItemAtIndexPath:indexPath
atScrollPosition:UICollectionViewScrollPositionCenteredVertically
animated:animated];
}
- (void)dismissModals {
ios::provider::DismissModalsForCollectionView(self.collectionView);
}
- (void)reload {
[self.collectionView reloadData];
}
- (void)willCloseAll {
self.isClosingAllOrUndoRunning = YES;
}
- (void)didCloseAll {
self.isClosingAllOrUndoRunning = NO;
[self updateTabsSectionHeaderType];
[self.collectionView.collectionViewLayout invalidateLayout];
}
- (void)willUndoCloseAll {
self.isClosingAllOrUndoRunning = YES;
}
- (void)didUndoCloseAll {
self.isClosingAllOrUndoRunning = NO;
[self updateTabsSectionHeaderType];
[self.collectionView.collectionViewLayout invalidateLayout];
}
#pragma mark - Suggested Actions Section
- (void)updateSuggestedActionsSection {
if (!self.suggestedActionsDelegate) {
return;
}
// In search mode if there is already a search query, and the suggested
// actions section is not yet added, add it. Otherwise remove the section if
// it exists and the search mode is not active.
GridSnapshot* snapshot = self.diffableDataSource.snapshot;
if (self.mode == TabGridMode::kSearch && self.searchText.length) {
if (!self.showingSuggestedActions) {
[snapshot appendSectionsWithIdentifiers:@[
kSuggestedActionsSectionIdentifier
]];
GridItemIdentifier* itemIdentifier =
[GridItemIdentifier suggestedActionsIdentifier];
[snapshot appendItemsWithIdentifiers:@[ itemIdentifier ]];
self.showingSuggestedActions = YES;
}
} else {
if (self.showingSuggestedActions) {
[snapshot deleteSectionsWithIdentifiers:@[
kSuggestedActionsSectionIdentifier
]];
self.showingSuggestedActions = NO;
}
}
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
}
#pragma mark - Private helpers for joint model and view updates
// Performs model and view updates together.
- (void)performModelAndViewUpdates:
(void (^)(GridSnapshot* snapshot))modelAndViewUpdates
completion:(ProceduralBlock)completion {
self.updating = YES;
// Synchronize model and diffable snapshot updates.
GridSnapshot* snapshot = self.diffableDataSource.snapshot;
modelAndViewUpdates(snapshot);
__weak __typeof(self) weakSelf = self;
[self.diffableDataSource applySnapshot:snapshot
animatingDifferences:YES
completion:^{
if (weakSelf) {
completion();
weakSelf.updating = NO;
}
}];
if ([self shouldShowEmptyState]) {
[self animateEmptyStateIn];
} else {
[self removeEmptyStateAnimated:YES];
}
[self updateVisibleCellIdentifiers];
}
// Makes the required changes to the data source when a new item is inserted
// before the given `nextItemIdentifier`. If `nextItemIdentifier` is nil,
// `item` is append at the end of the section.
- (void)applyModelAndViewUpdatesForInsertionOfItem:(GridItemIdentifier*)item
beforeItemID:(GridItemIdentifier*)
nextItemIdentifier
selectedItemIdentifier:
(GridItemIdentifier*)selectedItemIdentifier
snapshot:(GridSnapshot*)snapshot {
CHECK(item.type == GridItemType::kTab || item.type == GridItemType::kGroup);
// TODO(crbug.com/40069795): There are crash reports that show there could be
// cases where the open tabs section is not present in the snapshot. If so,
// don't perform the update.
NSInteger section =
[snapshot indexOfSectionIdentifier:kGridOpenTabsSectionIdentifier];
DUMP_WILL_BE_CHECK(section != NSNotFound)
<< base::SysNSStringToUTF8([snapshot description]);
if (section == NSNotFound) {
return;
}
// Consistency check: `item`'s ID is not in the collection view.
CHECK(![self.diffableDataSource indexPathForItemIdentifier:item]);
self.selectedItemIdentifier = selectedItemIdentifier;
if (item.type == GridItemType::kTab) {
self.lastInsertedItemID = item.tabSwitcherItem.identifier;
} else if (item.type == GridItemType::kGroup) {
self.lastInsertedItemID = web::WebStateID();
}
if (nextItemIdentifier) {
[snapshot insertItemsWithIdentifiers:@[ item ]
beforeItemWithIdentifier:nextItemIdentifier];
} else {
[snapshot appendItemsWithIdentifiers:@[ item ]
intoSectionWithIdentifier:kGridOpenTabsSectionIdentifier];
}
}
// Makes the required changes when a new item has been inserted.
- (void)modelAndViewUpdatesForInsertionDidCompleteForItemIdentifier:
(GridItemIdentifier*)item {
[self updateSelectedCollectionViewItemRingAndBringIntoView:NO];
}
// Makes the required changes to the data source when an existing item is
// removed.
- (void)applyModelAndViewUpdatesForRemovalOfItemWithID:
(GridItemIdentifier*)removedItemIdentifier
selectedItemIdentifier:
(GridItemIdentifier*)selectedItemIdentifier
snapshot:(GridSnapshot*)snapshot {
self.selectedItemIdentifier = selectedItemIdentifier;
[self.mutator removeFromSelectionItemID:removedItemIdentifier];
[snapshot deleteItemsWithIdentifiers:@[ removedItemIdentifier ]];
}
// Makes the required changes when a new item has been removed.
- (void)modelAndViewUpdatesForRemovalDidCompleteForItemWithID:
(web::WebStateID)removedItemID {
NSInteger numberOfTabs = [self numberOfTabs];
if (numberOfTabs > 0) {
[self updateSelectedCollectionViewItemRingAndBringIntoView:NO];
}
[self.delegate gridViewController:self didRemoveItemWIthID:removedItemID];
}
// Makes the required changes to the data source when an existing item is moved.
- (void)applyModelAndViewUpdatesForMoveOfItem:(GridItemIdentifier*)item
beforeItem:
(GridItemIdentifier*)nextItemIdentifier
snapshot:(GridSnapshot*)snapshot {
if (nextItemIdentifier) {
[snapshot moveItemWithIdentifier:item
beforeItemWithIdentifier:nextItemIdentifier];
} else {
NSInteger section = [self.diffableDataSource
indexForSectionIdentifier:kGridOpenTabsSectionIdentifier];
NSIndexPath* lastIndexPath =
[NSIndexPath indexPathForItem:[self numberOfTabs] - 1
inSection:section];
GridItemIdentifier* lastItem =
[self.diffableDataSource itemIdentifierForIndexPath:lastIndexPath];
if (lastItem == item) {
return;
}
// If the moved item was pinned, it does not belong to the collection view
// yet.
if ([snapshot indexOfItemIdentifier:item] == NSNotFound) {
[snapshot insertItemsWithIdentifiers:@[ item ]
afterItemWithIdentifier:lastItem];
} else {
[snapshot moveItemWithIdentifier:item afterItemWithIdentifier:lastItem];
}
}
}
// Makes the required changes when an item has been moved.
- (void)modelAndViewUpdatesForMoveDidComplete {
[self.delegate gridViewControllerDidMoveItem:self];
}
#pragma mark - Private properties
- (NSUInteger)selectedIndex {
if (!self.selectedItemIdentifier) {
return NSNotFound;
}
NSIndexPath* selectedIndexPath = [self.diffableDataSource
indexPathForItemIdentifier:self.selectedItemIdentifier];
if (selectedIndexPath) {
return selectedIndexPath.item;
}
return NSNotFound;
}
#pragma mark - Protected
- (TabGridMode)mode {
return _mode;
}
- (void)createRegistrations {
__weak __typeof(self) weakSelf = self;
// Register GridCell.
auto configureGridCell =
^(GridCell* cell, NSIndexPath* indexPath, TabSwitcherItem* item) {
[weakSelf configureCell:cell withItem:item atIndex:indexPath.item];
};
_gridCellRegistration = [UICollectionViewCellRegistration
registrationWithCellClass:GridCell.class
configurationHandler:configureGridCell];
// Register GroupGridCell.
auto configureGroupGridCell =
^(GroupGridCell* cell, NSIndexPath* indexPath, TabGroupItem* item) {
[weakSelf configureGroupCell:cell withItem:item atIndex:indexPath.item];
};
_groupGridCellRegistration = [UICollectionViewCellRegistration
registrationWithCellClass:GroupGridCell.class
configurationHandler:configureGroupGridCell];
// Register SuggestedActionsGridCell.
auto configureSuggestedActionsCell =
^(SuggestedActionsGridCell* suggestedActionsCell, NSIndexPath* indexPath,
id item) {
CHECK(weakSelf.suggestedActionsViewController);
suggestedActionsCell.suggestedActionsView =
weakSelf.suggestedActionsViewController.view;
};
_suggestedActionsCellRegistration = [UICollectionViewCellRegistration
registrationWithCellClass:SuggestedActionsGridCell.class
configurationHandler:configureSuggestedActionsCell];
// Register GridHeader.
auto configureGridHeader =
^(GridHeader* gridHeader, NSString* elementKind, NSIndexPath* indexPath) {
NSString* sectionIdentifier = [weakSelf.diffableDataSource
sectionIdentifierForIndex:indexPath.section];
[weakSelf configureGridHeader:gridHeader
forSectionIdentifier:sectionIdentifier];
};
_gridHeaderRegistration = [UICollectionViewSupplementaryRegistration
registrationWithSupplementaryClass:[GridHeader class]
elementKind:UICollectionElementKindSectionHeader
configurationHandler:configureGridHeader];
}
- (UICollectionReusableView*)headerForSectionAtIndexPath:
(NSIndexPath*)indexPath {
UICollectionViewSupplementaryRegistration* registration;
switch (_mode) {
case TabGridMode::kNormal:
if (IsInactiveTabButtonRefactoringEnabled()) {
return nil;
} else {
NOTREACHED() << "Should be implemented in a subclass.";
}
case TabGridMode::kSelection:
NOTREACHED() << "Should not happen.";
case TabGridMode::kSearch:
registration = _gridHeaderRegistration;
break;
}
return [self.collectionView
dequeueConfiguredReusableSupplementaryViewWithRegistration:registration
forIndexPath:indexPath];
}
- (UICollectionViewCell*)cellForItemAtIndexPath:(NSIndexPath*)indexPath
itemIdentifier:
(GridItemIdentifier*)itemIdentifier {
switch (itemIdentifier.type) {
case GridItemType::kInactiveTabsButton:
// Must be handled in the subclasses.
NOTREACHED();
case GridItemType::kTab: {
UICollectionViewCellRegistration* registration = _gridCellRegistration;
return [self.collectionView
dequeueConfiguredReusableCellWithRegistration:registration
forIndexPath:indexPath
item:itemIdentifier
.tabSwitcherItem];
}
case GridItemType::kGroup: {
UICollectionViewCellRegistration* registration =
_groupGridCellRegistration;
return [self.collectionView
dequeueConfiguredReusableCellWithRegistration:registration
forIndexPath:indexPath
item:itemIdentifier
.tabGroupItem];
}
case GridItemType::kSuggestedActions:
UICollectionViewCellRegistration* registration =
_suggestedActionsCellRegistration;
return [self.collectionView
dequeueConfiguredReusableCellWithRegistration:registration
forIndexPath:indexPath
item:itemIdentifier];
}
}
- (void)updateSelectedCollectionViewItemRingAndBringIntoView:
(BOOL)shouldBringItemIntoView {
// Deselects all the collection view items.
NSArray<NSIndexPath*>* indexPathsForSelectedItems =
[self.collectionView indexPathsForSelectedItems];
for (NSIndexPath* itemIndexPath in indexPathsForSelectedItems) {
[self.collectionView deselectItemAtIndexPath:itemIndexPath animated:NO];
}
// Select the collection view item for the selected index.
NSInteger selectedIndex = self.selectedIndex;
CHECK(selectedIndex >= 0);
// Check `selectedIndex` boundaries in order to filter out possible race
// conditions while mutating the collection.
if (selectedIndex == NSNotFound ||
selectedIndex >= [self numberOfTabs]) {
return;
}
NSIndexPath* selectedIndexPath = [self indexPathForTabIndex:selectedIndex];
UICollectionViewScrollPosition scrollPosition =
shouldBringItemIntoView ? UICollectionViewScrollPositionTop
: UICollectionViewScrollPositionNone;
[self.collectionView selectItemAtIndexPath:selectedIndexPath
animated:NO
scrollPosition:scrollPosition];
}
- (void)updateTabsSectionHeaderType {
self.gridLayout.tabsSectionHeaderType =
[self tabsSectionHeaderTypeForMode:_mode];
[self.gridLayout invalidateLayout];
}
- (TabsSectionHeaderType)tabsSectionHeaderTypeForMode:(TabGridMode)mode {
switch (mode) {
case TabGridMode::kNormal:
case TabGridMode::kSelection:
return TabsSectionHeaderType::kNone;
case TabGridMode::kSearch:
if (_searchText.length == 0) {
return TabsSectionHeaderType::kNone;
}
return TabsSectionHeaderType::kSearch;
}
}
- (void)addAdditionalItemsToSnapshot:(GridSnapshot*)snapshot {
// Base class implementation is doing nothing.
}
- (void)updateSnapshotForModeUpdate:(GridSnapshot*)snapshot {
// Base class implementation is doing nothing.
}
- (MenuScenarioHistogram)scenarioForContextMenu {
switch (_mode) {
case TabGridMode::kSearch:
return kMenuScenarioHistogramTabGridSearchResult;
case TabGridMode::kNormal:
case TabGridMode::kSelection:
return kMenuScenarioHistogramTabGridEntry;
}
}
#pragma mark - Private
- (void)voiceOverStatusDidChange {
self.collectionView.dragInteractionEnabled =
[self shouldEnableDrapAndDropInteraction];
}
- (void)preferredContentSizeCategoryDidChange {
[self.collectionView.collectionViewLayout invalidateLayout];
}
// Returns YES if drag and drop is enabled.
// TODO(crbug.com/40824160): Enable dragging items from search results.
- (BOOL)shouldEnableDrapAndDropInteraction {
// Don't enable drag and drop when voice over is enabled.
return !UIAccessibilityIsVoiceOverRunning()
// Dragging multiple tabs to reorder them is not supported. So there is
// no need to enable dragging when multiple items are selected in
// devices that don't support multiple windows.
&& ((self.mode == TabGridMode::kSelection &&
base::ios::IsMultipleScenesSupported()) ||
self.mode == TabGridMode::kNormal);
}
// Configures `groupCell`'s identifier and title synchronously, and pass the
// list of `GroupTabInfo`asynchronously with information from `item`. Updates
// the `cell`'s theme to this view controller's theme. This view controller
// becomes the delegate for the cell.
- (void)configureGroupCell:(GroupGridCell*)cell
withItem:(TabGroupItem*)item
atIndex:(NSUInteger)index {
CHECK(cell);
CHECK(item);
GridItemIdentifier* groupItemIdentifier =
[[GridItemIdentifier alloc] initWithGroupItem:item];
cell.delegate = self;
cell.theme = self.theme;
cell.itemIdentifier = groupItemIdentifier;
cell.groupColor = item.groupColor;
cell.tabsCount = item.numberOfTabsInGroup;
cell.title = item.title;
cell.accessibilityIdentifier = GroupGridCellAccessibilityIdentifier(index);
if (self.mode == TabGridMode::kSelection) {
if ([self.gridProvider isItemSelected:groupItemIdentifier]) {
cell.state = GridCellStateEditingSelected;
} else {
cell.state = GridCellStateEditingUnselected;
}
} else {
cell.state = GridCellStateNotEditing;
}
[item fetchGroupTabInfos:^(TabGroupItem* innerItem,
NSArray<GroupTabInfo*>* groupTabInfos) {
if ([cell.itemIdentifier.tabGroupItem isEqual:innerItem]) {
[cell configureWithGroupTabInfos:groupTabInfos
totalTabsCount:innerItem.numberOfTabsInGroup];
}
}];
}
// Configures `cell`'s identifier and title synchronously, and favicon and
// snapshot asynchronously with information from `item`. Updates the `cell`'s
// theme to this view controller's theme. This view controller becomes the
// delegate for the cell.
- (void)configureCell:(GridCell*)cell
withItem:(TabSwitcherItem*)item
atIndex:(NSUInteger)index {
CHECK(cell);
CHECK(item);
GridItemIdentifier* itemIdentifier =
[[GridItemIdentifier alloc] initWithTabItem:item];
cell.delegate = self;
cell.theme = self.theme;
cell.itemIdentifier = itemIdentifier;
cell.title = item.title;
cell.titleHidden = item.hidesTitle;
cell.accessibilityIdentifier = GridCellAccessibilityIdentifier(index);
if (self.mode == TabGridMode::kSelection) {
if ([self.gridProvider isItemSelected:itemIdentifier]) {
cell.state = GridCellStateEditingSelected;
} else {
cell.state = GridCellStateEditingUnselected;
}
} else {
cell.state = GridCellStateNotEditing;
}
[item fetchFavicon:^(TabSwitcherItem* innerItem, UIImage* icon) {
// Only update the icon if the cell is not already reused for another item.
if ([cell.itemIdentifier.tabSwitcherItem isEqual:innerItem]) {
cell.icon = icon;
}
}];
[item fetchSnapshot:^(TabSwitcherItem* innerItem, UIImage* snapshot) {
// Only update the icon if the cell is not already reused for another item.
if ([cell.itemIdentifier.tabSwitcherItem isEqual:innerItem]) {
cell.snapshot = snapshot;
}
}];
web::WebStateID itemID = item.identifier;
[self.priceCardDataSource
priceCardForIdentifier:itemID
completion:^(PriceCardItem* priceCardItem) {
if (priceCardItem &&
cell.itemIdentifier.tabSwitcherItem.identifier ==
itemID) {
[cell setPriceDrop:priceCardItem.price
previousPrice:priceCardItem.previousPrice];
}
}];
cell.opacity = 1.0f;
if (item.showsActivity) {
[cell showActivityIndicator];
} else {
[cell hideActivityIndicator];
}
if (![self.pointerInteractionCells containsObject:cell]) {
[cell addInteraction:[[UIPointerInteraction alloc] initWithDelegate:self]];
// `self.pointerInteractionCells` is only expected to get as large as the
// number of reusable grid cells in memory.
[self.pointerInteractionCells addObject:cell];
}
if (ios::provider::IsRaccoonEnabled()) {
[self setHoverEffectToCell:cell];
}
}
// Tells the delegate that the user tapped the item with identifier
// corresponding to `indexPath`.
- (void)tappedItemAtIndexPath:(NSIndexPath*)indexPath {
// Speculative fix for crbug.com/1134663, where this method is called while
// updates from a tab insertion are processing.
// *** Do not add any code before this check. ***
if (self.updating) {
return;
}
NSString* sectionIdentifier =
[self.diffableDataSource sectionIdentifierForIndex:indexPath.section];
CHECK(
[sectionIdentifier isEqualToString:kInactiveTabButtonSectionIdentifier] ||
[sectionIdentifier isEqualToString:kGridOpenTabsSectionIdentifier]);
GridItemIdentifier* itemIdentifier =
[self.diffableDataSource itemIdentifierForIndexPath:indexPath];
CHECK(itemIdentifier.type == GridItemType::kInactiveTabsButton ||
itemIdentifier.type == GridItemType::kGroup ||
itemIdentifier.type == GridItemType::kTab);
[self.mutator userTappedOnItemID:itemIdentifier];
if (_mode == TabGridMode::kSelection) {
// Reconfigure the item.
[self reconfigureItem:itemIdentifier];
}
switch (itemIdentifier.type) {
case GridItemType::kInactiveTabsButton: {
CHECK(IsInactiveTabButtonRefactoringEnabled());
[self.delegate didTapInactiveTabsButtonInGridViewController:self];
break;
}
case GridItemType::kTab: {
web::WebStateID itemID = itemIdentifier.tabSwitcherItem.identifier;
[self.delegate gridViewController:self didSelectItemWithID:itemID];
break;
}
case GridItemType::kGroup: {
base::RecordAction(
base::UserMetricsAction("MobileTabGridTabGroupCellTapped"));
const TabGroup* group = itemIdentifier.tabGroupItem.tabGroup;
[self.delegate gridViewController:self didSelectGroup:group];
break;
}
case GridItemType::kSuggestedActions:
NOTREACHED();
}
}
// Animates the empty state into view.
- (void)animateEmptyStateIn {
// TODO(crbug.com/40566436) : Polish the animation, and put constants where
// they belong.
[self.emptyStateAnimator stopAnimation:YES];
self.emptyStateAnimator = [[UIViewPropertyAnimator alloc]
initWithDuration:1.0 - self.emptyStateView.alpha
dampingRatio:1.0
animations:^{
self.emptyStateView.alpha = 1.0;
self.emptyStateView.transform = CGAffineTransformIdentity;
}];
[self.emptyStateAnimator startAnimation];
}
// Removes the empty state out of view, with animation if `animated` is YES.
- (void)removeEmptyStateAnimated:(BOOL)animated {
// TODO(crbug.com/40566436) : Polish the animation, and put constants where
// they belong.
[self.emptyStateAnimator stopAnimation:YES];
auto removeEmptyState = ^{
self.emptyStateView.alpha = 0.0;
self.emptyStateView.transform = CGAffineTransformScale(
CGAffineTransformIdentity, /*sx=*/0.9, /*sy=*/0.9);
};
if (animated) {
self.emptyStateAnimator = [[UIViewPropertyAnimator alloc]
initWithDuration:self.emptyStateView.alpha
dampingRatio:1.0
animations:removeEmptyState];
[self.emptyStateAnimator startAnimation];
} else {
removeEmptyState();
}
}
// Update visible cells identifier, following a reorg of cells.
- (void)updateVisibleCellIdentifiers {
for (NSIndexPath* indexPath in self.collectionView
.indexPathsForVisibleItems) {
UICollectionViewCell* cell =
[self.collectionView cellForItemAtIndexPath:indexPath];
if (![cell isKindOfClass:[GridCell class]]) {
continue;
}
NSUInteger itemIndex = base::checked_cast<NSUInteger>(indexPath.item);
cell.accessibilityIdentifier = GridCellAccessibilityIdentifier(itemIndex);
}
}
- (BOOL)shouldShowEmptyState {
if (self.showingSuggestedActions) {
return NO;
}
return self.gridEmpty;
}
// Updates the number of results found on the search open tabs section header.
- (void)updateSearchResultsHeader {
CHECK_EQ(_mode, TabGridMode::kSearch, base::NotFatalUntil::M129);
CHECK_GT(_searchText.length, 0ul, base::NotFatalUntil::M129);
NSInteger tabSectionIndex = [self.diffableDataSource
indexForSectionIdentifier:kGridOpenTabsSectionIdentifier];
GridHeader* headerView = base::apple::ObjCCast<
GridHeader>([self.collectionView
supplementaryViewForElementKind:UICollectionElementKindSectionHeader
atIndexPath:[NSIndexPath
indexPathForRow:0
inSection:tabSectionIndex]]);
if (!headerView) {
return;
}
NSString* resultsCount = [@([self numberOfTabs]) stringValue];
headerView.value =
l10n_util::GetNSStringF(IDS_IOS_TABS_SEARCH_OPEN_TABS_COUNT,
base::SysNSStringToUTF16(resultsCount));
}
// Returns the number of tabs in the collection view.
- (NSInteger)numberOfTabs {
NSInteger sectionIndex = [self.diffableDataSource
indexForSectionIdentifier:kGridOpenTabsSectionIdentifier];
return [self.collectionView numberOfItemsInSection:sectionIndex];
}
// Returns the IndexPath of the item having the `ID`.
- (NSIndexPath*)indexPathForID:(web::WebStateID)ID {
TabSwitcherItem* tabItem = [[TabSwitcherItem alloc] initWithIdentifier:ID];
GridItemIdentifier* lookupItemIdentifier =
[[GridItemIdentifier alloc] initWithTabItem:tabItem];
return
[self.diffableDataSource indexPathForItemIdentifier:lookupItemIdentifier];
}
// Returns the indexPath for the tab at `index`.
- (NSIndexPath*)indexPathForTabIndex:(NSInteger)index {
NSInteger sectionIndex = [_diffableDataSource
indexForSectionIdentifier:kGridOpenTabsSectionIdentifier];
return [NSIndexPath indexPathForItem:index inSection:sectionIndex];
}
// Sets the hover effect to a cell. The shape of the hover effect is exactly the
// same as the border of a selected tab.
- (void)setHoverEffectToCell:(UICollectionViewCell*)cell {
DCHECK(ios::provider::IsRaccoonEnabled());
if (@available(iOS 17.0, *)) {
CGFloat margin =
kGridCellSelectionRingTintWidth + kGridCellSelectionRingGapWidth;
cell.hoverStyle = [UIHoverStyle
styleWithShape:[UIShape
fixedRectShapeWithRect:CGRectMake(
-margin, -margin,
cell.bounds.size.width +
margin * 2,
cell.bounds.size.height +
margin * 2)
cornerRadius:kGridCellCornerRadius +
margin]];
}
}
// Reconfigures `itemIdentifier`.
- (void)reconfigureItem:(GridItemIdentifier*)itemIdentifier {
GridSnapshot* snapshot = self.diffableDataSource.snapshot;
[snapshot reconfigureItemsWithIdentifiers:@[ itemIdentifier ]];
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
}
@end