// Copyright 2023 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/regular/regular_grid_view_controller.h"
#import "base/apple/foundation_util.h"
#import "base/functional/bind.h"
#import "base/task/sequenced_task_runner.h"
#import "components/tab_groups/tab_group_id.h"
#import "ios/chrome/browser/shared/model/web_state_list/tab_group.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/tabs/model/inactive_tabs/features.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_commands.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/group_grid_cell.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/regular/inactive_tabs_button_cell.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/regular/tabs_closure_animation.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_group_item.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_switcher_item.h"
#import "ios/web/public/web_state_id.h"
using base::apple::ObjCCast;
using base::apple::ObjCCastStrict;
namespace {
constexpr base::TimeDelta kInactiveTabsHeaderAnimationDuration =
base::Seconds(0.3);
// Returns the views to animate a tab group closure. If the entire group is to
// be closed, i.e. all tabs of `group_grid_cell` are in
// `indexes_in_group_to_close`, then returns the entire tab group grid cell. If
// only some tabs of the group are to be closed, then it returns only the views
// inside the group view that correspond to the tabs to be closed.
NSArray<UIView*>* GetTabGroupViewsToAnimateClosure(
GroupGridCell* group_grid_cell,
std::set<int> indexes_in_group_to_close) {
CHECK(!indexes_in_group_to_close.empty());
// If the entire group is going to be closed, then animate the entire grid
// cell.
if ((long)indexes_in_group_to_close.size() == group_grid_cell.tabsCount) {
return @[ group_grid_cell ];
}
// If only some of the tabs inside the tab group are going to be closed, then
// animate the corresponding views inside the tab group cell.
NSArray<UIView*>* all_views = [group_grid_cell allGroupTabViews];
NSMutableArray<UIView*>* all_views_to_close = [[NSMutableArray alloc] init];
NSInteger numberOfViews = (NSInteger)all_views.count;
// The last view inside a group can represent a tab or a counter.
NSInteger lastViewIsCounter = group_grid_cell.tabsCount > numberOfViews;
NSInteger numberOfTabViews =
lastViewIsCounter ? numberOfViews - 1 : numberOfViews;
// Loop through all the views that represent tabs. A tab view is animated out
// if that tab is going to be closed.
for (NSInteger index = 0; index < numberOfTabViews; index++) {
if (indexes_in_group_to_close.contains(index)) {
[all_views_to_close addObject:[all_views objectAtIndex:index]];
}
}
// Deal with the view that represents a counter, if it exists. A counter view
// is animated out if it will disappear after the animation.
if (lastViewIsCounter) {
NSInteger numberOfTabsAfterClosure =
group_grid_cell.tabsCount - indexes_in_group_to_close.size();
BOOL animateCounter = numberOfTabsAfterClosure <= numberOfViews;
if (animateCounter) {
[all_views_to_close
addObject:[all_views objectAtIndex:numberOfViews - 1]];
}
}
return all_views_to_close;
}
} // namespace
@implementation RegularGridViewController {
// Tracks if the Inactive Tabs button is being animated out.
BOOL _inactiveTabsHeaderHideAnimationInProgress;
// The number of currently inactive tabs. If there are (inactiveTabsCount > 0)
// and the grid is in TabGridMode::kNormal, a button is displayed at the top,
// advertizing them.
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.
NSInteger _inactiveTabsDaysThreshold;
// The cell registration for inactive tabs button cell.
UICollectionViewCellRegistration* _inactiveTabsButtonCellRegistration;
// The supplementary view registration for the Inactive Tabs button header.
UICollectionViewSupplementaryRegistration*
_inactiveTabsButtonHeaderRegistration;
// The object responsible for animating the tabs closure.
TabsClosureAnimation* _tabsClosureAnimation;
}
#pragma mark - Public
- (void)animateTabsClosureForTabs:(std::set<web::WebStateID>)tabsToClose
groups:
(std::map<tab_groups::TabGroupId, std::set<int>>)
groupsWithTabsToClose
allInactiveTabs:(BOOL)animateAllInactiveTabs
completionHandler:(ProceduralBlock)completionHandler {
NSMutableArray<UIView*>* gridCells = [[NSMutableArray alloc] init];
for (NSIndexPath* path in self.collectionView.indexPathsForVisibleItems) {
GridItemIdentifier* item =
[self.diffableDataSource itemIdentifierForIndexPath:path];
UICollectionViewCell* collectionViewCell =
[self.collectionView cellForItemAtIndexPath:path];
if (item.type == GridItemType::kTab &&
tabsToClose.contains(item.tabSwitcherItem.identifier)) {
[gridCells addObject:collectionViewCell];
} else if (item.type == GridItemType::kGroup &&
groupsWithTabsToClose.contains(
item.tabGroupItem.tabGroup->tab_group_id())) {
[gridCells addObjectsFromArray:
GetTabGroupViewsToAnimateClosure(
ObjCCastStrict<GroupGridCell>(collectionViewCell),
groupsWithTabsToClose[item.tabGroupItem.tabGroup
->tab_group_id()])];
} else if (item.type == GridItemType::kInactiveTabsButton &&
animateAllInactiveTabs) {
[gridCells addObject:collectionViewCell];
}
}
__weak RegularGridViewController* weakSelf = self;
_tabsClosureAnimation =
[[TabsClosureAnimation alloc] initWithWindow:self.view.window
gridCells:gridCells];
[_tabsClosureAnimation animateWithCompletion:^{
[weakSelf onTabsClosureAnimationEndWithCompletion:completionHandler];
}];
}
#pragma mark - Parent's functions
- (BOOL)isContainedGridEmpty {
return _inactiveTabsCount == 0;
}
- (UICollectionReusableView*)headerForSectionAtIndexPath:
(NSIndexPath*)indexPath {
if (IsInactiveTabButtonRefactoringEnabled()) {
return [super headerForSectionAtIndexPath:indexPath];
}
if (self.mode == TabGridMode::kNormal) {
CHECK(IsInactiveTabsAvailable());
// The Regular Tabs grid has a button to inform about the hidden inactive
// tabs.
return [self.collectionView
dequeueConfiguredReusableSupplementaryViewWithRegistration:
_inactiveTabsButtonHeaderRegistration
forIndexPath:indexPath];
}
return [super headerForSectionAtIndexPath:indexPath];
}
- (UICollectionViewCell*)cellForItemAtIndexPath:(NSIndexPath*)indexPath
itemIdentifier:
(GridItemIdentifier*)itemIdentifier {
if (itemIdentifier.type == GridItemType::kInactiveTabsButton) {
CHECK(IsInactiveTabButtonRefactoringEnabled());
UICollectionViewCellRegistration* registration =
_inactiveTabsButtonCellRegistration;
return [self.collectionView
dequeueConfiguredReusableCellWithRegistration:registration
forIndexPath:indexPath
item:itemIdentifier];
}
return [super cellForItemAtIndexPath:indexPath itemIdentifier:itemIdentifier];
}
- (void)createRegistrations {
__weak __typeof(self) weakSelf = self;
if (IsInactiveTabButtonRefactoringEnabled()) {
// Register InactiveTabsButtonCell.
auto configureInactiveTabsButtonCell =
^(InactiveTabsButtonCell* cell, NSIndexPath* indexPath, id item) {
[weakSelf configureInativeTabsButtonCell:cell];
};
_inactiveTabsButtonCellRegistration = [UICollectionViewCellRegistration
registrationWithCellClass:InactiveTabsButtonCell.class
configurationHandler:configureInactiveTabsButtonCell];
} else {
// Register InactiveTabsButtonHeader.
auto configureInactiveTabsButtonHeader =
^(InactiveTabsButtonHeader* header, NSString* elementKind,
NSIndexPath* indexPath) {
[weakSelf configureInactiveTabsButtonHeader:header];
};
_inactiveTabsButtonHeaderRegistration =
[UICollectionViewSupplementaryRegistration
registrationWithSupplementaryClass:[InactiveTabsButtonHeader class]
elementKind:
UICollectionElementKindSectionHeader
configurationHandler:
configureInactiveTabsButtonHeader];
}
[super createRegistrations];
}
- (TabsSectionHeaderType)tabsSectionHeaderTypeForMode:(TabGridMode)mode {
if (IsInactiveTabButtonRefactoringEnabled()) {
// With the refactoring, the base class does the right thing.
return [super tabsSectionHeaderTypeForMode:mode];
}
if (mode == TabGridMode::kNormal) {
if (!IsInactiveTabsAvailable()) {
return TabsSectionHeaderType::kNone;
}
if (self.isClosingAllOrUndoRunning) {
return TabsSectionHeaderType::kNone;
}
if (_inactiveTabsHeaderHideAnimationInProgress) {
return TabsSectionHeaderType::kAnimatingOut;
}
if (_inactiveTabsCount == 0) {
return TabsSectionHeaderType::kNone;
}
return TabsSectionHeaderType::kInactiveTabs;
}
return [super tabsSectionHeaderTypeForMode:mode];
}
- (void)addAdditionalItemsToSnapshot:(GridSnapshot*)snapshot {
if (!IsInactiveTabButtonRefactoringEnabled()) {
return;
}
[self updateInactiveTabsButtonInSnapshot:snapshot];
}
- (void)updateSnapshotForModeUpdate:(GridSnapshot*)snapshot {
if (!IsInactiveTabButtonRefactoringEnabled()) {
return;
}
[self updateInactiveTabsButtonInSnapshot:snapshot];
}
#pragma mark - InactiveTabsInfoConsumer
- (void)updateInactiveTabsCount:(NSInteger)count {
if (_inactiveTabsCount == count) {
return;
}
NSInteger oldCount = _inactiveTabsCount;
_inactiveTabsCount = count;
if (IsInactiveTabButtonRefactoringEnabled()) {
GridSnapshot* snapshot = [self.diffableDataSource snapshot];
[self updateInactiveTabsButtonInSnapshot:snapshot];
} else {
// Update the layout.
[self updateTabsSectionHeaderType];
// 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 (_inactiveTabsDaysThreshold == daysThreshold) {
return;
}
NSInteger oldDaysThreshold = _inactiveTabsDaysThreshold;
_inactiveTabsDaysThreshold = daysThreshold;
if (IsInactiveTabButtonRefactoringEnabled()) {
GridSnapshot* snapshot = [self.diffableDataSource snapshot];
[self updateInactiveTabsButtonInSnapshot:snapshot];
} else {
// 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];
}
}
}
#pragma mark - Actions
// Called when the Inactive Tabs button is tapped.
- (void)didTapInactiveTabsButton {
CHECK(!IsInactiveTabButtonRefactoringEnabled());
[self.delegate didTapInactiveTabsButtonInGridViewController:self];
}
#pragma mark - Private
// Callback of `_tabsClosureAnimation` when the animation has been completed.
// Closes the actual tabs in `tabsToClose`.
- (void)onTabsClosureAnimationEndWithCompletion:
(ProceduralBlock)closeSelectedTabsOnCompletion {
CHECK(closeSelectedTabsOnCompletion);
// Close selected tabs which which rearranges the grid to not include the tabs
// hidden by the animation.
closeSelectedTabsOnCompletion();
_tabsClosureAnimation = nil;
}
// Updates the inactive tabs button (reconfigure, show or remove) based on its
// visible state.
- (void)updateInactiveTabsButtonInSnapshot:(GridSnapshot*)snapshot {
if (!IsInactiveTabsAvailable()) {
return;
}
BOOL isEnabled = _inactiveTabsDaysThreshold != kInactiveTabsDisabledByUser;
BOOL hasInactiveTabs = _inactiveTabsCount != 0;
BOOL isInNormalMode = self.mode == TabGridMode::kNormal;
BOOL visible = isEnabled && hasInactiveTabs && isInNormalMode;
if (visible) {
GridItemIdentifier* item =
[GridItemIdentifier inactiveTabsButtonIdentifier];
if ([snapshot indexOfItemIdentifier:item] != NSNotFound) {
[snapshot reconfigureItemsWithIdentifiers:@[ item ]];
} else {
[self addInactiveTabsButtonToSnapshot:snapshot];
}
} else {
BOOL isSectionInSnapshot =
[snapshot
indexOfSectionIdentifier:kInactiveTabButtonSectionIdentifier] !=
NSNotFound;
if (isSectionInSnapshot) {
[snapshot deleteSectionsWithIdentifiers:@[
kInactiveTabButtonSectionIdentifier
]];
}
}
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:YES];
}
// Adds the inactive tabs button to `snapshot` if it is not there yet.
- (void)addInactiveTabsButtonToSnapshot:(GridSnapshot*)snapshot {
NSInteger sectionIndex =
[snapshot indexOfSectionIdentifier:kInactiveTabButtonSectionIdentifier];
if (sectionIndex == NSNotFound) {
[snapshot
insertSectionsWithIdentifiers:@[ kInactiveTabButtonSectionIdentifier ]
beforeSectionWithIdentifier:kGridOpenTabsSectionIdentifier];
}
GridItemIdentifier* item = [GridItemIdentifier inactiveTabsButtonIdentifier];
if ([snapshot indexOfItemIdentifier:item] == NSNotFound) {
[snapshot appendItemsWithIdentifiers:@[ item ]
intoSectionWithIdentifier:kInactiveTabButtonSectionIdentifier];
}
}
- (void)showInactiveTabsButtonHeader {
CHECK(!IsInactiveTabButtonRefactoringEnabled());
// Contrary to `hideInactiveTabsButtonHeader`, this doesn't need to be
// animated.
[self reloadInactiveTabsButtonHeader];
}
- (void)hideInactiveTabsButtonHeader {
CHECK(!IsInactiveTabButtonRefactoringEnabled());
NSInteger tabSectionIndex = [self.diffableDataSource
indexForSectionIdentifier:kGridOpenTabsSectionIdentifier];
NSIndexPath* indexPath = [NSIndexPath indexPathForItem:0
inSection:tabSectionIndex];
InactiveTabsButtonHeader* header =
ObjCCast<InactiveTabsButtonHeader>([self.collectionView
supplementaryViewForElementKind:UICollectionElementKindSectionHeader
atIndexPath:indexPath]);
if (!header) {
return;
}
_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 {
CHECK(!IsInactiveTabButtonRefactoringEnabled());
// Prevent the animation, as it leads to a jarring 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.
GridSnapshot* snapshot = self.diffableDataSource.snapshot;
[snapshot reloadSectionsWithIdentifiers:@[ kGridOpenTabsSectionIdentifier ]];
[self.diffableDataSource applySnapshot:snapshot animatingDifferences:NO];
// 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 {
CHECK(!IsInactiveTabButtonRefactoringEnabled());
NSInteger tabSectionIndex = [self.diffableDataSource
indexForSectionIdentifier:kGridOpenTabsSectionIdentifier];
NSIndexPath* indexPath = [NSIndexPath indexPathForItem:0
inSection:tabSectionIndex];
InactiveTabsButtonHeader* header =
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];
}
// Configures `cell` according to the current state.
- (void)configureInativeTabsButtonCell:(InactiveTabsButtonCell*)cell {
cell.count = _inactiveTabsCount;
cell.daysThreshold = _inactiveTabsDaysThreshold;
}
// Configures the Inactive Tabs Button header according to the current state.
- (void)configureInactiveTabsButtonHeader:(InactiveTabsButtonHeader*)header {
CHECK(!IsInactiveTabButtonRefactoringEnabled());
header.parent = self;
__weak __typeof(self) weakSelf = self;
header.buttonAction = ^{
[weakSelf didTapInactiveTabsButton];
};
[header configureWithDaysThreshold:_inactiveTabsDaysThreshold];
[header configureWithCount:_inactiveTabsCount];
header.hidden = _inactiveTabsCount == 0;
}
@end