// Copyright 2024 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/tab_groups/tab_groups_panel_view_controller.h"
#import <QuartzCore/QuartzCore.h>
#import "base/apple/foundation_util.h"
#import "base/check.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "ios/chrome/browser/shared/ui/symbols/symbols.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/ui/menu/action_factory.h"
#import "ios/chrome/browser/ui/menu/menu_histograms.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_state_view.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_grid_paging.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_groups/tab_groups_constants.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_groups/tab_groups_panel_cell.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_groups/tab_groups_panel_favicon_grid.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_groups/tab_groups_panel_item.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_groups/tab_groups_panel_item_data.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_groups/tab_groups_panel_mutator.h"
#import "ios/public/provider/chrome/browser/modals/modals_api.h"
namespace {
// Layout.
const CGFloat kInterItemSpacing = 24;
const CGFloat kInterGroupSpacing = 16;
const CGFloat kEstimatedItemHeight = 96;
// The minimum width to display two columns. Under that value, display only one
// column.
const CGFloat kColumnCountWidthThreshold = 1000;
const CGFloat kVerticalInset = 20;
const CGFloat kLargeVerticalInset = 40;
const CGFloat kHorizontalInset = 16;
// Percentage of the width dedicated to an horizontal inset when there is only
// one centered element.
const CGFloat kHorizontalInsetPercentageWhenLargeAndOneItem = 0.3125;
// Percentage of the width dedicated to an horizontal inset when there are two
// elements.
const CGFloat kHorizontalInsetPercentageWhenLargeAndTwoItems = 0.125;
NSString* const kTabGroupsSection = @"TabGroups";
typedef NSDiffableDataSourceSnapshot<NSString*, TabGroupsPanelItem*>
TabGroupsPanelSnapshot;
} // namespace
@interface TabGroupsPanelViewController () <UICollectionViewDelegate>
@end
@implementation TabGroupsPanelViewController {
UICollectionView* _collectionView;
UICollectionViewDiffableDataSource<NSString*, TabGroupsPanelItem*>*
_dataSource;
TabGridEmptyStateView* _emptyStateView;
UIViewPropertyAnimator* _emptyStateAnimator;
}
- (void)viewDidLoad {
[super viewDidLoad];
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
self.view.accessibilityIdentifier = kTabGroupsPanelIdentifier;
_collectionView =
[[UICollectionView alloc] initWithFrame:self.view.bounds
collectionViewLayout:[self createLayout]];
_collectionView.allowsSelection = NO;
_collectionView.backgroundColor = UIColor.clearColor;
// CollectionView, in contrast to TableView, doesn’t inset the
// cell content to the safe area guide by default. We will just manage the
// collectionView contentInset manually to fit in the safe area instead.
_collectionView.contentInsetAdjustmentBehavior =
UIScrollViewContentInsetAdjustmentNever;
_collectionView.backgroundView = [[UIView alloc] init];
_collectionView.backgroundColor = UIColor.clearColor;
_collectionView.delegate = self;
_collectionView.autoresizingMask =
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:_collectionView];
__weak __typeof(self) weakSelf = self;
UICollectionViewCellRegistration* registration =
[UICollectionViewCellRegistration
registrationWithCellClass:[TabGroupsPanelCell class]
configurationHandler:^(TabGroupsPanelCell* cell,
NSIndexPath* indexPath,
TabGroupsPanelItem* item) {
[weakSelf configureCell:cell withItem:item];
}];
_dataSource = [[UICollectionViewDiffableDataSource alloc]
initWithCollectionView:_collectionView
cellProvider:^(UICollectionView* collectionView,
NSIndexPath* indexPath,
TabGroupsPanelItem* item) {
return [collectionView
dequeueConfiguredReusableCellWithRegistration:registration
forIndexPath:indexPath
item:item];
}];
_emptyStateView =
[[TabGridEmptyStateView alloc] initWithPage:TabGridPageTabGroups];
_emptyStateView.scrollViewContentInsets =
_collectionView.adjustedContentInset;
_emptyStateView.translatesAutoresizingMaskIntoConstraints = NO;
[_collectionView.backgroundView addSubview:_emptyStateView];
UILayoutGuide* safeAreaGuide =
_collectionView.backgroundView.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:@[
[_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],
]];
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
[_collectionView.collectionViewLayout invalidateLayout];
}
#pragma mark Public
- (BOOL)isScrolledToTop {
return IsScrollViewScrolledToTop(_collectionView);
}
- (BOOL)isScrolledToBottom {
return IsScrollViewScrolledToBottom(_collectionView);
}
- (void)setContentInsets:(UIEdgeInsets)contentInsets {
// Set the vertical insets on the collection view…
_collectionView.contentInset =
UIEdgeInsetsMake(contentInsets.top, 0, contentInsets.bottom, 0);
// … and the horizontal insets on the layout sections.
// This is a workaround, as setting the horizontal insets on the collection
// view isn't honored by the layout when computing the item sizes (items are
// too big in landscape iPhones with a notch or Dynamic Island).
_contentInsets = contentInsets;
[_collectionView.collectionViewLayout invalidateLayout];
}
- (void)prepareForAppearance {
[_collectionView reloadData];
}
#pragma mark TabGroupsPanelConsumer
- (void)populateItems:(NSArray<TabGroupsPanelItem*>*)items {
const BOOL hadOneItem = [self hasOnlyOneItem];
// Update the data source.
CHECK(_dataSource);
TabGroupsPanelSnapshot* snapshot = [[TabGroupsPanelSnapshot alloc] init];
[snapshot appendSectionsWithIdentifiers:@[ kTabGroupsSection ]];
[snapshot appendItemsWithIdentifiers:items];
[snapshot reconfigureItemsWithIdentifiers:items];
[_dataSource applySnapshot:snapshot animatingDifferences:YES];
// Invalidate the layout when getting to or coming from 1 item.
if (hadOneItem != [self hasOnlyOneItem]) {
[_collectionView.collectionViewLayout invalidateLayout];
}
if ([self shouldShowEmptyState]) {
[self animateEmptyStateIn];
} else {
[self removeEmptyStateAnimated:YES];
}
}
- (void)reconfigureItem:(TabGroupsPanelItem*)item {
TabGroupsPanelSnapshot* snapshot = [_dataSource snapshot];
if ([snapshot indexOfItemIdentifier:item] == NSNotFound) {
return;
}
[snapshot reconfigureItemsWithIdentifiers:@[ item ]];
[_dataSource applySnapshot:snapshot animatingDifferences:YES];
}
- (void)dismissModals {
ios::provider::DismissModalsForCollectionView(_collectionView);
}
#pragma mark UICollectionViewDelegate
- (void)collectionView:(UICollectionView*)collectionView
performPrimaryActionForItemAtIndexPath:(NSIndexPath*)indexPath {
base::RecordAction(base::UserMetricsAction("MobileGroupPanelOpenGroup"));
TabGroupsPanelItem* item = [_dataSource itemIdentifierForIndexPath:indexPath];
[self.mutator selectTabGroupsPanelItem:item];
}
- (UIContextMenuConfiguration*)collectionView:(UICollectionView*)collectionView
contextMenuConfigurationForItemAtIndexPath:(NSIndexPath*)indexPath
point:(CGPoint)point {
UICollectionViewCell* collectionViewCell =
[_collectionView cellForItemAtIndexPath:indexPath];
TabGroupsPanelCell* cell =
base::apple::ObjCCastStrict<TabGroupsPanelCell>(collectionViewCell);
return
[self contextMenuConfigurationForCell:cell
menuScenario:
kMenuScenarioHistogramTabGroupsPanelEntry];
}
#pragma mark UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
[self.UIDelegate tabGroupsPanelViewControllerDidScroll:self];
}
- (void)scrollViewDidChangeAdjustedContentInset:(UIScrollView*)scrollView {
_emptyStateView.scrollViewContentInsets = scrollView.contentInset;
}
#pragma mark Private
// Returns whether the data source has only one item. It's used to configure the
// layout.
- (BOOL)hasOnlyOneItem {
return _dataSource.snapshot.numberOfItems == 1;
}
// Returns the compositional layout for the Tab Groups panel.
- (UICollectionViewLayout*)createLayout {
__weak __typeof(self) weakSelf = self;
UICollectionViewLayout* layout = [[UICollectionViewCompositionalLayout alloc]
initWithSectionProvider:^(
NSInteger sectionIndex,
id<NSCollectionLayoutEnvironment> layoutEnvironment) {
return [weakSelf makeSectionWithLayoutEnvironment:layoutEnvironment];
}];
return layout;
}
// Returns the layout section for the Tab Groups panel.
- (NSCollectionLayoutSection*)makeSectionWithLayoutEnvironment:
(id<NSCollectionLayoutEnvironment>)layoutEnvironment {
const BOOL onlyOneItem = [self hasOnlyOneItem];
const CGFloat width = layoutEnvironment.container.effectiveContentSize.width;
const BOOL hasLargeWidth = width > kColumnCountWidthThreshold;
const NSInteger columnCount = hasLargeWidth && !onlyOneItem ? 2 : 1;
NSCollectionLayoutDimension* itemWidth =
[NSCollectionLayoutDimension fractionalWidthDimension:1. / columnCount];
NSCollectionLayoutDimension* itemHeight =
[NSCollectionLayoutDimension estimatedDimension:kEstimatedItemHeight];
NSCollectionLayoutSize* itemSize =
[NSCollectionLayoutSize sizeWithWidthDimension:itemWidth
heightDimension:itemHeight];
NSCollectionLayoutItem* item =
[NSCollectionLayoutItem itemWithLayoutSize:itemSize];
NSCollectionLayoutDimension* groupWidth =
[NSCollectionLayoutDimension fractionalWidthDimension:1];
NSCollectionLayoutSize* groupSize =
[NSCollectionLayoutSize sizeWithWidthDimension:groupWidth
heightDimension:itemHeight];
NSCollectionLayoutGroup* group =
[NSCollectionLayoutGroup horizontalGroupWithLayoutSize:groupSize
repeatingSubitem:item
count:columnCount];
group.interItemSpacing =
[NSCollectionLayoutSpacing fixedSpacing:kInterItemSpacing];
CGFloat groupHorizontalInset = kHorizontalInset;
const BOOL hasLargeInset =
layoutEnvironment.traitCollection.horizontalSizeClass ==
UIUserInterfaceSizeClassRegular;
if (hasLargeInset) {
groupHorizontalInset =
width * kHorizontalInsetPercentageWhenLargeAndTwoItems;
if (hasLargeWidth && onlyOneItem) {
groupHorizontalInset =
width * kHorizontalInsetPercentageWhenLargeAndOneItem;
}
}
group.contentInsets = NSDirectionalEdgeInsetsMake(0, groupHorizontalInset, 0,
groupHorizontalInset);
NSCollectionLayoutSection* section =
[NSCollectionLayoutSection sectionWithGroup:group];
section.interGroupSpacing = kInterGroupSpacing;
const CGFloat sectionVerticalInset =
hasLargeInset ? kLargeVerticalInset : kVerticalInset;
// Use the `_contentInsets` horizontal insets. See `setContentInsets:` for
// more details.
section.contentInsets = NSDirectionalEdgeInsetsMake(
sectionVerticalInset, self.contentInsets.left, sectionVerticalInset,
self.contentInsets.right);
return section;
}
// Whether to show the empty state view.
- (BOOL)shouldShowEmptyState {
return _dataSource.snapshot.numberOfItems == 0;
}
// Animates the empty state into view.
- (void)animateEmptyStateIn {
// TODO(crbug.com/40566436) : Polish the animation, and put constants where
// they belong.
[_emptyStateAnimator stopAnimation:YES];
UIView* emptyStateView = _emptyStateView;
_emptyStateAnimator = [[UIViewPropertyAnimator alloc]
initWithDuration:1.0 - _emptyStateView.alpha
dampingRatio:1.0
animations:^{
emptyStateView.alpha = 1.0;
emptyStateView.transform = CGAffineTransformIdentity;
}];
[_emptyStateAnimator startAnimation];
}
// Removes the empty state out of view, with animation if `animated` is YES.
- (void)removeEmptyStateAnimated:(BOOL)animated {
// TODO(crbug.com/40566436) : Polish the animation, and put constants where
// they belong.
[_emptyStateAnimator stopAnimation:YES];
UIView* emptyStateView = _emptyStateView;
auto removeEmptyState = ^{
emptyStateView.alpha = 0.0;
emptyStateView.transform = CGAffineTransformScale(CGAffineTransformIdentity,
/*sx=*/0.9, /*sy=*/0.9);
};
if (animated) {
_emptyStateAnimator =
[[UIViewPropertyAnimator alloc] initWithDuration:_emptyStateView.alpha
dampingRatio:1.0
animations:removeEmptyState];
[_emptyStateAnimator startAnimation];
} else {
removeEmptyState();
}
}
- (void)configureCell:(TabGroupsPanelCell*)cell
withItem:(TabGroupsPanelItem*)item {
cell.item = item;
TabGroupsPanelItemData* itemData = [_itemDataSource dataForItem:item];
cell.titleLabel.text = itemData.title;
cell.dot.backgroundColor = itemData.color;
cell.subtitleLabel.text = itemData.creationText;
NSUInteger numberOfTabs = itemData.numberOfTabs;
cell.faviconsGrid.numberOfTabs = numberOfTabs;
cell.faviconsGrid.favicon1 = nil;
cell.faviconsGrid.favicon2 = nil;
cell.faviconsGrid.favicon3 = nil;
cell.faviconsGrid.favicon4 = nil;
UIImage* fallbackImage = DefaultSymbolWithPointSize(kGlobeAmericasSymbol, 16);
if (numberOfTabs >= 1) {
cell.faviconsGrid.favicon1 = fallbackImage;
[_itemDataSource fetchFaviconForItem:item
index:0
completion:^(UIImage* favicon) {
if ([cell.item isEqual:item] && favicon) {
cell.faviconsGrid.favicon1 = favicon;
}
}];
}
if (numberOfTabs >= 2) {
cell.faviconsGrid.favicon2 = fallbackImage;
[_itemDataSource fetchFaviconForItem:item
index:1
completion:^(UIImage* favicon) {
if ([cell.item isEqual:item] && favicon) {
cell.faviconsGrid.favicon2 = favicon;
}
}];
}
if (numberOfTabs >= 3) {
cell.faviconsGrid.favicon3 = fallbackImage;
[_itemDataSource fetchFaviconForItem:item
index:2
completion:^(UIImage* favicon) {
if ([cell.item isEqual:item] && favicon) {
cell.faviconsGrid.favicon3 = favicon;
}
}];
}
if (numberOfTabs == 4) {
cell.faviconsGrid.favicon4 = fallbackImage;
[_itemDataSource fetchFaviconForItem:item
index:3
completion:^(UIImage* favicon) {
if ([cell.item isEqual:item] && favicon) {
cell.faviconsGrid.favicon4 = favicon;
}
}];
}
}
// Returns a context menu configuration instance for the given cell in the tab
// groups panel.
- (UIContextMenuConfiguration*)
contextMenuConfigurationForCell:(TabGroupsPanelCell*)cell
menuScenario:(MenuScenarioHistogram)scenario {
// Record that this context menu was shown to the user.
RecordMenuShown(scenario);
ActionFactory* actionFactory =
[[ActionFactory alloc] initWithScenario:scenario];
__weak TabGroupsPanelViewController* weakSelf = self;
NSMutableArray<UIMenuElement*>* menuElements = [[NSMutableArray alloc] init];
[menuElements addObject:[actionFactory actionToDeleteTabGroupWithBlock:^{
[weakSelf.mutator deleteTabGroupsPanelItem:cell.item
sourceView:cell];
}]];
UIContextMenuActionProvider actionProvider =
^(NSArray<UIMenuElement*>* suggestedActions) {
return [UIMenu menuWithTitle:@"" children:menuElements];
};
return
[UIContextMenuConfiguration configurationWithIdentifier:nil
previewProvider:nil
actionProvider:actionProvider];
}
@end