[go: nahoru, domu]

blob: 6dbcaa92795a7d8e504b223e6d7fa23e229fcba5 [file] [log] [blame]
// 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/grid_view_controller.h"
#import <algorithm>
#import <memory>
#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/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/tabs/features.h"
#import "ios/chrome/browser/tabs/inactive_tabs/features.h"
#import "ios/chrome/browser/ui/commerce/price_card/price_card_data_source.h"
#import "ios/chrome/browser/ui/commerce/price_card/price_card_item.h"
#import "ios/chrome/browser/ui/incognito_reauth/incognito_reauth_commands.h"
#import "ios/chrome/browser/ui/incognito_reauth/incognito_reauth_view.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/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_layout.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_shareable_items_provider.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_view_controller+private.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/inactive_tabs/inactive_tabs_button_ui_swift.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/inactive_tabs/inactive_tabs_preamble_header.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_switcher_item.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 "third_party/abseil-cpp/absl/types/optional.h"
#import "ui/base/l10n/l10n_util.h"
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 {
// TODO(crbug.com/1466000): Remove hard-coding of sections.
constexpr int kOpenTabsSectionIndex = 0;
constexpr int kSuggestedActionsSectionIndex = 1;
NSString* const kOpenTabsSectionIdentifier = @"OpenTabsSectionIdentifier";
NSString* const kSuggestedActionsSectionIdentifier =
@"SuggestedActionsSectionIdentifier";
NSString* const kCellIdentifier = @"GridCellIdentifier";
NSString* const kSuggestedActionsCellIdentifier =
@"SuggestedActionsCellIdentifier";
NSString* const kGridHeaderIdentifier = @"GridHeaderIdentifier";
NSString* const kInactiveTabsButtonHeaderIdentifier =
@"InactiveTabsButtonHeaderIdentifier";
NSString* const kInactiveTabsPreambleHeaderIdentifier =
@"InactiveTabsPreambleHeaderIdentifier";
constexpr base::TimeDelta kInactiveTabsHeaderAnimationDuration =
base::Seconds(0.3);
// Creates an NSIndexPath with `index` in section 0.
NSIndexPath* CreateIndexPath(NSInteger index) {
return [NSIndexPath indexPathForItem:index inSection:0];
}
// 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];
}
} // namespace
@interface BidirectionalCollectionViewTransitionLayout
: UICollectionViewTransitionLayout
@end
@implementation BidirectionalCollectionViewTransitionLayout
- (BOOL)flipsHorizontallyInOppositeLayoutDirection {
return UseRTLLayout() ? YES : NO;
}
@end
@interface GridViewController () <GridCellDelegate,
SuggestedActionsViewControllerDelegate,
// TODO(crbug.com/1462907): Remove once the
// diffable data source refactor is validated
// by testers.
UICollectionViewDataSource,
UICollectionViewDelegate,
UICollectionViewDelegateFlowLayout,
UICollectionViewDragDelegate,
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)
UICollectionViewDiffableDataSource<NSString*, NSString*>*
diffableDataSource;
// The cell registration for grid cells.
@property(nonatomic, strong)
UICollectionViewCellRegistration* gridCellRegistration;
// The cell registration for the Suggested Actions cell.
@property(nonatomic, strong)
UICollectionViewCellRegistration* suggestedActionsCellRegistration;
// The supplementary view registration for the grid header.
@property(nonatomic, strong)
UICollectionViewSupplementaryRegistration* gridHeaderRegistration;
// The supplementary view registration for the Inactive Tabs button header.
@property(nonatomic, strong) UICollectionViewSupplementaryRegistration*
inactiveTabsButtonHeaderRegistration;
// The supplementary view registration for the Inactive Tabs preamble header.
@property(nonatomic, strong) UICollectionViewSupplementaryRegistration*
inactiveTabsPreambleHeaderRegistration;
// A view to obscure incognito content when the user isn't authorized to
// see it.
@property(nonatomic, strong) IncognitoReauthView* blockingView;
// The local model backing the collection view.
@property(nonatomic, strong) NSMutableArray<TabSwitcherItem*>* items;
// Identifier of the selected item. This value is disregarded if `self.items` is
// empty. This bookkeeping is done to set the correct selection on
// `-viewWillAppear:`.
@property(nonatomic, copy) NSString* selectedItemID;
// Index of the selected item in `items`.
@property(nonatomic, readonly) NSUInteger selectedIndex;
// Items selected for editing.
@property(nonatomic, strong) NSMutableSet<NSString*>* selectedEditingItemIDs;
// Items selected for editing which are shareable outside of the app.
@property(nonatomic, strong)
NSMutableSet<NSString*>* selectedSharableEditingItemIDs;
// 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, copy) NSString* lastInsertedItemID;
// Identifier of the lastest dragged item. This property is set when the item is
// long pressed which does not always result in a drag action.
@property(nonatomic, copy) NSString* draggedItemID;
// Animator to show or hide the empty state.
@property(nonatomic, strong) UIViewPropertyAnimator* emptyStateAnimator;
// The current layout for the tab switcher.
@property(nonatomic, strong) FlowLayout* currentLayout;
// The layout for the tab grid.
@property(nonatomic, strong) GridLayout* gridLayout;
// By how much the user scrolled past the view's content size. A negative value
// means the user hasn't scrolled past the end of the scroll view.
@property(nonatomic, assign, readonly) CGFloat offsetPastEndOfScrollView;
// The view controller that holds the view of the suggested saerch 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;
// The number of currently inactive tabs. If there are (inactiveTabsCount > 0)
// and the grid is in TabGridModeNormal, a button is displayed at the top,
// advertizing them.
@property(nonatomic, assign) NSInteger inactiveTabsCount;
// The number of days after which tabs are considered inactive. This is
// displayed to the user in the Inactive Tabs button when inactiveTabsCount > 0.
@property(nonatomic, assign) NSInteger inactiveTabsDaysThreshold;
// Tracks if a drop action initiated in this grid is in progress.
@property(nonatomic) BOOL localDragActionInProgress;
// Tracks if the Inactive Tabs button is being animated out.
@property(nonatomic) BOOL inactiveTabsHeaderHideAnimationInProgress;
// 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 GridViewController {
// Tracks when the grid view is scrolling. Create a new instance to start
// timing and reset to stop and log the associated time histogram.
absl::optional<ScopedScrollingTimeLogger> _scopedScrollingTimeLogger;
}
- (instancetype)init {
if (self = [super init]) {
_items = [[NSMutableArray<TabSwitcherItem*> alloc] init];
_selectedEditingItemIDs = [[NSMutableSet<NSString*> alloc] init];
_selectedSharableEditingItemIDs = [[NSMutableSet<NSString*> alloc] init];
_dropAnimationInProgress = NO;
_localDragActionInProgress = NO;
_notSelectedTabCellOpacity = 1.0;
_mode = TabGridModeNormal;
// 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.gridLayout = [[GridLayout alloc] init];
self.currentLayout = self.gridLayout;
UICollectionView* collectionView =
[[UICollectionView alloc] initWithFrame:CGRectZero
collectionViewLayout:self.currentLayout];
// During deletion (in horizontal layout) the backgroundView can resize,
// revealing temporarily the collectionView background. This makes sure
// both are the same color.
collectionView.backgroundColor = [UIColor colorNamed:kGridBackgroundColor];
// If this stays as the default `YES`, then cells aren't highlighted
// immediately on touch, but after a short delay.
collectionView.delaysContentTouches = NO;
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
[self createRegistrations];
__weak __typeof(self) weakSelf = self;
self.diffableDataSource = [[UICollectionViewDiffableDataSource alloc]
initWithCollectionView:collectionView
cellProvider:^UICollectionViewCell*(
UICollectionView* innerCollectionView,
NSIndexPath* indexPath, NSString* itemIdentifier) {
return [weakSelf cellForItemAtIndexPath:indexPath
itemIdentifier:itemIdentifier];
}];
self.diffableDataSource.supplementaryViewProvider =
^UICollectionReusableView*(UICollectionView* innerCollectionView,
NSString* elementKind,
NSIndexPath* indexPath) {
return [weakSelf headerForSectionAtIndexPath:indexPath];
};
collectionView.dataSource = self.diffableDataSource;
} else {
[collectionView registerClass:[GridCell class]
forCellWithReuseIdentifier:kCellIdentifier];
[collectionView registerClass:[SuggestedActionsGridCell class]
forCellWithReuseIdentifier:kSuggestedActionsCellIdentifier];
[collectionView registerClass:[GridHeader class]
forSupplementaryViewOfKind:UICollectionElementKindSectionHeader
withReuseIdentifier:kGridHeaderIdentifier];
[collectionView registerClass:[InactiveTabsButtonHeader class]
forSupplementaryViewOfKind:UICollectionElementKindSectionHeader
withReuseIdentifier:kInactiveTabsButtonHeaderIdentifier];
[collectionView registerClass:[InactiveTabsPreambleHeader class]
forSupplementaryViewOfKind:UICollectionElementKindSectionHeader
withReuseIdentifier:kInactiveTabsPreambleHeaderIdentifier];
collectionView.dataSource = self;
}
collectionView.delegate = self;
collectionView.backgroundView = [[UIView alloc] init];
collectionView.backgroundView.backgroundColor =
[UIColor colorNamed:kGridBackgroundColor];
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;
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];
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self contentWillAppearAnimated:animated];
}
- (void)viewWillDisappear:(BOOL)animated {
[self contentWillDisappear];
[super viewWillDisappear:animated];
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
[coordinator
animateAlongsideTransition:^(
id<UIViewControllerTransitionCoordinatorContext> context) {
[self.collectionView.collectionViewLayout invalidateLayout];
}
completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
[self.collectionView setNeedsLayout];
[self.collectionView layoutIfNeeded];
}];
}
#pragma mark - Public
- (UIScrollView*)gridView {
return self.collectionView;
}
- (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.items.count == 0;
}
- (BOOL)isInactiveGridEmpty {
return self.inactiveTabsCount == 0;
}
// Returns the items whose associated cell is visible.
- (NSSet<TabSwitcherItem*>*)visibleGridItems {
NSArray<NSIndexPath*>* visibleItemsIndexPaths =
[self.collectionView indexPathsForVisibleItems];
return [self itemsFromIndexPaths:visibleItemsIndexPaths];
}
- (void)setMode:(TabGridMode)mode {
if (_mode == mode) {
return;
}
TabGridMode previousMode = _mode;
_mode = mode;
// TODO(crbug.com/1300369): Enable dragging items from search results.
self.collectionView.dragInteractionEnabled = (_mode != TabGridModeSearch);
self.emptyStateView.tabGridMode = _mode;
if (mode == TabGridModeSearch && self.suggestedActionsDelegate) {
if (!self.suggestedActionsViewController) {
self.suggestedActionsViewController =
[[SuggestedActionsViewController alloc] initWithDelegate:self];
}
}
[self updateSuggestedActionsSection];
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
// Reconfigure all items.
NSDiffableDataSourceSnapshot* snapshot = self.diffableDataSource.snapshot;
[snapshot reconfigureItemsWithIdentifiers:snapshot.itemIdentifiers];
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
NSUInteger selectedIndex = self.selectedIndex;
if (previousMode != TabGridModeSelection && mode == TabGridModeNormal &&
selectedIndex != NSNotFound) {
// Scroll to the selected item here, so the action of reloading and
// scrolling happens at once.
[self.collectionView
scrollToItemAtIndexPath:CreateIndexPath(selectedIndex)
atScrollPosition:UICollectionViewScrollPositionTop
animated:NO];
}
} else {
// Reloading specific sections in a `performBatchUpdates` fades the changes
// in rather than reloads the collection view with a harsh flash.
__weak GridViewController* weakSelf = self;
[self.collectionView
performBatchUpdates:^{
GridViewController* strongSelf = weakSelf;
if (!strongSelf || !strongSelf.collectionView) {
return;
}
if (mode == TabGridModeSelection &&
previousMode == TabGridModeNormal) {
// If the grid is switching from normal to selected state don't
// reload the whole table view to avoid having a flash, particularly
// visible when using context menu.
for (UITableViewCell* cell in strongSelf.collectionView
.visibleCells) {
GridCell* gridCell = base::apple::ObjCCast<GridCell>(cell);
gridCell.state = mode == TabGridModeSelection
? GridCellStateEditingUnselected
: GridCellStateNotEditing;
}
} else {
NSRange allSectionsRange = NSMakeRange(
/*location=*/0, strongSelf.collectionView.numberOfSections);
NSIndexSet* allSectionsIndexSet =
[NSIndexSet indexSetWithIndexesInRange:allSectionsRange];
[strongSelf.collectionView reloadSections:allSectionsIndexSet];
}
NSUInteger selectedIndex = strongSelf.selectedIndex;
if (previousMode != TabGridModeSelection &&
mode == TabGridModeNormal && selectedIndex != NSNotFound) {
// Scroll to the selected item here, so the animation of reloading
// and scrolling happens at once.
[strongSelf.collectionView
scrollToItemAtIndexPath:CreateIndexPath(selectedIndex)
atScrollPosition:UICollectionViewScrollPositionTop
animated:NO];
}
}
completion:nil];
}
if (mode == TabGridModeNormal) {
// Clear items when exiting selection mode.
[self.selectedEditingItemIDs removeAllObjects];
[self.selectedSharableEditingItemIDs removeAllObjects];
// 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;
}
}
- (void)setSearchText:(NSString*)searchText {
_searchText = searchText;
_suggestedActionsViewController.searchText = searchText;
[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 = CreateIndexPath(selectedIndex);
return [self.collectionView.indexPathsForVisibleItems
containsObject:selectedIndexPath];
}
- (LegacyGridTransitionLayout*)transitionLayout {
[self.collectionView layoutIfNeeded];
NSMutableArray<LegacyGridTransitionItem*>* items =
[[NSMutableArray alloc] init];
LegacyGridTransitionActiveItem* activeItem;
LegacyGridTransitionItem* selectionItem;
for (NSIndexPath* path in self.collectionView.indexPathsForVisibleItems) {
if (path.section != kOpenTabsSectionIndex)
continue;
GridCell* cell = base::apple::ObjCCastStrict<GridCell>(
[self.collectionView cellForItemAtIndexPath:path]);
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 hasIdentifier:self.selectedItemID]) {
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 hasIdentifier: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];
}
- (void)prepareForAppearance {
for (TabSwitcherItem* item in [self visibleGridItems]) {
[item prefetchSnapshot];
}
}
- (void)contentWillAppearAnimated:(BOOL)animated {
self.currentLayout.animatesItemUpdates = YES;
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
[self reloadCollectionViewData];
} else {
[self.collectionView reloadData];
}
// Selection is invalid if there are no items.
if ([self shouldShowEmptyState]) {
[self animateEmptyStateIn];
return;
}
[self updateSelectedCollectionViewItemRingAndBringIntoView:YES];
// Update the delegate, in case it wasn't set when `items` was populated.
[self.delegate gridViewController:self didChangeItemCount:self.items.count];
[self removeEmptyStateAnimated:NO];
self.lastInsertedItemID = nil;
}
- (void)contentDidAppear {
for (TabSwitcherItem* item in self.items) {
[item clearPrefetchedSnapshot];
}
}
- (void)contentWillDisappear {
}
- (void)prepareForDismissal {
// Stop animating the collection view to prevent the insertion animation from
// interfering with the tab presentation animation.
self.currentLayout.animatesItemUpdates = NO;
}
- (void)willCloseAll {
self.isClosingAllOrUndoRunning = YES;
}
- (void)didCloseAll {
self.isClosingAllOrUndoRunning = NO;
}
- (void)willUndoCloseAll {
self.isClosingAllOrUndoRunning = YES;
}
- (void)didUndoCloseAll {
self.isClosingAllOrUndoRunning = NO;
// Reload the button and ensure it is not hidden, as this is the only flow
// where the button can dynamically reappear when the app is running and the
// reappearance is not managed by default.
[self reloadInactiveTabsButtonHeader];
NSIndexPath* indexPath = [NSIndexPath indexPathForItem:0
inSection:kOpenTabsSectionIndex];
InactiveTabsButtonHeader* header =
base::apple::ObjCCast<InactiveTabsButtonHeader>([self.collectionView
supplementaryViewForElementKind:UICollectionElementKindSectionHeader
atIndexPath:indexPath]);
header.hidden = NO;
}
#pragma mark - Public Editing Mode Selection
- (void)selectAllItemsForEditing {
if (_mode != TabGridModeSelection) {
base::debug::DumpWithoutCrashing();
return;
}
for (TabSwitcherItem* item in self.items) {
[self selectItemWithIDForEditing:item.identifier];
}
[self.collectionView reloadData];
}
- (void)deselectAllItemsForEditing {
if (_mode != TabGridModeSelection) {
base::debug::DumpWithoutCrashing();
return;
}
for (TabSwitcherItem* item in self.items) {
[self deselectItemWithIDForEditing:item.identifier];
}
[self.collectionView reloadData];
}
- (NSArray<NSString*>*)selectedItemIDsForEditing {
return [self.selectedEditingItemIDs allObjects];
}
- (NSArray<NSString*>*)selectedShareableItemIDsForEditing {
return [self.selectedSharableEditingItemIDs allObjects];
}
- (BOOL)allItemsSelectedForEditing {
return _mode == TabGridModeSelection &&
self.items.count == self.selectedEditingItemIDs.count;
}
#pragma mark - Private Editing Mode Selection
- (BOOL)isItemWithIDSelectedForEditing:(NSString*)identifier {
return [self.selectedEditingItemIDs containsObject:identifier];
}
- (void)selectItemWithIDForEditing:(NSString*)identifier {
[self.selectedEditingItemIDs addObject:identifier];
if ([self.shareableItemsProvider isItemWithIdentifierSharable:identifier]) {
[self.selectedSharableEditingItemIDs addObject:identifier];
}
}
- (void)deselectItemWithIDForEditing:(NSString*)identifier {
[self.selectedEditingItemIDs removeObject:identifier];
[self.selectedSharableEditingItemIDs removeObject:identifier];
}
// TODO(crbug.com/1462907): Remove the UICollectionViewDataSource methods once
// the diffable data source refactor is validated by testers.
#pragma mark - UICollectionViewDataSource
- (NSInteger)numberOfSectionsInCollectionView:
(UICollectionView*)collectionView {
CHECK(!base::FeatureList::IsEnabled(kTabGridRefactoring));
if (self.showingSuggestedActions) {
return kSuggestedActionsSectionIndex + 1;
}
return 1;
}
- (NSInteger)collectionView:(UICollectionView*)collectionView
numberOfItemsInSection:(NSInteger)section {
CHECK(!base::FeatureList::IsEnabled(kTabGridRefactoring));
if (section == kSuggestedActionsSectionIndex) {
// In the search mode there there is only one item in the suggested actions
// section which contains the table for the suggested actions.
if (self.showingSuggestedActions) {
return 1;
}
return 0;
}
return base::checked_cast<NSInteger>(self.items.count);
}
// TODO(crbug.com/1462907): Remove the UICollectionViewDataSource methods once
// the diffable data source refactor is validated by testers.
- (UICollectionViewCell*)collectionView:(UICollectionView*)collectionView
cellForItemAtIndexPath:(NSIndexPath*)indexPath {
CHECK(!base::FeatureList::IsEnabled(kTabGridRefactoring));
NSUInteger itemIndex = base::checked_cast<NSUInteger>(indexPath.item);
UICollectionViewCell* cell;
if (indexPath.section == kSuggestedActionsSectionIndex) {
DCHECK(self.suggestedActionsViewController);
cell = [collectionView
dequeueReusableCellWithReuseIdentifier:kSuggestedActionsCellIdentifier
forIndexPath:indexPath];
SuggestedActionsGridCell* suggestedActionsCell =
base::apple::ObjCCastStrict<SuggestedActionsGridCell>(cell);
suggestedActionsCell.suggestedActionsView =
self.suggestedActionsViewController.view;
} else {
// In some cases this is called with an indexPath.item that's beyond (by 1)
// the bounds of self.items -- see crbug.com/1068136. Presumably this is a
// race condition where an item has been deleted at the same time as the
// collection is doing layout (potentially during rotation?). Fudge by
// duplicating the last cell. The assumption is that there will be another,
// correct layout shortly after the incorrect one.
// Keep array bounds valid, but dump without crashing to report.
if (itemIndex >= self.items.count) {
itemIndex = self.items.count - 1;
base::debug::DumpWithoutCrashing();
}
TabSwitcherItem* item = self.items[itemIndex];
cell =
[collectionView dequeueReusableCellWithReuseIdentifier:kCellIdentifier
forIndexPath:indexPath];
GridCell* gridCell = base::apple::ObjCCastStrict<GridCell>(cell);
[self configureCell:gridCell withItem:item atIndex:itemIndex];
}
return cell;
}
// TODO(crbug.com/1462907): Remove the UICollectionViewDataSource methods once
// the diffable data source refactor is validated by testers.
- (UICollectionReusableView*)collectionView:(UICollectionView*)collectionView
viewForSupplementaryElementOfKind:(NSString*)kind
atIndexPath:(NSIndexPath*)indexPath {
CHECK(!base::FeatureList::IsEnabled(kTabGridRefactoring));
switch (_mode) {
case TabGridModeNormal: {
// The Regular Tabs grid has a button to inform about the hidden inactive
// tabs.
CHECK(IsInactiveTabsAvailable());
if (self.inactiveTabsCount == 0 &&
!self.inactiveTabsHeaderHideAnimationInProgress) {
base::debug::DumpWithoutCrashing();
}
InactiveTabsButtonHeader* header = [collectionView
dequeueReusableSupplementaryViewOfKind:kind
withReuseIdentifier:
kInactiveTabsButtonHeaderIdentifier
forIndexPath:indexPath];
[self configureInactiveTabsButtonHeader:header];
return header;
}
case TabGridModeSelection:
NOTREACHED();
return nil;
case TabGridModeSearch: {
GridHeader* headerView = [collectionView
dequeueReusableSupplementaryViewOfKind:kind
withReuseIdentifier:kGridHeaderIdentifier
forIndexPath:indexPath];
switch (indexPath.section) {
case kOpenTabsSectionIndex: {
headerView.title = l10n_util::GetNSString(
IDS_IOS_TABS_SEARCH_OPEN_TABS_SECTION_HEADER_TITLE);
NSString* resultsCount = [@(self.items.count) stringValue];
headerView.value =
l10n_util::GetNSStringF(IDS_IOS_TABS_SEARCH_OPEN_TABS_COUNT,
base::SysNSStringToUTF16(resultsCount));
break;
}
case kSuggestedActionsSectionIndex: {
headerView.title =
l10n_util::GetNSString(IDS_IOS_TABS_SEARCH_SUGGESTED_ACTIONS);
break;
}
}
return headerView;
}
case TabGridModeInactive:
// The Inactive Tabs grid has a header to inform about the feature and a
// link to its settings.
CHECK(IsInactiveTabsEnabled());
InactiveTabsPreambleHeader* header = [collectionView
dequeueReusableSupplementaryViewOfKind:kind
withReuseIdentifier:
kInactiveTabsPreambleHeaderIdentifier
forIndexPath:indexPath];
[self configureInactiveTabsPreambleHeader:header];
return header;
}
}
#pragma mark - UICollectionView Diffable Data Source Helpers
- (void)reloadCollectionViewData {
NSDiffableDataSourceSnapshot* snapshot =
[[NSDiffableDataSourceSnapshot alloc] init];
[snapshot appendSectionsWithIdentifiers:@[ kOpenTabsSectionIdentifier ]];
NSString* identifierKey = NSStringFromSelector(@selector(identifier));
[snapshot appendItemsWithIdentifiers:[self.items valueForKey:identifierKey]];
if (self.showingSuggestedActions) {
[snapshot
appendSectionsWithIdentifiers:@[ kSuggestedActionsSectionIdentifier ]];
[snapshot appendItemsWithIdentifiers:@[ kSuggestedActionsCellIdentifier ]];
}
[self.diffableDataSource applySnapshotUsingReloadData:snapshot];
}
// Creates the cell and supplementary view registrations and assigns them to the
// appropriate properties.
- (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];
};
self.gridCellRegistration = [UICollectionViewCellRegistration
registrationWithCellClass:[GridCell class]
configurationHandler:configureGridCell];
// Register SuggestedActionsGridCell.
auto configureSuggestedActionsCell =
^(SuggestedActionsGridCell* suggestedActionsCell, NSIndexPath* indexPath,
id item) {
CHECK(weakSelf.suggestedActionsViewController);
suggestedActionsCell.suggestedActionsView =
weakSelf.suggestedActionsViewController.view;
};
self.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];
};
self.gridHeaderRegistration = [UICollectionViewSupplementaryRegistration
registrationWithSupplementaryClass:[GridHeader class]
elementKind:UICollectionElementKindSectionHeader
configurationHandler:configureGridHeader];
// Register InactiveTabsButtonHeader.
auto configureInactiveTabsButtonHeader =
^(InactiveTabsButtonHeader* header, NSString* elementKind,
NSIndexPath* indexPath) {
[weakSelf configureInactiveTabsButtonHeader:header];
};
self.inactiveTabsButtonHeaderRegistration =
[UICollectionViewSupplementaryRegistration
registrationWithSupplementaryClass:[InactiveTabsButtonHeader class]
elementKind:
UICollectionElementKindSectionHeader
configurationHandler:configureInactiveTabsButtonHeader];
// Register InactiveTabsPreambleHeader.
auto configureInactiveTabsPreambleHeader =
^(InactiveTabsPreambleHeader* header, NSString* elementKind,
NSIndexPath* indexPath) {
[weakSelf configureInactiveTabsPreambleHeader:header];
};
self.inactiveTabsPreambleHeaderRegistration =
[UICollectionViewSupplementaryRegistration
registrationWithSupplementaryClass:[InactiveTabsPreambleHeader class]
elementKind:
UICollectionElementKindSectionHeader
configurationHandler:
configureInactiveTabsPreambleHeader];
}
// Configures the grid header for the given section.
- (void)configureGridHeader:(GridHeader*)gridHeader
forSectionIdentifier:(NSString*)sectionIdentifier {
CHECK(base::FeatureList::IsEnabled(kTabGridRefactoring));
if ([sectionIdentifier isEqualToString:kOpenTabsSectionIdentifier]) {
gridHeader.title = l10n_util::GetNSString(
IDS_IOS_TABS_SEARCH_OPEN_TABS_SECTION_HEADER_TITLE);
NSString* resultsCount = [@(self.items.count) 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);
}
}
// Configures the Inactive Tabs Button header according to the current state.
- (void)configureInactiveTabsButtonHeader:(InactiveTabsButtonHeader*)header {
header.parent = self;
__weak __typeof(self) weakSelf = self;
header.buttonAction = ^{
[weakSelf didTapInactiveTabsButton];
};
[header configureWithDaysThreshold:self.inactiveTabsDaysThreshold];
if (IsShowInactiveTabsCountEnabled()) {
[header configureWithCount:self.inactiveTabsCount];
}
}
// Configures the Inactive Tabs Preamble header according to the current state.
- (void)configureInactiveTabsPreambleHeader:
(InactiveTabsPreambleHeader*)header {
__weak __typeof(self) weakSelf = self;
header.settingsLinkAction = ^{
[weakSelf didTapInactiveTabsSettingsLink];
};
header.daysThreshold = self.inactiveTabsDaysThreshold;
}
// Returns a configured header for the given index path.
- (UICollectionReusableView*)headerForSectionAtIndexPath:
(NSIndexPath*)indexPath {
UICollectionViewSupplementaryRegistration* registration;
switch (_mode) {
case TabGridModeNormal:
// The Regular Tabs grid has a button to inform about the hidden inactive
// tabs.
CHECK(IsInactiveTabsAvailable());
if (self.inactiveTabsCount == 0 &&
!self.inactiveTabsHeaderHideAnimationInProgress) {
base::debug::DumpWithoutCrashing();
}
registration = self.inactiveTabsButtonHeaderRegistration;
break;
case TabGridModeSelection:
NOTREACHED();
break;
case TabGridModeSearch:
registration = self.gridHeaderRegistration;
break;
case TabGridModeInactive:
// The Inactive Tabs grid has a header to inform about the feature and a
// link to its settings.
CHECK(IsInactiveTabsEnabled());
registration = self.inactiveTabsPreambleHeaderRegistration;
break;
}
return [self.collectionView
dequeueConfiguredReusableSupplementaryViewWithRegistration:registration
forIndexPath:indexPath];
}
// Returns a configured cell for the given index path and item identifier.
- (UICollectionViewCell*)cellForItemAtIndexPath:(NSIndexPath*)indexPath
itemIdentifier:(NSString*)itemIdentifier {
CHECK(base::FeatureList::IsEnabled(kTabGridRefactoring));
// Handle the SuggestedActionsGridCell.
if ([itemIdentifier isEqualToString:kSuggestedActionsCellIdentifier]) {
return [self.collectionView
dequeueConfiguredReusableCellWithRegistration:
self.suggestedActionsCellRegistration
forIndexPath:indexPath
item:itemIdentifier];
}
// Handle GridCell-s.
NSUInteger itemIndex = base::checked_cast<NSUInteger>(indexPath.item);
// In some cases this is called with an indexPath.item that's beyond (by 1)
// the bounds of self.items -- see crbug.com/1068136. Presumably this is a
// race condition where an item has been deleted at the same time as the
// collection is doing layout (potentially during rotation?). Fudge by
// duplicating the last cell. The assumption is that there will be another,
// correct layout shortly after the incorrect one.
// Keep array bounds valid, but dump without crashing to report.
if (itemIndex >= self.items.count) {
itemIndex = self.items.count - 1;
base::debug::DumpWithoutCrashing();
}
TabSwitcherItem* item = self.items[itemIndex];
return [self.collectionView
dequeueConfiguredReusableCellWithRegistration:self.gridCellRegistration
forIndexPath:indexPath
item:item];
}
#pragma mark - UICollectionViewDelegate
- (CGSize)collectionView:(UICollectionView*)collectionView
layout:(UICollectionViewLayout*)collectionViewLayout
sizeForItemAtIndexPath:(NSIndexPath*)indexPath {
// `collectionViewLayout` should always be a flow layout.
DCHECK(
[collectionViewLayout isKindOfClass:[UICollectionViewFlowLayout class]]);
if (self.isClosingAllOrUndoRunning) {
return CGSizeZero;
}
UICollectionViewFlowLayout* layout =
(UICollectionViewFlowLayout*)collectionViewLayout;
CGSize itemSize = layout.itemSize;
// The SuggestedActions cell can't use the item size that is set in
// `prepareLayout` of the layout class. For that specific cell calculate the
// anticipated size from the layout section insets and the content view insets
// and return it.
if (indexPath.section == kSuggestedActionsSectionIndex) {
UIEdgeInsets sectionInset = layout.sectionInset;
UIEdgeInsets contentInset = layout.collectionView.adjustedContentInset;
CGFloat width = layout.collectionView.frame.size.width - sectionInset.left -
sectionInset.right - contentInset.left - contentInset.right;
CGFloat height = self.suggestedActionsViewController.contentHeight;
return CGSizeMake(width, height);
}
return itemSize;
}
- (CGSize)collectionView:(UICollectionView*)collectionView
layout:
(UICollectionViewLayout*)collectionViewLayout
referenceSizeForHeaderInSection:(NSInteger)section {
switch (_mode) {
case TabGridModeNormal:
if (!IsInactiveTabsAvailable()) {
return CGSizeZero;
}
if (self.isClosingAllOrUndoRunning) {
return CGSizeZero;
}
if (self.inactiveTabsHeaderHideAnimationInProgress) {
// The header is animated out to a height of 0.1.
return CGSizeMake(collectionView.bounds.size.width, 0.1);
}
if (self.inactiveTabsCount == 0) {
return CGSizeZero;
}
// The Regular Tabs grid has a button to inform about the hidden inactive
// tabs.
return [self inactiveTabsButtonHeaderSize];
case TabGridModeSelection:
return CGSizeZero;
case TabGridModeSearch: {
if (_searchText.length == 0) {
return CGSizeZero;
}
CGFloat height = UIContentSizeCategoryIsAccessibilityCategory(
self.traitCollection.preferredContentSizeCategory)
? kGridHeaderAccessibilityHeight
: kGridHeaderHeight;
return CGSizeMake(collectionView.bounds.size.width, height);
}
case TabGridModeInactive:
if (!IsInactiveTabsEnabled()) {
return CGSizeZero;
}
// The Inactive Tabs grid has a header to inform about the feature and a
// link to its settings.
return [self inactiveTabsPreambleHeaderSize];
}
}
// This prevents the user from dragging a cell past the plus sign cell (the last
// cell in the collection view).
- (NSIndexPath*)collectionView:(UICollectionView*)collectionView
targetIndexPathForMoveFromItemAtIndexPath:(NSIndexPath*)originalIndexPath
toProposedIndexPath:(NSIndexPath*)proposedIndexPath {
return proposedIndexPath;
}
// This method is used instead of -didSelectItemAtIndexPath, because any
// selection events will be signalled through the model layer and handled in
// the TabCollectionConsumer -selectItemWithID: method.
- (BOOL)collectionView:(UICollectionView*)collectionView
shouldSelectItemAtIndexPath:(NSIndexPath*)indexPath {
if (@available(iOS 16, *)) {
// This is handled by
// `collectionView:performPrimaryActionForItemAtIndexPath:` on iOS 16. The
// method comment should be updated once iOS 15 is dropped.
return YES;
} else {
[self tappedItemAtIndexPath:indexPath];
}
// Tapping on a non-selected cell should not select it immediately. The
// delegate will trigger a transition to show the item.
return NO;
}
- (BOOL)collectionView:(UICollectionView*)collectionView
shouldDeselectItemAtIndexPath:(NSIndexPath*)indexPath {
if (@available(iOS 16, *)) {
// This is handled by
// `collectionView:performPrimaryActionForItemAtIndexPath:` on iOS 16.
} else {
[self tappedItemAtIndexPath:indexPath];
}
// Tapping on the current selected cell should not deselect it.
return NO;
}
- (void)collectionView:(UICollectionView*)collectionView
performPrimaryActionForItemAtIndexPath:(NSIndexPath*)indexPath {
[self tappedItemAtIndexPath:indexPath];
}
- (UIContextMenuConfiguration*)collectionView:(UICollectionView*)collectionView
contextMenuConfigurationForItemAtIndexPath:(NSIndexPath*)indexPath
point:(CGPoint)point {
// Don't allow long-press previews when the incognito reauth view is blocking
// the content.
if (self.contentNeedsAuthentication) {
return nil;
}
// Context menu shouldn't appear in the selection mode.
if (_mode == TabGridModeSelection) {
return nil;
}
// No context menu on suggested actions section.
if (indexPath.section == kSuggestedActionsSectionIndex) {
return nil;
}
GridCell* cell = base::apple::ObjCCastStrict<GridCell>(
[self.collectionView cellForItemAtIndexPath:indexPath]);
MenuScenarioHistogram scenario;
if (_mode == TabGridModeSearch) {
scenario = MenuScenarioHistogram::kTabGridSearchResult;
} else if (_mode == TabGridModeInactive) {
scenario = MenuScenarioHistogram::kInactiveTabsEntry;
} else {
scenario = MenuScenarioHistogram::kTabGridEntry;
}
return [self.menuProvider contextMenuConfigurationForTabCell:cell
menuScenario:scenario];
}
- (UICollectionViewTransitionLayout*)
collectionView:(UICollectionView*)collectionView
transitionLayoutForOldLayout:(UICollectionViewLayout*)fromLayout
newLayout:(UICollectionViewLayout*)toLayout {
return [[BidirectionalCollectionViewTransitionLayout alloc]
initWithCurrentLayout:fromLayout
nextLayout:toLayout];
}
- (void)collectionView:(UICollectionView*)collectionView
willDisplayCell:(UICollectionViewCell*)cell
forItemAtIndexPath:(NSIndexPath*)indexPath {
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
return;
}
// Checking and updating the GridCell's `state` if needed.
//
// For the context: `setMode:` method updates the `state` property of the
// visible GridCells only. However, there are might be some cells that were
// dequeued already but haven't been displayed yet. Those will never update
// their `state` unless we forcely handle it here.
//
// See crbug.com//1427278
if ([cell isKindOfClass:[GridCell class]]) {
GridCell* gridCell = base::apple::ObjCCastStrict<GridCell>(cell);
BOOL isTabGridInSelectionMode = _mode == TabGridModeSelection;
BOOL isGridCellInSelectionMode = gridCell.state != GridCellStateNotEditing;
if (isTabGridInSelectionMode != isGridCellInSelectionMode) {
gridCell.state = isTabGridInSelectionMode ? GridCellStateEditingUnselected
: GridCellStateNotEditing;
}
}
}
- (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.
[base::apple::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.dragDropHandler dragWillBeginForItemWithID:_draggedItemID];
self.dragEndAtNewIndex = NO;
self.localDragActionInProgress = YES;
base::UmaHistogramEnumeration(kUmaGridViewDragDropTabs,
DragDropTabs::kDragBegin);
[self.delegate gridViewControllerDragSessionWillBegin:self];
}
- (void)collectionView:(UICollectionView*)collectionView
dragSessionDidEnd:(id<UIDragSession>)session {
self.localDragActionInProgress = NO;
DragDropTabs dragEvent = self.dragEndAtNewIndex
? DragDropTabs::kDragEndAtNewIndex
: DragDropTabs::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 = DragDropTabs::kDragEndInOtherCollection;
}
base::UmaHistogramEnumeration(kUmaGridViewDragDropTabs, 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.dragDropHandler dragSessionDidEnd];
[self.delegate gridViewControllerDragSessionDidEnd:self];
}
- (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 == TabGridModeSearch) {
// TODO(crbug.com/1300369): Enable dragging items from search results.
return @[];
}
if (indexPath.section == kSuggestedActionsSectionIndex) {
// Return an empty array because ther suggested actions cell should not be
// dragged.
return @[];
}
if (_mode != TabGridModeSelection) {
TabSwitcherItem* item = self.items[indexPath.item];
_draggedItemID = item.identifier;
return @[ [self.dragDropHandler dragItemForItemWithID:_draggedItemID] ];
}
// Make sure that the long pressed cell is selected before initiating a drag
// from it.
NSUInteger index = base::checked_cast<NSUInteger>(indexPath.item);
NSString* pressedItemID = self.items[index].identifier;
if (![self isItemWithIDSelectedForEditing:pressedItemID]) {
[self tappedItemAtIndexPath:indexPath];
}
NSMutableArray<UIDragItem*>* dragItems = [[NSMutableArray alloc] init];
for (NSString* itemID in self.selectedEditingItemIDs) {
[dragItems addObject:[self.dragDropHandler dragItemForItemWithID:itemID]];
}
return dragItems;
}
- (NSArray<UIDragItem*>*)collectionView:(UICollectionView*)collectionView
itemsForAddingToDragSession:(id<UIDragSession>)session
atIndexPath:(NSIndexPath*)indexPath
point:(CGPoint)point {
// TODO(crbug.com/1087848): Allow multi-select.
// Prevent more items from getting added to the drag session.
return @[];
}
- (UIDragPreviewParameters*)collectionView:(UICollectionView*)collectionView
dragPreviewParametersForItemAtIndexPath:(NSIndexPath*)indexPath {
if (indexPath.section == kSuggestedActionsSectionIndex) {
// Return nil so that the suggested actions cell doesn't superpose the
// dragged cell.
return nil;
}
GridCell* gridCell = base::apple::ObjCCastStrict<GridCell>(
[self.collectionView cellForItemAtIndexPath:indexPath]);
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 != TabGridModeSearch);
}
- (UICollectionViewDropProposal*)
collectionView:(UICollectionView*)collectionView
dropSessionDidUpdate:(id<UIDropSession>)session
withDestinationIndexPath:(NSIndexPath*)destinationIndexPath {
// This is how the explicit forbidden icon or (+) copy icon is shown. Move has
// no explicit icon.
UIDropOperation dropOperation =
[self.dragDropHandler dropOperationForDropSession:session];
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.
NSUInteger destinationIndex =
item.sourceIndexPath ? self.items.count - 1 : self.items.count;
if (coordinator.destinationIndexPath) {
destinationIndex =
base::checked_cast<NSUInteger>(coordinator.destinationIndexPath.item);
}
self.dragEndAtNewIndex = YES;
NSIndexPath* dropIndexPath = CreateIndexPath(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 =
base::apple::ObjCCastStrict<GridCell>(placeholderCell);
gridCell.theme = self.theme;
};
placeholder.previewParametersProvider =
^UIDragPreviewParameters*(UICollectionViewCell* placeholderCell) {
GridCell* gridCell =
base::apple::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];
}
}
}
- (void)collectionView:(UICollectionView*)collectionView
dropSessionDidEnter:(id<UIDropSession>)session {
if (IsPinnedTabsEnabled()) {
// Notify the delegate that a drag cames from another app.
[self.delegate gridViewControllerDragSessionWillBegin: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 {
[self.delegate gridViewControllerWillBeginDragging:self];
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 {
[self.delegate gridViewController:self
didCloseItemWithID:cell.itemIdentifier];
// Record when a tab is closed via the X.
base::RecordAction(
base::UserMetricsAction("MobileTabGridCloseControlTapped"));
if (_mode == TabGridModeSearch) {
base::RecordAction(
base::UserMetricsAction("MobileTabGridCloseControlTappedDuringSearch"));
}
}
#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.suggestedActionsDelegate searchHistoryForText:self.searchText];
}
- (void)didSelectSearchRecentTabsInSuggestedActionsViewController:
(SuggestedActionsViewController*)viewController {
base::RecordAction(
base::UserMetricsAction("TabsSearch.SuggestedActions.RecentTabs"));
[self.suggestedActionsDelegate searchRecentTabsForText:self.searchText];
}
- (void)didSelectSearchWebInSuggestedActionsViewController:
(SuggestedActionsViewController*)viewController {
base::RecordAction(
base::UserMetricsAction("TabsSearch.SuggestedActions.SearchOnWeb"));
[self.suggestedActionsDelegate searchWebForText:self.searchText];
}
#pragma mark - IncognitoReauthConsumer
- (void)setItemsRequireAuthentication:(BOOL)require {
self.contentNeedsAuthentication = require;
[self.delegate gridViewController:self
contentNeedsAuthenticationChanged:require];
if (require) {
if (!self.blockingView) {
self.blockingView = [[IncognitoReauthView alloc] init];
self.blockingView.translatesAutoresizingMaskIntoConstraints = NO;
self.blockingView.layer.zPosition = FLT_MAX;
// No need to show tab switcher button when already in the tab switcher.
self.blockingView.tabSwitcherButton.hidden = YES;
// Hide the logo.
self.blockingView.logoView.hidden = YES;
[self.blockingView.authenticateButton
addTarget:self.reauthHandler
action:@selector(authenticateIncognitoContent)
forControlEvents:UIControlEventTouchUpInside];
}
[self.view addSubview:self.blockingView];
self.blockingView.alpha = 1;
AddSameConstraints(self.collectionView.frameLayoutGuide, self.blockingView);
} else {
[UIView animateWithDuration:0.2
animations:^{
self.blockingView.alpha = 0;
}
completion:^(BOOL finished) {
if (self.contentNeedsAuthentication) {
self.blockingView.alpha = 1;
} else {
[self.blockingView removeFromSuperview];
}
}];
}
}
#pragma mark - TabCollectionConsumer
- (void)populateItems:(NSArray<TabSwitcherItem*>*)items
selectedItemID:(NSString*)selectedItemID {
#ifndef NDEBUG
// Consistency check: ensure no IDs are duplicated.
NSMutableSet<NSString*>* identifiers = [[NSMutableSet alloc] init];
for (TabSwitcherItem* item in items) {
[identifiers addObject:item.identifier];
}
CHECK_EQ(identifiers.count, items.count);
#endif
self.items = [items mutableCopy];
self.selectedItemID = selectedItemID;
[self.selectedEditingItemIDs removeAllObjects];
[self.selectedSharableEditingItemIDs removeAllObjects];
[self reloadTabs];
[self updateSelectedCollectionViewItemRingAndBringIntoView:YES];
if ([self shouldShowEmptyState]) {
[self animateEmptyStateIn];
} else {
[self removeEmptyStateAnimated:YES];
}
// Whether the view is visible or not, the delegate must be updated.
[self.delegate gridViewController:self didChangeItemCount:self.items.count];
if (_mode == TabGridModeSearch) {
if (_searchText.length)
[self updateSearchResultsHeader];
[self.collectionView
setContentOffset:CGPointMake(
-self.collectionView.adjustedContentInset.left,
-self.collectionView.adjustedContentInset.top)
animated:NO];
}
}
- (void)insertItem:(TabSwitcherItem*)item
atIndex:(NSUInteger)index
selectedItemID:(NSString*)selectedItemID {
if (_mode == TabGridModeSearch) {
// Prevent inserting items while viewing search results.
return;
}
__weak __typeof(self) weakSelf = self;
[self
performModelAndViewUpdates:^(NSDiffableDataSourceSnapshot* snapshot) {
[weakSelf applyModelAndViewUpdatesForInsertionOfItem:item
atIndex:index
selectedItemID:selectedItemID
snapshot:snapshot];
}
completion:^{
[weakSelf modelAndViewUpdatesForInsertionDidCompleteAtIndex:index];
}];
}
- (void)removeItemWithID:(NSString*)removedItemID
selectedItemID:(NSString*)selectedItemID {
NSUInteger index = [self indexOfItemWithID:removedItemID];
// Do not remove if not showing the item (i.e. showing search results).
if (index == NSNotFound) {
[self selectItemWithID:selectedItemID];
return;
}
__weak __typeof(self) weakSelf = self;
[self
performModelAndViewUpdates:^(NSDiffableDataSourceSnapshot* snapshot) {
[weakSelf applyModelAndViewUpdatesForRemovalOfItemWithID:removedItemID
selectedItemID:selectedItemID
snapshot:snapshot];
}
completion:^{
[weakSelf modelAndViewUpdatesForRemovalDidCompleteForItemWithID:
removedItemID];
}];
if (_searchText.length) {
[self updateSearchResultsHeader];
}
}
- (void)selectItemWithID:(NSString*)selectedItemID {
if ([self.selectedItemID isEqualToString:selectedItemID])
return;
self.selectedItemID = selectedItemID;
[self updateSelectedCollectionViewItemRingAndBringIntoView:NO];
}
- (void)replaceItemID:(NSString*)itemID withItem:(TabSwitcherItem*)item {
if ([self indexOfItemWithID:itemID] == NSNotFound)
return;
// Consistency check: `item`'s ID is either `itemID` or not in `items`.
DCHECK([item.identifier isEqualToString:itemID] ||
[self indexOfItemWithID:item.identifier] == NSNotFound);
NSUInteger index = [self indexOfItemWithID:itemID];
self.items[index] = item;
GridCell* cell = base::apple::ObjCCastStrict<GridCell>(
[self.collectionView cellForItemAtIndexPath:CreateIndexPath(index)]);
// `cell` may be nil if it is scrolled offscreen.
if (cell) {
[self configureCell:cell withItem:item atIndex:index];
}
}
- (void)moveItemWithID:(NSString*)itemID toIndex:(NSUInteger)toIndex {
if (_mode == TabGridModeSearch) {
// Prevent moving items while viewing search results.
return;
}
NSUInteger fromIndex = [self indexOfItemWithID:itemID];
// If this move would be a no-op, early return and avoid spurious UI updates.
if (fromIndex == toIndex || toIndex == NSNotFound ||
fromIndex == NSNotFound) {
return;
}
__weak __typeof(self) weakSelf = self;
[self
performModelAndViewUpdates:^(NSDiffableDataSourceSnapshot* snapshot) {
[weakSelf applyModelAndViewUpdatesForMoveOfItemWithID:itemID
fromIndex:fromIndex
toIndex:toIndex
snapshot:snapshot];
}
completion:^{
[weakSelf modelAndViewUpdatesForMoveDidCompleteForItemWithID:itemID
toIndex:toIndex];
}];
}
- (void)dismissModals {
ios::provider::DismissModalsForCollectionView(self.collectionView);
}
#pragma mark - InactiveTabsInfoConsumer
- (void)updateInactiveTabsCount:(NSInteger)count {
if (self.inactiveTabsCount == count) {
return;
}
NSInteger oldCount = self.inactiveTabsCount;
self.inactiveTabsCount = count;
// Update the header.
if (oldCount == 0) {
[self showInactiveTabsButtonHeader];
} else if (count == 0) {
[self hideInactiveTabsButtonHeader];
} else {
// The header just needs to be updated with the new count.
[self updateInactiveTabsButtonHeader];
}
}
- (void)updateInactiveTabsDaysThreshold:(NSInteger)daysThreshold {
if (self.inactiveTabsDaysThreshold == daysThreshold) {
return;
}
NSInteger oldDaysThreshold = self.inactiveTabsDaysThreshold;
self.inactiveTabsDaysThreshold = daysThreshold;
// Update the header.
if (oldDaysThreshold == kInactiveTabsDisabledByUser ||
daysThreshold == kInactiveTabsDisabledByUser) {
// The header should appear or disappear. Reload the section.
[self reloadInactiveTabsButtonHeader];
} else {
// The header just needs to be updated with the new days threshold.
[self updateInactiveTabsButtonHeader];
}
// Update the preamble.
[self updateInactiveTabsPreambleHeader];
}
#pragma mark - Actions
// Called when the Inactive Tabs button is tapped.
- (void)didTapInactiveTabsButton {
[self.delegate didTapInactiveTabsButtonInGridViewController:self];
}
// Called when the Inactive Tabs settings link is tapped.
- (void)didTapInactiveTabsSettingsLink {
[self.delegate didTapInactiveTabsSettingsLinkInGridViewController:self];
}
#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.
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
NSDiffableDataSourceSnapshot* snapshot = self.diffableDataSource.snapshot;
if (self.mode == TabGridModeSearch && self.searchText.length) {
if (!self.showingSuggestedActions) {
[snapshot appendSectionsWithIdentifiers:@[
kSuggestedActionsSectionIdentifier
]];
[snapshot
appendItemsWithIdentifiers:@[ kSuggestedActionsCellIdentifier ]];
self.showingSuggestedActions = YES;
}
} else {
if (self.showingSuggestedActions) {
[snapshot deleteSectionsWithIdentifiers:@[
kSuggestedActionsSectionIdentifier
]];
self.showingSuggestedActions = NO;
}
}
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
} else {
auto updateSectionBlock = ^{
NSIndexSet* sections =
[NSIndexSet indexSetWithIndex:kSuggestedActionsSectionIndex];
if (self.mode == TabGridModeSearch && self.searchText.length) {
if (!self.showingSuggestedActions) {
[self.collectionView insertSections:sections];
self.showingSuggestedActions = YES;
}
} else {
if (self.showingSuggestedActions) {
[self.collectionView deleteSections:sections];
self.showingSuggestedActions = NO;
}
}
};
[UIView performWithoutAnimation:^{
[self.collectionView performBatchUpdates:updateSectionBlock
completion:nil];
}];
}
}
#pragma mark - Private helpers for joint model and view updates
// Performs model and view updates together.
- (void)performModelAndViewUpdates:
(void (^)(NSDiffableDataSourceSnapshot* snapshot))
modelAndViewUpdates
completion:(ProceduralBlock)completion {
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
self.updating = YES;
// Synchronize model and diffable snapshot updates.
NSDiffableDataSourceSnapshot* snapshot = self.diffableDataSource.snapshot;
modelAndViewUpdates(snapshot);
__weak __typeof(self) weakSelf = self;
[self.diffableDataSource applySnapshot:snapshot
animatingDifferences:YES
completion:^{
if (weakSelf) {
completion();
weakSelf.updating = NO;
}
}];
} else {
__weak __typeof(self) weakSelf = self;
[self.collectionView
performBatchUpdates:^{
weakSelf.updating = YES;
modelAndViewUpdates(nil);
}
completion:^(BOOL completed) {
if (weakSelf) {
completion();
weakSelf.updating = NO;
}
}];
}
[self updateVisibleCellIdentifiers];
}
// Makes the required changes to `items` and `collectionView` when a new item is
// inserted.
- (void)applyModelAndViewUpdatesForInsertionOfItem:(TabSwitcherItem*)item
atIndex:(NSUInteger)index
selectedItemID:(NSString*)selectedItemID
snapshot:
(NSDiffableDataSourceSnapshot*)
snapshot {
// Consistency check: `item`'s ID is not in `items`.
// (using DCHECK rather than DCHECK_EQ to avoid a checked_cast on NSNotFound).
DCHECK([self indexOfItemWithID:item.identifier] == NSNotFound);
// Store the identifier of the current item at the given index, if any, prior
// to model updates.
NSString* previousItemIdentifierAtIndex;
if (index < self.items.count) {
previousItemIdentifierAtIndex = self.items[index].identifier;
}
[self.items insertObject:item atIndex:index];
self.selectedItemID = selectedItemID;
self.lastInsertedItemID = item.identifier;
[self.delegate gridViewController:self didChangeItemCount:self.items.count];
[self removeEmptyStateAnimated:YES];
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
// The snapshot API doesn't provide a way to insert at a given index (that's
// its purpose actually), only before/after an existing item, or by
// appending to an existing section.
// If the new item is taking the spot of an existing item, insert the new
// one before it. Otherwise (if the section is empty, or the new index is
// the new last position), append at the end of the section.
if (previousItemIdentifierAtIndex) {
[snapshot insertItemsWithIdentifiers:@[ item.identifier ]
beforeItemWithIdentifier:previousItemIdentifierAtIndex];
} else {
[snapshot appendItemsWithIdentifiers:@[ item.identifier ]
intoSectionWithIdentifier:kOpenTabsSectionIdentifier];
}
} else {
[self.collectionView insertItemsAtIndexPaths:@[ CreateIndexPath(index) ]];
}
}
// Makes the required changes when a new item has been inserted.
- (void)modelAndViewUpdatesForInsertionDidCompleteAtIndex:(NSUInteger)index {
[self updateSelectedCollectionViewItemRingAndBringIntoView:YES];
[self.delegate gridViewController:self didChangeItemCount:self.items.count];
// Check `index` boundaries in order to filter out possible race conditions
// while mutating the collection.
if (index == NSNotFound || index >= self.items.count) {
return;
}
[self.collectionView
scrollToItemAtIndexPath:CreateIndexPath(index)
atScrollPosition:UICollectionViewScrollPositionCenteredVertically
animated:YES];
}
// Makes the required changes to `items` and `collectionView` when an existing
// item is removed.
- (void)
applyModelAndViewUpdatesForRemovalOfItemWithID:(NSString*)removedItemID
selectedItemID:(NSString*)selectedItemID
snapshot:
(NSDiffableDataSourceSnapshot*)
snapshot {
NSUInteger index = [self indexOfItemWithID:removedItemID];
[self.items removeObjectAtIndex:index];
self.selectedItemID = selectedItemID;
[self deselectItemWithIDForEditing:removedItemID];
[self.delegate gridViewController:self didChangeItemCount:self.items.count];
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
[snapshot deleteItemsWithIdentifiers:@[ removedItemID ]];
if ([self shouldShowEmptyState]) {
[self animateEmptyStateIn];
}
} else {
[self.collectionView deleteItemsAtIndexPaths:@[ CreateIndexPath(index) ]];
}
if ([self shouldShowEmptyState]) {
[self animateEmptyStateIn];
}
}
// Makes the required changes when a new item has been removed.
- (void)modelAndViewUpdatesForRemovalDidCompleteForItemWithID:
(NSString*)removedItemID {
if (self.items.count > 0) {
[self updateSelectedCollectionViewItemRingAndBringIntoView:NO];
}
[self.delegate gridViewController:self didChangeItemCount:self.items.count];
[self.delegate gridViewController:self didRemoveItemWIthID:removedItemID];
}
// Makes the required changes to `items` and `collectionView` when an existing
// item is moved.
- (void)applyModelAndViewUpdatesForMoveOfItemWithID:(NSString*)itemID
fromIndex:(NSUInteger)fromIndex
toIndex:(NSUInteger)toIndex
snapshot:
(NSDiffableDataSourceSnapshot*)
snapshot {
NSString* toItemID = self.items[toIndex].identifier;
TabSwitcherItem* item = self.items[fromIndex];
[self.items removeObjectAtIndex:fromIndex];
[self.items insertObject:item atIndex:toIndex];
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
if (fromIndex < toIndex) {
[snapshot moveItemWithIdentifier:itemID afterItemWithIdentifier:toItemID];
} else {
[snapshot moveItemWithIdentifier:itemID
beforeItemWithIdentifier:toItemID];
}
} else {
[self.collectionView moveItemAtIndexPath:CreateIndexPath(fromIndex)
toIndexPath:CreateIndexPath(toIndex)];
}
}
// Makes the required changes when a new item has been moved.
- (void)modelAndViewUpdatesForMoveDidCompleteForItemWithID:(NSString*)itemID
toIndex:(NSUInteger)toIndex {
[self.delegate gridViewController:self
didMoveItemWithID:itemID
toIndex:toIndex];
}
#pragma mark - Private properties
- (NSUInteger)selectedIndex {
return [self indexOfItemWithID:self.selectedItemID];
}
- (CGFloat)offsetPastEndOfScrollView {
// Use collectionViewLayout.collectionViewContentSize because it has the
// correct size during a batch update.
return self.collectionView.contentOffset.x +
self.collectionView.frame.size.width -
self.collectionView.collectionViewLayout.collectionViewContentSize
.width;
}
- (void)setCurrentLayout:(FlowLayout*)currentLayout {
_currentLayout = currentLayout;
}
#pragma mark - Private
// Updates the ring to be around the currently selected item. If
// `shouldBringItemIntoView` is true, the collection view scrolls to present the
// selected item at the top.
- (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.
NSUInteger selectedIndex = self.selectedIndex;
// Check `selectedIndex` boundaries in order to filter out possible race
// conditions while mutating the collection.
if (selectedIndex == NSNotFound || selectedIndex >= self.items.count) {
return;
}
NSIndexPath* selectedIndexPath = CreateIndexPath(selectedIndex);
UICollectionViewScrollPosition scrollPosition =
shouldBringItemIntoView ? UICollectionViewScrollPositionTop
: UICollectionViewScrollPositionNone;
[self.collectionView selectItemAtIndexPath:selectedIndexPath
animated:NO
scrollPosition:scrollPosition];
}
- (void)voiceOverStatusDidChange {
self.collectionView.dragInteractionEnabled =
[self shouldEnableDrapAndDropInteraction];
}
- (void)preferredContentSizeCategoryDidChange {
[self.collectionView.collectionViewLayout invalidateLayout];
}
- (BOOL)shouldEnableDrapAndDropInteraction {
// Don't enable drag and drop when voice over is enabled.
return !UIAccessibilityIsVoiceOverRunning();
}
// Returns the index in `self.items` of the first item whose identifier is
// `identifier`.
- (NSUInteger)indexOfItemWithID:(NSString*)identifier {
auto selectedTest =
^BOOL(TabSwitcherItem* item, NSUInteger index, BOOL* stop) {
return [item.identifier isEqualToString:identifier];
};
return [self.items indexOfObjectPassingTest:selectedTest];
}
// 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 {
DCHECK(cell);
DCHECK(item);
cell.delegate = self;
cell.theme = self.theme;
cell.itemIdentifier = item.identifier;
cell.title = item.title;
cell.titleHidden = item.hidesTitle;
cell.accessibilityIdentifier = GridCellAccessibilityIdentifier(index);
if (self.mode == TabGridModeSelection) {
if ([self isItemWithIDSelectedForEditing:item.identifier]) {
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 hasIdentifier:innerItem.identifier]) {
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 hasIdentifier:innerItem.identifier]) {
cell.snapshot = snapshot;
}
}];
NSString* itemIdentifier = item.identifier;
[self.priceCardDataSource
priceCardForIdentifier:itemIdentifier
completion:^(PriceCardItem* priceCardItem) {
if (priceCardItem && [cell hasIdentifier:itemIdentifier])
[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];
}
}
// Tells the delegate that the user tapped the item with identifier
// corresponding to `indexPath`.
// TODO(crbug.com/1350453): Use the "Primary Action" APIs for collection views
// when running under iOS16 instead of overloading selection changes to handle
// cell taps.
- (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;
NSUInteger index = base::checked_cast<NSUInteger>(indexPath.item);
DCHECK_LT(index, self.items.count);
// crbug.com/1163238: In the wild, the above DCHECK condition is hit. This
// might be a case of the UI sending touch events after the model has updated.
// In this case, just no-op; if the user has to tap again to activate a tab,
// that's better than a crash.
if (index >= self.items.count)
return;
NSString* itemID = self.items[index].identifier;
if (_mode == TabGridModeSelection) {
if ([self isItemWithIDSelectedForEditing:itemID]) {
[self deselectItemWithIDForEditing:itemID];
} else {
[self selectItemWithIDForEditing:itemID];
}
// 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.
if (self.selectedItemIDsForEditing.count > 1 &&
!base::ios::IsMultipleScenesSupported()) {
self.collectionView.dragInteractionEnabled = NO;
} else {
self.collectionView.dragInteractionEnabled = YES;
}
// Reconfigure the item.
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
NSDiffableDataSourceSnapshot* snapshot = self.diffableDataSource.snapshot;
[snapshot reconfigureItemsWithIdentifiers:@[ itemID ]];
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
} else {
[UIView performWithoutAnimation:^{
[self.collectionView reloadItemsAtIndexPaths:@[ indexPath ]];
}];
}
}
[self.delegate gridViewController:self didSelectItemWithID:itemID];
}
// Animates the empty state into view.
- (void)animateEmptyStateIn {
// TODO(crbug.com/820410) : 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/820410) : 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.items.count == 0;
}
// Reloads the tabs section of the grid view.
- (void)reloadTabs {
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
NSDiffableDataSourceSnapshot* snapshot = self.diffableDataSource.snapshot;
if (NSNotFound ==
[snapshot indexOfSectionIdentifier:kOpenTabsSectionIdentifier]) {
return;
}
// Delete old tabs if the section is present.
auto oldTabsIdentifiers = [snapshot
itemIdentifiersInSectionWithIdentifier:kOpenTabsSectionIdentifier];
[snapshot deleteItemsWithIdentifiers:oldTabsIdentifiers];
// Add new tabs.
NSString* identifierKey = NSStringFromSelector(@selector(identifier));
[snapshot appendItemsWithIdentifiers:[self.items valueForKey:identifierKey]
intoSectionWithIdentifier:kOpenTabsSectionIdentifier];
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
[self updateVisibleCellIdentifiers];
} else {
NSIndexSet* targetSections =
[NSIndexSet indexSetWithIndex:kOpenTabsSectionIndex];
[UIView performWithoutAnimation:^{
// There is a collection view bug (crbug.com/1300733) that prevents
// CollectionView's `reloadData` from working properly if its preceded by
// CollectionView's `performBatchUpdates:` in the same UI cycle. To avoid
// this bug, `reloadSections:` method is used instead to reload the items
// in the tab grid.
[self.collectionView reloadSections:targetSections];
}];
}
}
// Updates the number of results found on the search open tabs section header.
- (void)updateSearchResultsHeader {
GridHeader* headerView = (GridHeader*)[self.collectionView
supplementaryViewForElementKind:UICollectionElementKindSectionHeader
atIndexPath:
[NSIndexPath
indexPathForRow:0
inSection:kOpenTabsSectionIndex]];
if (!headerView)
return;
NSString* resultsCount = [@(self.items.count) stringValue];
headerView.value =
l10n_util::GetNSStringF(IDS_IOS_TABS_SEARCH_OPEN_TABS_COUNT,
base::SysNSStringToUTF16(resultsCount));
}
// Returns the items at the given index paths.
- (NSSet<TabSwitcherItem*>*)itemsFromIndexPaths:
(NSArray<NSIndexPath*>*)indexPaths {
NSMutableSet<TabSwitcherItem*>* items = [[NSMutableSet alloc] init];
[indexPaths enumerateObjectsUsingBlock:^(NSIndexPath* indexPath,
NSUInteger index, BOOL* stop) {
NSUInteger itemIndex = base::checked_cast<NSUInteger>(indexPath.item);
if (itemIndex < self.items.count) {
[items addObject:self.items[itemIndex]];
}
}];
return items;
}
// Returns the size that should be dedicated the the Inactive Tabs button
// header.
- (CGSize)inactiveTabsButtonHeaderSize {
// Keep a sizing header.
static InactiveTabsButtonHeader* gHeader =
[[InactiveTabsButtonHeader alloc] init];
// Configure it.
[gHeader configureWithDaysThreshold:self.inactiveTabsDaysThreshold];
if (IsShowInactiveTabsCountEnabled()) {
[gHeader configureWithCount:self.inactiveTabsCount];
}
// Get its fitting size.
CGFloat width = CGRectGetWidth(self.collectionView.bounds);
CGSize targetSize = CGSize(width, UILayoutFittingExpandedSize.height);
// Host the view in the hierarchy for it to get the appropriate trait
// collection. This might be due a UIKit/SwiftUI interaction bug, as this is
// not necessary for `InactiveTabsPreambleHeader` below for example.
gHeader.parent = self;
[self.view addSubview:gHeader];
CGSize size =
[gHeader systemLayoutSizeFittingSize:targetSize
withHorizontalFittingPriority:UILayoutPriorityRequired
verticalFittingPriority:UILayoutPriorityFittingSizeLevel];
// De-parent the header.
[gHeader removeFromSuperview];
gHeader.parent = nil;
return CGSizeMake(width, size.height);
}
// Returns the size that should be dedicated the the Inactive Tabs preamble
// header.
- (CGSize)inactiveTabsPreambleHeaderSize {
// Keep a sizing header.
static InactiveTabsPreambleHeader* gHeader =
[[InactiveTabsPreambleHeader alloc] init];
// Configure it.
gHeader.daysThreshold = self.inactiveTabsDaysThreshold;
// Get its fitting size.
CGFloat width = CGRectGetWidth(self.collectionView.bounds);
CGSize targetSize = CGSize(width, UILayoutFittingExpandedSize.height);
CGSize size =
[gHeader systemLayoutSizeFittingSize:targetSize
withHorizontalFittingPriority:UILayoutPriorityRequired
verticalFittingPriority:UILayoutPriorityFittingSizeLevel];
return CGSizeMake(width, size.height);
}
- (void)showInactiveTabsButtonHeader {
// Contrary to `hideInactiveTabsButtonHeader`, this doesn't need to be
// animated.
[self reloadInactiveTabsButtonHeader];
}
- (void)hideInactiveTabsButtonHeader {
NSIndexPath* indexPath = [NSIndexPath indexPathForItem:0
inSection:kOpenTabsSectionIndex];
InactiveTabsButtonHeader* header =
base::apple::ObjCCast<InactiveTabsButtonHeader>([self.collectionView
supplementaryViewForElementKind:UICollectionElementKindSectionHeader
atIndexPath:indexPath]);
if (!header) {
return;
}
self.inactiveTabsHeaderHideAnimationInProgress = YES;
[UIView animateWithDuration:kInactiveTabsHeaderAnimationDuration.InSecondsF()
animations:^{
header.alpha = 0;
[self.collectionView.collectionViewLayout invalidateLayout];
}
completion:^(BOOL finished) {
header.hidden = YES;
self.inactiveTabsHeaderHideAnimationInProgress = NO;
// Update the header to make it entirely disappear once the animation is
// done. This is done after a delay because the completion can be called
// before the animation ended, causing a visual glitch.
__weak __typeof(self) weakSelf = self;
base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
FROM_HERE, base::BindOnce(^{
[weakSelf reloadInactiveTabsButtonHeader];
}),
kInactiveTabsHeaderAnimationDuration);
}];
}
// Reloads the section containing the Inactive Tabs button header.
- (void)reloadInactiveTabsButtonHeader {
// Prevent the animation, as it leads to a jarrying effect when closing all
// inactive tabs: the inactive tabs view controller gets popped, and the
// underlying regular Tab Grid moves tabs up.
// Note: this could be revisited when supporting iPad, as the user could
// have closed all inactive tabs in a different window.
if (base::FeatureList::IsEnabled(kTabGridRefactoring)) {
NSDiffableDataSourceSnapshot* snapshot = self.diffableDataSource.snapshot;
[snapshot reloadSectionsWithIdentifiers:@[ kOpenTabsSectionIdentifier ]];
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
} else {
NSIndexSet* openTabsSection =
[NSIndexSet indexSetWithIndex:kOpenTabsSectionIndex];
[UIView performWithoutAnimation:^{
[self.collectionView reloadSections:openTabsSection];
}];
}
// Make sure to restore the selection. Reloading the section cleared it.
// https://developer.apple.com/forums/thread/656529
[self updateSelectedCollectionViewItemRingAndBringIntoView:NO];
}
// Reconfigures the Inactive Tabs button header.
- (void)updateInactiveTabsButtonHeader {
NSIndexPath* indexPath = [NSIndexPath indexPathForItem:0
inSection:kOpenTabsSectionIndex];
InactiveTabsButtonHeader* header =
base::apple::ObjCCast<InactiveTabsButtonHeader>([self.collectionView
supplementaryViewForElementKind:UICollectionElementKindSectionHeader
atIndexPath:indexPath]);
// Note: At this point, `header` could be nil if not visible, or if the
// supplementary view is not an InactiveTabsButtonHeader.
[self configureInactiveTabsButtonHeader:header];
}
// Reconfigures the Inactive Tabs preamble header.
- (void)updateInactiveTabsPreambleHeader {
NSIndexPath* indexPath = [NSIndexPath indexPathForItem:0
inSection:kOpenTabsSectionIndex];
InactiveTabsPreambleHeader* header =
base::apple::ObjCCast<InactiveTabsPreambleHeader>([self.collectionView
supplementaryViewForElementKind:UICollectionElementKindSectionHeader
atIndexPath:indexPath]);
// Note: At this point, `header` could be nil if not visible, or if the
// supplementary view is not an InactiveTabsPreambleHeader.
header.daysThreshold = self.inactiveTabsDaysThreshold;
}
@end