blob: 027ade5171980e23407d84b443d1e3d95e48b2ce [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// 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_grid/tab_grid_view_controller.h"
#include "base/metrics/user_metrics.h"
#include "base/metrics/user_metrics_action.h"
#include "base/strings/sys_string_conversions.h"
#import "ios/chrome/browser/ui/commands/application_commands.h"
#import "ios/chrome/browser/ui/recent_tabs/recent_tabs_table_view_controller.h"
#import "ios/chrome/browser/ui/rtl_geometry.h"
#import "ios/chrome/browser/ui/tab_grid/grid/grid_commands.h"
#import "ios/chrome/browser/ui/tab_grid/grid/grid_constants.h"
#import "ios/chrome/browser/ui/tab_grid/grid/grid_consumer.h"
#import "ios/chrome/browser/ui/tab_grid/grid/grid_image_data_source.h"
#import "ios/chrome/browser/ui/tab_grid/grid/grid_view_controller.h"
#import "ios/chrome/browser/ui/tab_grid/tab_grid_bottom_toolbar.h"
#import "ios/chrome/browser/ui/tab_grid/tab_grid_constants.h"
#import "ios/chrome/browser/ui/tab_grid/tab_grid_empty_state_view.h"
#import "ios/chrome/browser/ui/tab_grid/tab_grid_new_tab_button.h"
#import "ios/chrome/browser/ui/tab_grid/tab_grid_page_control.h"
#import "ios/chrome/browser/ui/tab_grid/tab_grid_top_toolbar.h"
#import "ios/chrome/browser/ui/table_view/chrome_table_view_styler.h"
#import "ios/chrome/browser/ui/uikit_ui_util.h"
#import "ios/chrome/common/ui_util/constraints_ui_util.h"
#include "ios/chrome/grit/ios_strings.h"
#include "ui/base/l10n/l10n_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
// Types of configurations of this view controller.
typedef NS_ENUM(NSUInteger, TabGridConfiguration) {
TabGridConfigurationBottomToolbar = 1,
TabGridConfigurationFloatingButton,
};
// Computes the page from the offset and width of |scrollView|.
TabGridPage GetPageFromScrollView(UIScrollView* scrollView) {
CGFloat pageWidth = scrollView.frame.size.width;
NSUInteger page = lround(scrollView.contentOffset.x / pageWidth);
if (UseRTLLayout()) {
// In RTL, page indexes are inverted, so subtract |page| from the highest-
// index TabGridPage value.
return static_cast<TabGridPage>(TabGridPageRemoteTabs - page);
}
return static_cast<TabGridPage>(page);
}
NSUInteger GetPageIndexFromPage(TabGridPage page) {
if (UseRTLLayout()) {
// In RTL, page indexes are inverted, so subtract |page| from the highest-
// index TabGridPage value.
return static_cast<NSUInteger>(TabGridPageRemoteTabs - page);
}
return static_cast<NSUInteger>(page);
}
} // namespace
@interface TabGridViewController ()<GridViewControllerDelegate,
UIScrollViewAccessibilityDelegate>
// It is programmer error to broadcast incognito content visibility when the
// view is not visible. Bookkeeping is based on |-viewWillAppear:| and
// |-viewWillDisappear methods. Note that the |Did| methods are not reliably
// called (e.g., edge case in multitasking).
@property(nonatomic, assign) BOOL broadcasting;
// Child view controllers.
@property(nonatomic, strong) GridViewController* regularTabsViewController;
@property(nonatomic, strong) GridViewController* incognitoTabsViewController;
// Other UI components.
@property(nonatomic, weak) UIScrollView* scrollView;
@property(nonatomic, weak) UIView* scrollContentView;
@property(nonatomic, weak) TabGridTopToolbar* topToolbar;
@property(nonatomic, weak) TabGridBottomToolbar* bottomToolbar;
@property(nonatomic, weak) UIButton* doneButton;
@property(nonatomic, weak) UIButton* closeAllButton;
@property(nonatomic, assign) BOOL undoCloseAllAvailable;
// Clang does not allow property getters to start with the reserved word "new",
// but provides a workaround. The getter must be set before the property is
// declared.
- (TabGridNewTabButton*)newTabButton __attribute__((objc_method_family(none)));
@property(nonatomic, weak) TabGridNewTabButton* newTabButton;
@property(nonatomic, weak) TabGridNewTabButton* floatingButton;
@property(nonatomic, assign) TabGridConfiguration configuration;
// Setting the current page will adjust the scroll view to the correct position.
@property(nonatomic, assign) TabGridPage currentPage;
@end
@implementation TabGridViewController
// Public properties.
@synthesize dispatcher = _dispatcher;
@synthesize tabPresentationDelegate = _tabPresentationDelegate;
@synthesize regularTabsDelegate = _regularTabsDelegate;
@synthesize incognitoTabsDelegate = _incognitoTabsDelegate;
@synthesize regularTabsImageDataSource = _regularTabsImageDataSource;
@synthesize incognitoTabsImageDataSource = _incognitoTabsImageDataSource;
// TabGridPaging property.
@synthesize activePage = _activePage;
// Private properties.
@synthesize broadcasting = _broadcasting;
@synthesize regularTabsViewController = _regularTabsViewController;
@synthesize incognitoTabsViewController = _incognitoTabsViewController;
@synthesize remoteTabsViewController = _remoteTabsViewController;
@synthesize scrollView = _scrollView;
@synthesize scrollContentView = _scrollContentView;
@synthesize topToolbar = _topToolbar;
@synthesize bottomToolbar = _bottomToolbar;
@synthesize doneButton = _doneButton;
@synthesize closeAllButton = _closeAllButton;
@synthesize undoCloseAllAvailable = _undoCloseAllAvailable;
@synthesize newTabButton = _newTabButton;
@synthesize floatingButton = _floatingButton;
@synthesize configuration = _configuration;
@synthesize currentPage = _currentPage;
- (instancetype)init {
if (self = [super init]) {
_regularTabsViewController = [[GridViewController alloc] init];
_incognitoTabsViewController = [[GridViewController alloc] init];
_remoteTabsViewController = [[RecentTabsTableViewController alloc] init];
}
return self;
}
#pragma mark - UIViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = UIColorFromRGB(kGridBackgroundColor);
[self setupScrollView];
[self setupIncognitoTabsViewController];
[self setupRegularTabsViewController];
[self setupRemoteTabsViewController];
[self setupTopToolbar];
[self setupBottomToolbar];
[self setupFloatingButton];
// Hide the toolbars and the floating button, so they can fade in the first
// time there's a transition into this view controller.
[self hideToolbars];
}
- (void)viewWillAppear:(BOOL)animated {
self.broadcasting = YES;
[self.topToolbar.pageControl setSelectedPage:self.currentPage animated:YES];
[self configureViewControllerForCurrentSizeClassesAndPage];
// The toolbars should be hidden (alpha 0.0) before the tab appears, so that
// they can be animated in. They can't be set to 0.0 here, because if
// |animated| is YES, this method is being called inside the animation block.
if (animated && self.transitionCoordinator) {
[self animateToolbarsForAppearance];
} else {
[self showToolbars];
}
[self broadcastIncognitoContentVisibility];
[super viewWillAppear:animated];
}
- (void)viewWillDisappear:(BOOL)animated {
self.undoCloseAllAvailable = NO;
[self.regularTabsDelegate discardSavedClosedItems];
// When the view disappears, the toolbar alpha should be set to 0; either as
// part of the animation, or directly with -hideToolbars.
if (animated && self.transitionCoordinator) {
[self animateToolbarsForDisappearance];
} else {
[self hideToolbars];
}
self.broadcasting = NO;
[super viewWillDisappear:animated];
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
// Call the current page setter to sync the scroll view offset to the current
// page value, if the scroll view isn't scrolling. Don't animate this.
if (!self.scrollView.dragging && !self.scrollView.decelerating) {
self.currentPage = _currentPage;
}
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
// The content inset of the tab grids must be modified so that the toolbars
// do not obscure the tabs. This may change depending on orientation.
CGFloat bottomInset = self.configuration == TabGridConfigurationBottomToolbar
? self.bottomToolbar.intrinsicContentSize.height
: 0;
UIEdgeInsets contentInset = UIEdgeInsetsMake(
self.topToolbar.intrinsicContentSize.height, 0, bottomInset, 0);
[self setInsetForRemoteTabs:contentInset];
[self setInsetForGridViews:contentInset];
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
auto animate = ^(id<UIViewControllerTransitionCoordinatorContext> context) {
// Call the current page setter to sync the scroll view offset to the
// current page value.
self.currentPage = _currentPage;
[self configureViewControllerForCurrentSizeClassesAndPage];
};
[coordinator animateAlongsideTransition:animate completion:nil];
}
- (UIStatusBarStyle)preferredStatusBarStyle {
return UIStatusBarStyleLightContent;
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
if (scrollView.dragging || scrollView.decelerating) {
// Only when user initiates scroll through dragging.
CGFloat offsetWidth =
self.scrollView.contentSize.width - self.scrollView.frame.size.width;
CGFloat offset = scrollView.contentOffset.x / offsetWidth;
// In RTL, flip the offset.
if (UseRTLLayout())
offset = 1.0 - offset;
self.topToolbar.pageControl.sliderPosition = offset;
TabGridPage page = GetPageFromScrollView(scrollView);
if (page != _currentPage) {
_currentPage = page;
[self broadcastIncognitoContentVisibility];
[self configureButtonsForActiveAndCurrentPage];
// Records when the user drags the scrollView to switch pages.
[self recordActionSwitchingToPage:_currentPage];
}
}
}
- (void)scrollViewWillBeginDragging:(UIScrollView*)scrollView {
// Disable the page control when the user drags on the scroll view since
// tapping on the page control during scrolling can result in erratic
// scrolling.
self.topToolbar.pageControl.userInteractionEnabled = NO;
}
- (void)scrollViewDidEndDragging:(UIScrollView*)scrollView
willDecelerate:(BOOL)decelerate {
// Re-enable the page control since the user isn't dragging anymore.
self.topToolbar.pageControl.userInteractionEnabled = YES;
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView*)scrollView {
_currentPage = GetPageFromScrollView(scrollView);
[self broadcastIncognitoContentVisibility];
[self configureButtonsForActiveAndCurrentPage];
}
#pragma mark - UIScrollViewAccessibilityDelegate
- (NSString*)accessibilityScrollStatusForScrollView:(UIScrollView*)scrollView {
// This reads the new page whenever the user scrolls in VoiceOver.
int stringID;
switch (self.currentPage) {
case TabGridPageIncognitoTabs:
stringID = IDS_IOS_TAB_GRID_INCOGNITO_TABS_TITLE;
break;
case TabGridPageRegularTabs:
stringID = IDS_IOS_TAB_GRID_REGULAR_TABS_TITLE;
break;
case TabGridPageRemoteTabs:
stringID = IDS_IOS_TAB_GRID_REMOTE_TABS_TITLE;
break;
}
return l10n_util::GetNSString(stringID);
}
#pragma mark - GridTransitionStateProviding properties
- (BOOL)isSelectedCellVisible {
if (self.activePage != self.currentPage)
return NO;
GridViewController* gridViewController =
[self gridViewControllerForPage:self.activePage];
return gridViewController == nil ? NO
: gridViewController.selectedCellVisible;
}
- (GridTransitionLayout*)layoutForTransitionContext:
(id<UIViewControllerContextTransitioning>)context {
GridViewController* gridViewController =
[self gridViewControllerForPage:self.activePage];
return gridViewController == nil ? nil
: [gridViewController transitionLayout];
}
- (UIView*)proxyContainerForTransitionContext:
(id<UIViewControllerContextTransitioning>)context {
return self.view;
}
- (UIView*)proxyPositionForTransitionContext:
(id<UIViewControllerContextTransitioning>)context {
return self.floatingButton;
}
#pragma mark - Public
- (id<GridConsumer>)regularTabsConsumer {
return self.regularTabsViewController;
}
- (void)setRegularTabsImageDataSource:
(id<GridImageDataSource>)regularTabsImageDataSource {
self.regularTabsViewController.imageDataSource = regularTabsImageDataSource;
_regularTabsImageDataSource = regularTabsImageDataSource;
}
- (id<GridConsumer>)incognitoTabsConsumer {
return self.incognitoTabsViewController;
}
- (void)setIncognitoTabsImageDataSource:
(id<GridImageDataSource>)incognitoTabsImageDataSource {
self.incognitoTabsViewController.imageDataSource =
incognitoTabsImageDataSource;
_incognitoTabsImageDataSource = incognitoTabsImageDataSource;
}
- (id<RecentTabsTableConsumer>)remoteTabsConsumer {
return self.remoteTabsViewController;
}
#pragma mark - TabGridPaging
- (void)setActivePage:(TabGridPage)activePage {
[self setCurrentPage:activePage animated:YES];
_activePage = activePage;
}
#pragma mark - Private
// Sets the proper insets for the Remote Tabs ViewController to accomodate for
// the safe area, toolbar, and status bar.
- (void)setInsetForRemoteTabs:(UIEdgeInsets)inset {
// TableView uses SafeArea to adjust insets, so no need to add them to
// contentInset.
if (@available(iOS 11, *)) {
// Left or right side (depending on rtl) is missing correct safe area
// inset upon rotation. Manually correct it. This is because the leading
// constraint of the tableView is not directly bound to the scroll view's
// margins.
UIEdgeInsets safeArea = self.scrollView.safeAreaInsets;
if (UseRTLLayout()) {
inset.right +=
safeArea.right -
self.remoteTabsViewController.tableView.safeAreaInsets.right;
} else {
inset.left += safeArea.left -
self.remoteTabsViewController.tableView.safeAreaInsets.left;
}
// Ensure that the View Controller doesn't have safe area inset that already
// covers the view's bounds.
DCHECK(!CGRectIsEmpty(UIEdgeInsetsInsetRect(
self.remoteTabsViewController.tableView.bounds,
self.remoteTabsViewController.tableView.safeAreaInsets)));
self.remoteTabsViewController.additionalSafeAreaInsets = inset;
} else {
// Must manually account for status bar in pre-iOS 11.
inset.top += self.topLayoutGuide.length;
self.remoteTabsViewController.tableView.contentInset = inset;
}
}
// Sets the proper insets for the Grid ViewControllers to accomodate for the
// safe area and toolbar.
- (void)setInsetForGridViews:(UIEdgeInsets)inset {
if (@available(iOS 11, *)) {
inset.left = self.scrollView.safeAreaInsets.left;
inset.right = self.scrollView.safeAreaInsets.right;
inset.top += self.scrollView.safeAreaInsets.top;
inset.bottom += self.scrollView.safeAreaInsets.bottom;
} else {
// Must manually account for status bar in pre-iOS 11.
inset.top += self.topLayoutGuide.length;
}
self.incognitoTabsViewController.gridView.contentInset = inset;
self.regularTabsViewController.gridView.contentInset = inset;
}
// Returns the corresponding GridViewController for |page|. Returns |nil| if
// page does not have a corresponding GridViewController.
- (GridViewController*)gridViewControllerForPage:(TabGridPage)page {
switch (page) {
case TabGridPageIncognitoTabs:
return self.incognitoTabsViewController;
case TabGridPageRegularTabs:
return self.regularTabsViewController;
case TabGridPageRemoteTabs:
return nil;
}
}
- (void)setCurrentPage:(TabGridPage)currentPage {
// Setting the current page will adjust the scroll view to the correct
// position.
[self setCurrentPage:currentPage animated:NO];
}
// Sets the value of |currentPage|, adjusting the position of the scroll view
// to match. If |animated| is YES, the scroll view change may animate; if it is
// NO, it will never animate.
- (void)setCurrentPage:(TabGridPage)currentPage animated:(BOOL)animated {
// This method should never early return if |currentPage| == |_currentPage|;
// the ivar may have been set before the scroll view could be updated. Calling
// this method should always update the scroll view's offset if possible.
// If the view isn't loaded yet, just do bookkeeping on _currentPage.
if (!self.viewLoaded) {
_currentPage = currentPage;
return;
}
CGFloat pageWidth = self.scrollView.frame.size.width;
NSUInteger pageIndex = GetPageIndexFromPage(currentPage);
CGPoint offset = CGPointMake(pageIndex * pageWidth, 0);
// If the view is visible and |animated| is YES, animate the change.
// Otherwise don't.
if (self.view.window == nil || !animated) {
[self.scrollView setContentOffset:offset animated:NO];
_currentPage = currentPage;
} else {
[self.scrollView setContentOffset:offset animated:YES];
// _currentPage is set in scrollViewDidEndScrollingAnimation:
}
}
// Adds the scroll view and sets constraints.
- (void)setupScrollView {
UIScrollView* scrollView = [[UIScrollView alloc] init];
scrollView.translatesAutoresizingMaskIntoConstraints = NO;
scrollView.scrollEnabled = YES;
scrollView.pagingEnabled = YES;
scrollView.delegate = self;
if (@available(iOS 11, *)) {
// Ensures that scroll view does not add additional margins based on safe
// areas.
scrollView.contentInsetAdjustmentBehavior =
UIScrollViewContentInsetAdjustmentNever;
}
UIView* contentView = [[UIView alloc] init];
contentView.translatesAutoresizingMaskIntoConstraints = NO;
[scrollView addSubview:contentView];
[self.view addSubview:scrollView];
self.scrollContentView = contentView;
self.scrollView = scrollView;
NSArray* constraints = @[
[contentView.topAnchor constraintEqualToAnchor:scrollView.topAnchor],
[contentView.bottomAnchor constraintEqualToAnchor:scrollView.bottomAnchor],
[contentView.leadingAnchor
constraintEqualToAnchor:scrollView.leadingAnchor],
[contentView.trailingAnchor
constraintEqualToAnchor:scrollView.trailingAnchor],
[contentView.heightAnchor constraintEqualToAnchor:self.view.heightAnchor],
[scrollView.topAnchor constraintEqualToAnchor:self.view.topAnchor],
[scrollView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
[scrollView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[scrollView.trailingAnchor
constraintEqualToAnchor:self.view.trailingAnchor],
];
[NSLayoutConstraint activateConstraints:constraints];
}
// Adds the incognito tabs GridViewController as a contained view controller,
// and sets constraints.
- (void)setupIncognitoTabsViewController {
UIView* contentView = self.scrollContentView;
GridViewController* viewController = self.incognitoTabsViewController;
viewController.view.translatesAutoresizingMaskIntoConstraints = NO;
[self addChildViewController:viewController];
[contentView addSubview:viewController.view];
[viewController didMoveToParentViewController:self];
viewController.emptyStateView =
[[TabGridEmptyStateView alloc] initWithPage:TabGridPageIncognitoTabs];
viewController.emptyStateView.accessibilityIdentifier =
kTabGridIncognitoTabsEmptyStateIdentifier;
viewController.theme = GridThemeDark;
viewController.delegate = self;
NSArray* constraints = @[
[viewController.view.topAnchor
constraintEqualToAnchor:contentView.topAnchor],
[viewController.view.bottomAnchor
constraintEqualToAnchor:contentView.bottomAnchor],
[viewController.view.leadingAnchor
constraintEqualToAnchor:contentView.leadingAnchor],
[viewController.view.widthAnchor
constraintEqualToAnchor:self.view.widthAnchor]
];
[NSLayoutConstraint activateConstraints:constraints];
}
// Adds the regular tabs GridViewController as a contained view controller,
// and sets constraints.
- (void)setupRegularTabsViewController {
UIView* contentView = self.scrollContentView;
GridViewController* viewController = self.regularTabsViewController;
viewController.view.translatesAutoresizingMaskIntoConstraints = NO;
[self addChildViewController:viewController];
[contentView addSubview:viewController.view];
[viewController didMoveToParentViewController:self];
viewController.emptyStateView =
[[TabGridEmptyStateView alloc] initWithPage:TabGridPageRegularTabs];
viewController.emptyStateView.accessibilityIdentifier =
kTabGridRegularTabsEmptyStateIdentifier;
viewController.theme = GridThemeLight;
viewController.delegate = self;
NSArray* constraints = @[
[viewController.view.topAnchor
constraintEqualToAnchor:contentView.topAnchor],
[viewController.view.bottomAnchor
constraintEqualToAnchor:contentView.bottomAnchor],
[viewController.view.leadingAnchor
constraintEqualToAnchor:self.incognitoTabsViewController.view
.trailingAnchor],
[viewController.view.widthAnchor
constraintEqualToAnchor:self.view.widthAnchor]
];
[NSLayoutConstraint activateConstraints:constraints];
}
// Adds the remote tabs view controller as a contained view controller, and
// sets constraints.
- (void)setupRemoteTabsViewController {
// TODO(crbug.com/804589) : Dark style on remote tabs.
// The styler must be set before the view controller is loaded.
ChromeTableViewStyler* styler = [[ChromeTableViewStyler alloc] init];
styler.tableViewSectionHeaderBlurEffect = nil;
styler.tableViewBackgroundColor = UIColorFromRGB(kGridBackgroundColor);
styler.cellTitleColor = UIColorFromRGB(kGridDarkThemeCellTitleColor);
styler.headerFooterTitleColor = UIColorFromRGB(kGridDarkThemeCellTitleColor);
self.remoteTabsViewController.styler = styler;
UIView* contentView = self.scrollContentView;
RecentTabsTableViewController* viewController = self.remoteTabsViewController;
viewController.view.translatesAutoresizingMaskIntoConstraints = NO;
[self addChildViewController:viewController];
[contentView addSubview:viewController.view];
[viewController didMoveToParentViewController:self];
NSArray* constraints = @[
[viewController.view.topAnchor
constraintEqualToAnchor:contentView.topAnchor],
[viewController.view.bottomAnchor
constraintEqualToAnchor:contentView.bottomAnchor],
[viewController.view.leadingAnchor
constraintEqualToAnchor:self.regularTabsViewController.view
.trailingAnchor],
[viewController.view.trailingAnchor
constraintEqualToAnchor:contentView.trailingAnchor],
[viewController.view.widthAnchor
constraintEqualToAnchor:self.view.widthAnchor]
];
[NSLayoutConstraint activateConstraints:constraints];
}
// Adds the top toolbar and sets constraints.
- (void)setupTopToolbar {
TabGridTopToolbar* topToolbar = [[TabGridTopToolbar alloc] init];
topToolbar.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:topToolbar];
self.topToolbar = topToolbar;
// Configure and initialize the page control.
[self.topToolbar.pageControl addTarget:self
action:@selector(pageControlChangedValue:)
forControlEvents:UIControlEventValueChanged];
[self.topToolbar.pageControl addTarget:self
action:@selector(pageControlChangedPage:)
forControlEvents:UIControlEventTouchUpInside];
NSArray* constraints = @[
[topToolbar.topAnchor constraintEqualToAnchor:self.view.topAnchor],
[topToolbar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[topToolbar.trailingAnchor
constraintEqualToAnchor:self.view.trailingAnchor],
];
[NSLayoutConstraint activateConstraints:constraints];
// Set the height of the toolbar, including unsafe areas.
if (@available(iOS 11, *)) {
// SafeArea is only available in iOS 11+.
[topToolbar.bottomAnchor
constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor
constant:topToolbar.intrinsicContentSize.height]
.active = YES;
} else {
// Top and bottom layout guides are deprecated starting in iOS 11.
[topToolbar.bottomAnchor
constraintEqualToAnchor:self.topLayoutGuide.bottomAnchor
constant:topToolbar.intrinsicContentSize.height]
.active = YES;
}
}
// Adds the bottom toolbar and sets constraints.
- (void)setupBottomToolbar {
TabGridBottomToolbar* bottomToolbar = [[TabGridBottomToolbar alloc] init];
bottomToolbar.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:bottomToolbar];
self.bottomToolbar = bottomToolbar;
NSArray* constraints = @[
[bottomToolbar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
[bottomToolbar.leadingAnchor
constraintEqualToAnchor:self.view.leadingAnchor],
[bottomToolbar.trailingAnchor
constraintEqualToAnchor:self.view.trailingAnchor],
];
[NSLayoutConstraint activateConstraints:constraints];
// Adds the height of the toolbar above the bottom safe area.
if (@available(iOS 11, *)) {
// SafeArea is only available in iOS 11+.
[self.view.safeAreaLayoutGuide.bottomAnchor
constraintEqualToAnchor:bottomToolbar.topAnchor
constant:bottomToolbar.intrinsicContentSize.height]
.active = YES;
} else {
// Top and bottom layout guides are deprecated starting in iOS 11.
[bottomToolbar.topAnchor
constraintEqualToAnchor:self.bottomLayoutGuide.topAnchor
constant:-bottomToolbar.intrinsicContentSize.height]
.active = YES;
}
}
// Adds floating button and constraints.
- (void)setupFloatingButton {
TabGridNewTabButton* button = [TabGridNewTabButton
buttonWithSizeClass:TabGridNewTabButtonSizeClassLarge];
button.translatesAutoresizingMaskIntoConstraints = NO;
// Position the floating button over the scroll view, so transition animations
// can be above the button but below the toolbars.
[self.view insertSubview:button aboveSubview:self.scrollView];
self.floatingButton = button;
CGFloat verticalInset = kTabGridFloatingButtonVerticalInsetSmall;
if (self.traitCollection.verticalSizeClass ==
UIUserInterfaceSizeClassRegular &&
self.traitCollection.horizontalSizeClass ==
UIUserInterfaceSizeClassRegular) {
verticalInset = kTabGridFloatingButtonVerticalInsetLarge;
}
id<LayoutGuideProvider> safeAreaGuide = SafeAreaLayoutGuideForView(self.view);
[NSLayoutConstraint activateConstraints:@[
[button.trailingAnchor
constraintEqualToAnchor:safeAreaGuide.trailingAnchor
constant:-kTabGridFloatingButtonHorizontalInset],
[button.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor
constant:-verticalInset]
]];
}
- (void)configureViewControllerForCurrentSizeClassesAndPage {
self.configuration = TabGridConfigurationFloatingButton;
if (self.traitCollection.verticalSizeClass ==
UIUserInterfaceSizeClassRegular &&
self.traitCollection.horizontalSizeClass ==
UIUserInterfaceSizeClassCompact) {
// The only bottom toolbar configuration is when the UI is narrow but
// vertically long.
self.configuration = TabGridConfigurationBottomToolbar;
}
switch (self.configuration) {
case TabGridConfigurationBottomToolbar:
self.topToolbar.leadingButton.hidden = YES;
self.topToolbar.trailingButton.hidden = YES;
self.bottomToolbar.hidden = NO;
self.floatingButton.hidden = YES;
self.doneButton = self.bottomToolbar.trailingButton;
self.closeAllButton = self.bottomToolbar.leadingButton;
self.newTabButton = self.bottomToolbar.centerButton;
break;
case TabGridConfigurationFloatingButton:
self.topToolbar.leadingButton.hidden = NO;
self.topToolbar.trailingButton.hidden = NO;
self.bottomToolbar.hidden = YES;
self.floatingButton.hidden = NO;
self.doneButton = self.topToolbar.trailingButton;
self.closeAllButton = self.topToolbar.leadingButton;
self.newTabButton = self.floatingButton;
break;
}
[self.doneButton setTitle:l10n_util::GetNSString(IDS_IOS_TAB_GRID_DONE_BUTTON)
forState:UIControlStateNormal];
self.doneButton.titleLabel.font =
[UIFont preferredFontForTextStyle:UIFontTextStyleHeadline];
self.closeAllButton.titleLabel.font =
[UIFont preferredFontForTextStyle:UIFontTextStyleBody];
self.doneButton.titleLabel.adjustsFontForContentSizeCategory = YES;
self.closeAllButton.titleLabel.adjustsFontForContentSizeCategory = YES;
self.doneButton.accessibilityIdentifier = kTabGridDoneButtonIdentifier;
[self.doneButton addTarget:self
action:@selector(doneButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self.closeAllButton addTarget:self
action:@selector(closeAllButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self.newTabButton addTarget:self
action:@selector(newTabButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self configureButtonsForActiveAndCurrentPage];
}
- (void)configureButtonsForActiveAndCurrentPage {
self.newTabButton.page = self.currentPage;
if (self.currentPage == TabGridPageRemoteTabs) {
[self configureDoneButtonBasedOnPage:self.activePage];
} else {
[self configureDoneButtonBasedOnPage:self.currentPage];
}
[self configureCloseAllButtonForCurrentPageAndUndoAvailability];
}
- (void)configureDoneButtonBasedOnPage:(TabGridPage)page {
GridViewController* gridViewController =
[self gridViewControllerForPage:page];
if (!gridViewController) {
NOTREACHED() << "The done button should not be configured based on the "
"contents of the recent tabs page.";
}
self.doneButton.enabled = !gridViewController.gridEmpty;
}
- (void)configureCloseAllButtonForCurrentPageAndUndoAvailability {
if (self.undoCloseAllAvailable &&
self.currentPage == TabGridPageRegularTabs) {
// Setup closeAllButton as undo button.
self.closeAllButton.enabled = YES;
[self.closeAllButton
setTitle:l10n_util::GetNSString(IDS_IOS_TAB_GRID_UNDO_CLOSE_ALL_BUTTON)
forState:UIControlStateNormal];
self.closeAllButton.accessibilityIdentifier =
kTabGridUndoCloseAllButtonIdentifier;
return;
}
// Otherwise setup as a Close All button.
GridViewController* gridViewController =
[self gridViewControllerForPage:self.currentPage];
self.closeAllButton.enabled =
gridViewController == nil ? NO : !gridViewController.gridEmpty;
[self.closeAllButton
setTitle:l10n_util::GetNSString(IDS_IOS_TAB_GRID_CLOSE_ALL_BUTTON)
forState:UIControlStateNormal];
self.closeAllButton.accessibilityIdentifier =
kTabGridCloseAllButtonIdentifier;
}
// Shows (by setting the alpha to 1.0) the two toolbar views and the floating
// button. Suitable for use in animations.
- (void)showToolbars {
self.topToolbar.alpha = 1.0;
self.bottomToolbar.alpha = 1.0;
self.floatingButton.alpha = 1.0;
}
// Hides (by setting the alpha to 0.0) the two toolbar views and the floating
// button. Suitable for use in animations.
- (void)hideToolbars {
self.topToolbar.alpha = 0.0;
self.bottomToolbar.alpha = 0.0;
self.floatingButton.alpha = 0.0;
}
// Translates the toolbar views offscreen and then animates them back in using
// the transition coordinator. Transitions are preferred here since they don't
// interact with the layout system at all.
- (void)animateToolbarsForAppearance {
DCHECK(self.transitionCoordinator);
// Unless reduce motion is enabled, hide the scroll view during the
// animation.
if (!UIAccessibilityIsReduceMotionEnabled()) {
self.scrollView.hidden = YES;
}
// Fade the toolbars in for the last 60% of the transition.
auto keyframe = ^{
[UIView addKeyframeWithRelativeStartTime:0.2
relativeDuration:0.6
animations:^{
[self showToolbars];
}];
};
// Animation block that does the keyframe animation.
auto animation = ^(id<UIViewControllerTransitionCoordinatorContext> context) {
[UIView animateKeyframesWithDuration:context.transitionDuration
delay:0
options:UIViewAnimationOptionLayoutSubviews
animations:keyframe
completion:nil];
};
// Restore the scroll view and toolbar opacities (in case the animation didn't
// complete) as part of the completion.
auto cleanup = ^(id<UIViewControllerTransitionCoordinatorContext> context) {
self.scrollView.hidden = NO;
[self showToolbars];
};
// Animate the toolbar alphas alongside the current transition.
[self.transitionCoordinator animateAlongsideTransition:animation
completion:cleanup];
}
// Translates the toolbar views offscreen using the transition coordinator.
- (void)animateToolbarsForDisappearance {
DCHECK(self.transitionCoordinator);
// Unless reduce motion is enabled, hide the scroll view during the
// animation.
if (!UIAccessibilityIsReduceMotionEnabled()) {
self.scrollView.hidden = YES;
}
// Fade the toolbars out in the first 66% of the transition.
auto keyframe = ^{
[UIView addKeyframeWithRelativeStartTime:0
relativeDuration:0.40
animations:^{
[self hideToolbars];
}];
};
// Animation block that does the keyframe animation.
auto animation = ^(id<UIViewControllerTransitionCoordinatorContext> context) {
[UIView animateKeyframesWithDuration:context.transitionDuration
delay:0
options:UIViewAnimationOptionLayoutSubviews
animations:keyframe
completion:nil];
};
// Hide the scroll view (and thus the tab grids) until the transition
// completes. Restore the toolbar opacity when the transition completes.
auto cleanup = ^(id<UIViewControllerTransitionCoordinatorContext> context) {
self.scrollView.hidden = NO;
};
// Animate the toolbar alphas alongside the current transition.
[self.transitionCoordinator animateAlongsideTransition:animation
completion:cleanup];
}
// Records when the user switches between incognito and regular pages in the tab
// grid. Switching to a different TabGridPage can either be driven by dragging
// the scrollView or tapping on the pageControl.
- (void)recordActionSwitchingToPage:(TabGridPage)page {
switch (page) {
case TabGridPageIncognitoTabs:
// There are duplicate metrics below that correspond to the previous
// separate implementations for iPhone and iPad. Having both allow for
// comparisons to the previous implementations.
// TODO(crbug.com/856965) : Consolidate and rename metrics.
base::RecordAction(
base::UserMetricsAction("MobileStackViewIncognitoMode"));
base::RecordAction(base::UserMetricsAction(
"MobileTabSwitcherHeaderViewSelectIncognitoPanel"));
break;
case TabGridPageRegularTabs:
// There are duplicate metrics below that correspond to the previous
// separate implementations for iPhone and iPad. Having both allow for
// comparisons to the previous implementations.
// TODO(crbug.com/856965) : Consolidate and rename metrics.
base::RecordAction(base::UserMetricsAction("MobileStackViewNormalMode"));
base::RecordAction(base::UserMetricsAction(
"MobileTabSwitcherHeaderViewSelectNonIncognitoPanel"));
break;
case TabGridPageRemoteTabs:
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(base::UserMetricsAction(
"MobileTabSwitcherHeaderViewSelectDistantSessionPanel"));
break;
}
}
// Tells the appropriate delegate to create a new item, and then tells the
// presentation delegate to show the new item.
- (void)openNewTabInPage:(TabGridPage)page {
switch (page) {
case TabGridPageIncognitoTabs:
[self.incognitoTabsDelegate addNewItem];
// Record when new incognito tab is created.
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(
base::UserMetricsAction("MobileTabSwitcherCreateIncognitoTab"));
break;
case TabGridPageRegularTabs:
[self.regularTabsDelegate addNewItem];
// Record when new regular tab is created.
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(
base::UserMetricsAction("MobileTabSwitcherCreateNonIncognitoTab"));
break;
case TabGridPageRemoteTabs:
// This is intended as NO-OP since the user can ⌘-t while on this page.
break;
}
self.activePage = page;
[self.tabPresentationDelegate showActiveTabInPage:page];
}
// Creates and shows a new regular tab.
- (void)openNewRegularTab {
[self openNewTabInPage:TabGridPageRegularTabs];
}
// Creates and shows a new incognito tab.
- (void)openNewIncognitoTab {
[self openNewTabInPage:TabGridPageIncognitoTabs];
}
// Creates and shows a new tab in the current page.
- (void)openNewTabInCurrentPage {
[self openNewTabInPage:self.currentPage];
}
// Broadcasts whether incognito tabs are showing.
- (void)broadcastIncognitoContentVisibility {
if (!self.broadcasting)
return;
BOOL incognitoContentVisible =
(self.currentPage == TabGridPageIncognitoTabs &&
!self.incognitoTabsViewController.gridEmpty);
[self.dispatcher setIncognitoContentVisible:incognitoContentVisible];
}
#pragma mark - GridViewControllerDelegate
- (void)gridViewController:(GridViewController*)gridViewController
didSelectItemWithID:(NSString*)itemID {
// Update the model with the tab selection, but don't have the grid view
// controller display the new selection, since there will be a transition
// away from it immediately afterwards.
gridViewController.showsSelectionUpdates = NO;
if (gridViewController == self.regularTabsViewController) {
[self.regularTabsDelegate selectItemWithID:itemID];
// Record when a regular tab is opened.
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(
base::UserMetricsAction("MobileTabSwitcherOpenNonIncognitoTab"));
} else if (gridViewController == self.incognitoTabsViewController) {
[self.incognitoTabsDelegate selectItemWithID:itemID];
// Record when an incognito tab is opened.
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(
base::UserMetricsAction("MobileTabSwitcherOpenIncognitoTab"));
}
self.activePage = self.currentPage;
[self.tabPresentationDelegate showActiveTabInPage:self.currentPage];
gridViewController.showsSelectionUpdates = YES;
}
- (void)gridViewController:(GridViewController*)gridViewController
didCloseItemWithID:(NSString*)itemID {
if (gridViewController == self.regularTabsViewController) {
[self.regularTabsDelegate closeItemWithID:itemID];
// Record when a regular tab is closed.
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(
base::UserMetricsAction("MobileTabSwitcherCloseNonIncognitoTab"));
} else if (gridViewController == self.incognitoTabsViewController) {
[self.incognitoTabsDelegate closeItemWithID:itemID];
// Record when an incognito tab is closed.
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(
base::UserMetricsAction("MobileTabSwitcherCloseIncognitoTab"));
}
}
- (void)gridViewController:(GridViewController*)gridViewController
didMoveItemWithID:(NSString*)itemID
toIndex:(NSUInteger)destinationIndex {
if (gridViewController == self.regularTabsViewController) {
[self.regularTabsDelegate moveItemWithID:itemID toIndex:destinationIndex];
} else if (gridViewController == self.incognitoTabsViewController) {
[self.incognitoTabsDelegate moveItemWithID:itemID toIndex:destinationIndex];
}
}
- (void)gridViewController:(GridViewController*)gridViewController
didChangeItemCount:(NSUInteger)count {
[self configureButtonsForActiveAndCurrentPage];
if (gridViewController == self.regularTabsViewController) {
self.topToolbar.pageControl.regularTabCount = count;
}
[self broadcastIncognitoContentVisibility];
}
#pragma mark - Control actions
- (void)doneButtonTapped:(id)sender {
TabGridPage newActivePage = self.currentPage;
if (self.currentPage == TabGridPageRemoteTabs) {
newActivePage = self.activePage;
}
self.activePage = newActivePage;
// Holding the done button down when it is enabled could result in done tap
// being triggered on release after tabs have been closed and the button
// disabled. Ensure that action is only taken on a valid state.
if (![[self gridViewControllerForPage:newActivePage] isGridEmpty]) {
[self.tabPresentationDelegate showActiveTabInPage:newActivePage];
// Record when users exit the tab grid to return to the current foreground
// tab.
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(
base::UserMetricsAction("MobileTabReturnedToCurrentTab"));
}
}
- (void)closeAllButtonTapped:(id)sender {
switch (self.currentPage) {
case TabGridPageIncognitoTabs:
[self.incognitoTabsDelegate closeAllItems];
break;
case TabGridPageRegularTabs:
DCHECK_EQ(self.undoCloseAllAvailable,
self.regularTabsViewController.gridEmpty);
if (self.undoCloseAllAvailable) {
[self.regularTabsDelegate undoCloseAllItems];
} else {
[self.regularTabsDelegate saveAndCloseAllItems];
}
self.undoCloseAllAvailable = !self.undoCloseAllAvailable;
[self configureCloseAllButtonForCurrentPageAndUndoAvailability];
break;
case TabGridPageRemoteTabs:
NOTREACHED() << "It is invalid to call close all tabs on remote tabs.";
break;
}
}
- (void)newTabButtonTapped:(id)sender {
[self openNewTabInCurrentPage];
// Record only when a new tab is created through the + button.
// TODO(crbug.com/856965) : Rename metrics.
base::RecordAction(base::UserMetricsAction("MobileToolbarStackViewNewTab"));
}
- (void)pageControlChangedValue:(id)sender {
// Map the page control slider position (in the range 0.0-1.0) to an
// x-offset for the scroll view.
CGFloat offset = self.topToolbar.pageControl.sliderPosition;
// In RTL, flip the offset.
if (UseRTLLayout())
offset = 1.0 - offset;
// Total space available for the scroll view to scroll (horizontally).
CGFloat offsetWidth =
self.scrollView.contentSize.width - self.scrollView.frame.size.width;
CGPoint contentOffset = self.scrollView.contentOffset;
// Find the final offset by using |offset| as a fraction of the available
// scroll width.
contentOffset.x = offsetWidth * offset;
self.scrollView.contentOffset = contentOffset;
}
- (void)pageControlChangedPage:(id)sender {
TabGridPage newPage = self.topToolbar.pageControl.selectedPage;
[self setCurrentPage:newPage animated:YES];
// Records when the user taps on the pageControl to switch pages.
[self recordActionSwitchingToPage:newPage];
}
#pragma mark - UIResponder
- (NSArray*)keyCommands {
UIKeyCommand* newWindowShortcut =
[UIKeyCommand keyCommandWithInput:@"n"
modifierFlags:UIKeyModifierCommand
action:@selector(openNewRegularTab)
discoverabilityTitle:l10n_util::GetNSStringWithFixup(
IDS_IOS_TOOLS_MENU_NEW_TAB)];
UIKeyCommand* newIncognitoWindowShortcut = [UIKeyCommand
keyCommandWithInput:@"n"
modifierFlags:UIKeyModifierCommand | UIKeyModifierShift
action:@selector(openNewIncognitoTab)
discoverabilityTitle:l10n_util::GetNSStringWithFixup(
IDS_IOS_TOOLS_MENU_NEW_INCOGNITO_TAB)];
UIKeyCommand* newTabShortcut =
[UIKeyCommand keyCommandWithInput:@"t"
modifierFlags:UIKeyModifierCommand
action:@selector(openNewTabInCurrentPage)
discoverabilityTitle:l10n_util::GetNSStringWithFixup(
IDS_IOS_TOOLS_MENU_NEW_TAB)];
return @[ newWindowShortcut, newIncognitoWindowShortcut, newTabShortcut ];
}
@end