// 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/inactive_tabs/inactive_tabs_coordinator.h"
#import <QuartzCore/QuartzCore.h>
#import <UIKit/UIKit.h>
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/notreached.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/browser/shared/coordinator/alert/action_sheet_coordinator.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/snapshots/model/snapshot_browser_agent.h"
#import "ios/chrome/browser/tabs/model/inactive_tabs/features.h"
#import "ios/chrome/browser/tabs/model/tabs_closer.h"
#import "ios/chrome/browser/ui/settings/settings_navigation_controller.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/regular/regular_grid_view_controller.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/inactive_tabs/inactive_tabs_constants.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/inactive_tabs/inactive_tabs_coordinator_delegate.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/inactive_tabs/inactive_tabs_grid_view_controller.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/inactive_tabs/inactive_tabs_mediator.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/inactive_tabs/inactive_tabs_user_education_coordinator.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/inactive_tabs/inactive_tabs_view_controller.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/tab_context_menu/tab_context_menu_helper.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/web/public/web_state_id.h"
#import "ui/base/l10n/l10n_util.h"
#import "ui/strings/grit/ui_strings.h"
// A view that can be dimmed continusouly between no dimming and being fully
// dimmed (the view is then fully black).
@interface DimmableSnapshot : UIView
// How much to dim the view.
// A value of 0.0 shows the snapshot with no dimming. A value of 1.0 shows a
// totally dimmed view.
// Default is 0.0.
@property(nonatomic) CGFloat dimming;
// Returns a dimmable view representing `view` as it is snapshot.
- (instancetype)initWithView:(UIView*)view;
@end
@implementation DimmableSnapshot {
UIView* _snapshotView;
UIView* _dimmingView;
}
- (instancetype)initWithView:(UIView*)view {
self = [super initWithFrame:view.frame];
if (self) {
_snapshotView = [view snapshotViewAfterScreenUpdates:YES];
_snapshotView.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:_snapshotView];
AddSameConstraints(self, _snapshotView);
_dimmingView = [[UIView alloc] init];
_dimmingView.backgroundColor = UIColor.blackColor;
_dimmingView.translatesAutoresizingMaskIntoConstraints = NO;
_dimmingView.alpha = 0;
[self addSubview:_dimmingView];
AddSameConstraints(self, _dimmingView);
}
return self;
}
- (CGFloat)dimming {
return _dimmingView.alpha;
}
- (void)setDimming:(CGFloat)dimming {
_dimmingView.alpha = dimming;
}
@end
namespace {
// Presentation/dismissal animation constants for the inactive tabs view.
const NSTimeInterval kDuration = 0.5;
const CGFloat kSpringDamping = 1.0;
const CGFloat kInitialSpringVelocity = 1.0;
const CGFloat kDimming = 0.2;
const CGFloat kParallaxDisplacement = 100;
// The minimum horizontal velocity to the trailing edge that will dismiss the
// view controller, no matter it's current swiped position.
const CGFloat kMinForwardVelocityToDismiss = 100;
// The minimum horizontal velocity to the leading edge that will cancel the
// dismissal of the view controller, when the swiped position is already more
// than half of the screen's width.
const CGFloat kMinBackwardVelocityToCancelDismiss = 10;
// When the inactive tabs grid would be emptied (last inactive tab, or closing
// all inactive tabs via the confirmation dialog), the Inactive Tabs grid is
// popped, but to avoid having it emptied immediately (producing a glitch),
// delay the closing of the tab(s) in the mediator.
const base::TimeDelta kPopUIDelay = base::Seconds(0.3);
} // namespace
@interface InactiveTabsCoordinator () <
GridViewControllerDelegate,
InactiveTabsUserEducationCoordinatorDelegate,
InactiveTabsViewControllerDelegate,
SettingsNavigationControllerDelegate>
// The view controller displaying the inactive tabs.
@property(nonatomic, strong) InactiveTabsViewController* viewController;
// The mediator handling the inactive tabs.
@property(nonatomic, strong) InactiveTabsMediator* mediator;
// The constraints for placing `viewController` horizontally.
@property(nonatomic, strong) NSLayoutConstraint* horizontalPosition;
// Whether the view controller is shown. It is true inbetween calls to `-show`
// and `-hide`.
@property(nonatomic, getter=isShowing) BOOL showing;
// The snapshot of the base view prior to showing Inactive Tabs.
@property(nonatomic, strong) DimmableSnapshot* baseViewSnapshot;
// The horizontal position of `baseViewSnapshot`. Change the constant to move
// `baseViewSnapshot`.
@property(nonatomic, strong)
NSLayoutConstraint* baseViewSnapshotHorizontalPosition;
// Whether settings are currently presented.
@property(nonatomic, getter=isPresetingSettings) BOOL presentingSettings;
// The optional user education coordinator shown the first time Inactive Tabs
// are displayed.
@property(nonatomic, strong)
InactiveTabsUserEducationCoordinator* userEducationCoordinator;
@end
@implementation InactiveTabsCoordinator {
// Delegate for dismissing the coordinator.
__weak id<InactiveTabsCoordinatorDelegate> _delegate;
// Provides the context menu for the tabs on the grid.
TabContextMenuHelper* _contextMenuProvider;
// The navigation controller for inactive tabs settings.
SettingsNavigationController* _settingsController;
ActionSheetCoordinator* _actionSheetCoordinator;
}
#pragma mark - Public
- (instancetype)initWithBaseViewController:(UIViewController*)viewController
browser:(Browser*)browser
delegate:(id<InactiveTabsCoordinatorDelegate>)
delegate {
CHECK(IsInactiveTabsAvailable());
CHECK(delegate);
self = [super initWithBaseViewController:viewController browser:browser];
if (self) {
_delegate = delegate;
}
return self;
}
- (id<GridCommands>)gridCommandsHandler {
return self.mediator;
}
- (id<GridToolbarsConfigurationProvider>)toolbarsConfigurationProvider {
return self.mediator;
}
#pragma mark - ChromeCoordinator
- (void)start {
[super start];
_contextMenuProvider = [[TabContextMenuHelper alloc]
initWithBrowserState:self.browser->GetActiveBrowser()->GetBrowserState()
tabContextMenuDelegate:self.tabContextMenuDelegate];
Browser* browser = self.browser;
SnapshotStorageWrapper* snapshotStorage =
SnapshotBrowserAgent::FromBrowser(browser)->snapshot_storage();
self.mediator = [[InactiveTabsMediator alloc]
initWithWebStateList:browser->GetWebStateList()
prefService:GetApplicationContext()->GetLocalState()
snapshotStorage:snapshotStorage
tabsCloser:std::make_unique<TabsCloser>(
browser, TabsCloser::ClosePolicy::kAllTabs)];
}
- (void)show {
if (self.showing) {
return;
}
self.showing = YES;
base::RecordAction(base::UserMetricsAction("MobileInactiveTabGridEntered"));
// Create the view controller.
self.viewController = [[InactiveTabsViewController alloc] init];
self.viewController.delegate = self;
self.viewController.gridViewController.delegate = self;
UIScreenEdgePanGestureRecognizer* edgeSwipeRecognizer =
[[UIScreenEdgePanGestureRecognizer alloc]
initWithTarget:self
action:@selector(onEdgeSwipe:)];
edgeSwipeRecognizer.edges = UIRectEdgeLeft;
[self.viewController.view addGestureRecognizer:edgeSwipeRecognizer];
self.mediator.consumer = self.viewController.gridViewController;
self.viewController.gridViewController.menuProvider = _contextMenuProvider;
// Add the Inactive Tabs view controller to the hierarchy.
UIView* baseView = self.baseViewController.view;
UIView* view = self.viewController.view;
view.translatesAutoresizingMaskIntoConstraints = NO;
[self.baseViewController addChildViewController:self.viewController];
[baseView addSubview:view];
[self.viewController didMoveToParentViewController:self.baseViewController];
// Place the Inactive Tabs view controller.
self.horizontalPosition = [view.leadingAnchor
constraintEqualToAnchor:baseView.leadingAnchor
constant:CGRectGetWidth(baseView.bounds)];
[NSLayoutConstraint activateConstraints:@[
[view.topAnchor constraintEqualToAnchor:baseView.topAnchor],
[view.bottomAnchor constraintEqualToAnchor:baseView.bottomAnchor],
[view.widthAnchor constraintEqualToAnchor:baseView.widthAnchor],
self.horizontalPosition,
]];
// Add the dimmable snapshot of the base view.
DimmableSnapshot* snapshot = [[DimmableSnapshot alloc] initWithView:baseView];
snapshot.translatesAutoresizingMaskIntoConstraints = NO;
[baseView insertSubview:snapshot belowSubview:view];
self.baseViewSnapshot = snapshot;
// Place the dimmable snapshot.
self.baseViewSnapshotHorizontalPosition =
[snapshot.centerXAnchor constraintEqualToAnchor:baseView.centerXAnchor];
[NSLayoutConstraint activateConstraints:@[
[snapshot.widthAnchor constraintEqualToAnchor:baseView.widthAnchor],
[snapshot.heightAnchor constraintEqualToAnchor:baseView.heightAnchor],
[snapshot.centerYAnchor constraintEqualToAnchor:baseView.centerYAnchor],
self.baseViewSnapshotHorizontalPosition,
]];
[self animateIn];
}
- (void)hide {
if (!self.showing) {
return;
}
base::RecordAction(base::UserMetricsAction("MobileInactiveTabGridExited"));
[self.userEducationCoordinator stop];
self.userEducationCoordinator = nil;
if (self.presentingSettings) {
[self closeSettings];
}
[_actionSheetCoordinator stop];
_actionSheetCoordinator = nil;
[self.viewController.gridViewController dismissModals];
// Unhide the snapshot.
self.baseViewSnapshot.hidden = NO;
[self animateOut];
}
- (void)stop {
[super stop];
[self.userEducationCoordinator stop];
self.userEducationCoordinator = nil;
[self dismissActionSheetCoordinator];
[self.mediator disconnect];
self.mediator = nil;
self.viewController = nil;
}
#pragma mark - GridViewControllerDelegate
- (void)gridViewController:(BaseGridViewController*)gridViewController
didSelectItemWithID:(web::WebStateID)itemID {
base::RecordAction(base::UserMetricsAction("MobileTabGridOpenInactiveTab"));
[_delegate inactiveTabsCoordinator:self didSelectItemWithID:itemID];
[self didFinish];
}
- (void)gridViewController:(BaseGridViewController*)gridViewController
didSelectGroup:(const TabGroup*)group {
NOTREACHED();
}
- (void)gridViewController:(BaseGridViewController*)gridViewController
didCloseItemWithID:(web::WebStateID)itemID {
__weak __typeof(self) weakSelf = self;
auto closeItem = ^{
[weakSelf.mediator closeItemWithID:itemID];
};
NSInteger numberOfTabs = [self.mediator numberOfItems];
// If it is the latest item, pop the view (UI change), and defer the model
// change after the UI is no longer visible.
if (numberOfTabs <= 1) {
// Pop the view controller.
[self didFinish];
// To prevent the Inactive Tabs grid from being immediately emptied, defer
// the closing to after the view is popped.
base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
FROM_HERE, base::BindOnce(closeItem), kPopUIDelay);
} else {
// Otherwise, close the item immediately.
closeItem();
}
}
- (void)gridViewControllerDidMoveItem:
(BaseGridViewController*)gridViewController {
NOTREACHED_IN_MIGRATION();
}
- (void)gridViewController:(BaseGridViewController*)gridViewController
didRemoveItemWIthID:(web::WebStateID)itemID {
// No op.
}
- (void)gridViewControllerDragSessionWillBeginForTab:
(BaseGridViewController*)gridViewController {
// No op.
}
- (void)gridViewControllerDragSessionWillBeginForTabGroup:
(BaseGridViewController*)gridViewController {
// No-op.
}
- (void)gridViewControllerDragSessionDidEnd:
(BaseGridViewController*)gridViewController {
// No op.
}
- (void)gridViewControllerScrollViewDidScroll:
(BaseGridViewController*)gridViewController {
// No op.
}
- (void)gridViewControllerDropAnimationWillBegin:
(BaseGridViewController*)gridViewController {
NOTREACHED_IN_MIGRATION();
}
- (void)gridViewControllerDropAnimationDidEnd:
(BaseGridViewController*)gridViewController {
NOTREACHED_IN_MIGRATION();
}
- (void)didTapInactiveTabsButtonInGridViewController:
(BaseGridViewController*)gridViewController {
NOTREACHED_IN_MIGRATION();
}
- (void)didTapInactiveTabsSettingsLinkInGridViewController:
(BaseGridViewController*)gridViewController {
[self presentSettings];
}
- (void)gridViewControllerDidRequestContextMenu:
(BaseGridViewController*)gridViewController {
// No-op.
}
#pragma mark - InactiveTabsUserEducationCoordinatorDelegate
- (void)inactiveTabsUserEducationCoordinatorDidTapSettingsButton:
(InactiveTabsUserEducationCoordinator*)
inactiveTabsUserEducationCoordinator {
[self.userEducationCoordinator stop];
self.userEducationCoordinator = nil;
[self presentSettings];
}
- (void)inactiveTabsUserEducationCoordinatorDidFinish:
(InactiveTabsUserEducationCoordinator*)
inactiveTabsUserEducationCoordinator {
[self.userEducationCoordinator stop];
self.userEducationCoordinator = nil;
}
#pragma mark - InactiveTabsViewControllerDelegate
- (void)inactiveTabsViewControllerDidTapBackButton:
(InactiveTabsViewController*)inactiveTabsViewController {
[self didFinish];
}
- (void)inactiveTabsViewController:
(InactiveTabsViewController*)inactiveTabsViewController
didTapCloseAllInactiveBarButtonItem:(UIBarButtonItem*)barButtonItem {
NSInteger numberOfTabs = [self.mediator numberOfItems];
if (numberOfTabs <= 0) {
return;
}
base::RecordAction(base::UserMetricsAction("MobileInactiveTabsCloseAll"));
NSString* title;
if (numberOfTabs > 99) {
title = l10n_util::GetNSString(
IDS_IOS_INACTIVE_TABS_CLOSE_ALL_CONFIRMATION_MANY);
} else {
title = base::SysUTF16ToNSString(l10n_util::GetPluralStringFUTF16(
IDS_IOS_INACTIVE_TABS_CLOSE_ALL_CONFIRMATION, numberOfTabs));
}
NSString* message = l10n_util::GetNSString(
IDS_IOS_INACTIVE_TABS_CLOSE_ALL_CONFIRMATION_MESSAGE);
[_actionSheetCoordinator stop];
_actionSheetCoordinator = [[ActionSheetCoordinator alloc]
initWithBaseViewController:self.baseViewController
browser:self.browser
title:title
message:message
barButtonItem:barButtonItem];
__weak __typeof(self) weakSelf = self;
NSString* closeAllActionTitle = l10n_util::GetNSString(
IDS_IOS_INACTIVE_TABS_CLOSE_ALL_CONFIRMATION_OPTION);
[_actionSheetCoordinator
addItemWithTitle:closeAllActionTitle
action:^{
base::RecordAction(base::UserMetricsAction(
"MobileInactiveTabsCloseAllConfirm"));
[weakSelf closeAllInactiveTabs];
[weakSelf dismissActionSheetCoordinator];
}
style:UIAlertActionStyleDestructive];
[_actionSheetCoordinator
addItemWithTitle:l10n_util::GetNSString(IDS_APP_CANCEL)
action:^{
[weakSelf dismissActionSheetCoordinator];
}
style:UIAlertActionStyleCancel];
[_actionSheetCoordinator start];
}
#pragma mark - SettingsNavigationControllerDelegate
- (void)closeSettings {
__weak __typeof(self) weakSelf = self;
[self.baseViewController dismissViewControllerAnimated:YES
completion:^{
[weakSelf onSettingsDismissed];
}];
}
- (void)settingsWasDismissed {
// This is called when the settings are swiped away by the user.
// `settingsWasDismissed` is not called after programmatically calling
// `closeSettings`, so call the completion here.
[self onSettingsDismissed];
}
- (id<ApplicationCommands, BrowserCommands>)handlerForSettings {
NOTREACHED_IN_MIGRATION();
return nil;
}
- (id<ApplicationCommands>)handlerForApplicationCommands {
NOTREACHED_IN_MIGRATION();
return nil;
}
- (id<SnackbarCommands>)handlerForSnackbarCommands {
NOTREACHED_IN_MIGRATION();
return nil;
}
#pragma mark - Actions
- (void)onEdgeSwipe:(UIScreenEdgePanGestureRecognizer*)edgeSwipeRecognizer {
UIView* baseView = self.baseViewController.view;
CGFloat horizontalPosition =
[edgeSwipeRecognizer translationInView:baseView].x;
CGFloat horizontalVelocity = [edgeSwipeRecognizer velocityInView:baseView].x;
CGFloat fractionComplete =
horizontalPosition / CGRectGetWidth(baseView.bounds);
switch (edgeSwipeRecognizer.state) {
case UIGestureRecognizerStateBegan:
// Unhide the snapshot.
self.baseViewSnapshot.hidden = NO;
break;
case UIGestureRecognizerStateChanged:
self.horizontalPosition.constant = horizontalPosition;
self.baseViewSnapshotHorizontalPosition.constant =
-kParallaxDisplacement * (1 - fractionComplete);
self.baseViewSnapshot.dimming = kDimming * (1 - fractionComplete);
break;
case UIGestureRecognizerStateEnded:
if (horizontalVelocity > kMinForwardVelocityToDismiss) {
[self animateOut];
} else {
if (horizontalVelocity < -kMinBackwardVelocityToCancelDismiss) {
[self animateIn];
} else {
if (horizontalPosition > CGRectGetWidth(baseView.bounds) / 2) {
[self animateOut];
} else {
[self animateIn];
}
}
}
break;
case UIGestureRecognizerStateCancelled:
[self animateIn];
break;
default:
break;
}
}
#pragma mark - Private
// Called when inactive tabs should be dismissed.
- (void)didFinish {
[_delegate inactiveTabsCoordinatorDidFinish:self];
}
- (void)dismissActionSheetCoordinator {
[_actionSheetCoordinator stop];
_actionSheetCoordinator = nil;
}
// Called to make the Inactive Tabs grid appear in an animation.
- (void)animateIn {
UIView* baseView = self.baseViewController.view;
// Trigger a layout, to take into account the changes to the hierarchy prior
// to animating.
[baseView layoutIfNeeded];
// Animate.
[UIView animateWithDuration:kDuration
delay:0
usingSpringWithDamping:kSpringDamping
initialSpringVelocity:kInitialSpringVelocity
options:0
animations:^{
// Make the Inactive Tabs view controller appear.
self.horizontalPosition.constant = 0;
// Make the dimmable snapshot move a little, to give the parallax
// effect.
self.baseViewSnapshotHorizontalPosition.constant =
-kParallaxDisplacement;
// And dim the snapshot.
self.baseViewSnapshot.dimming = kDimming;
// Trigger a layout, to animate constraints changes.
[baseView layoutIfNeeded];
}
completion:^(BOOL finished) {
// Hide the snapshot. The snapshot is supposed to be overlaid by the
// Inactive Tabs view controller, but it happened sometimes that the
// animation of the Inactive Tabs view controller left it just 1 pixel
// off of the edge, letting the snapshot visible underneath.
self.baseViewSnapshot.hidden = YES;
// Once appeared, potentially display the user education screen.
[self startUserEducationIfNeeded];
}];
}
// Called to make the Inactive Tabs grid disappear in an animation.
- (void)animateOut {
UIView* baseView = self.baseViewController.view;
// Trigger a layout, to take into account the changes to the hierarchy prior
// to animating.
[baseView layoutIfNeeded];
// Animate.
[UIView animateWithDuration:kDuration
delay:0
usingSpringWithDamping:kSpringDamping
initialSpringVelocity:kInitialSpringVelocity
options:0
animations:^{
// Make the Inactive Tabs view controller disappear.
self.horizontalPosition.constant = CGRectGetWidth(baseView.bounds);
// Reset the dimmable snapshot position.
self.baseViewSnapshotHorizontalPosition.constant = 0;
// And undim the snapshot.
self.baseViewSnapshot.dimming = 0;
// Trigger a layout, to animate constraints changes.
[baseView layoutIfNeeded];
}
completion:^(BOOL success) {
[self.viewController willMoveToParentViewController:nil];
[self.viewController.view removeFromSuperview];
[self.viewController removeFromParentViewController];
self.horizontalPosition = nil;
[self.baseViewSnapshot removeFromSuperview];
self.baseViewSnapshot = nil;
self.showing = NO;
self.mediator.consumer = nil;
self.viewController = nil;
}];
}
// Called when the Inactive Tabs grid is shown, to start the user education
// coordinator. If the user education screen was ever presented, this is a
// no-op.
- (void)startUserEducationIfNeeded {
NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults];
if ([defaults boolForKey:kInactiveTabsUserEducationShownOnceKey]) {
return;
}
// Start the user education coordinator.
self.userEducationCoordinator = [[InactiveTabsUserEducationCoordinator alloc]
initWithBaseViewController:self.viewController
browser:nullptr];
self.userEducationCoordinator.delegate = self;
[self.userEducationCoordinator start];
// Record the presentation.
[defaults setBool:YES forKey:kInactiveTabsUserEducationShownOnceKey];
}
// Called when the user confirmed wanting to close all inactive tabs.
- (void)closeAllInactiveTabs {
[self didFinish];
// To prevent the Inactive Tabs grid from being immediately emptied, defer the
// closing to after the view is popped.
__weak __typeof(self) weakSelf = self;
base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
FROM_HERE, base::BindOnce(^{
[weakSelf.mediator closeAllItems];
}),
kPopUIDelay);
}
// Presents the Inactive Tabs settings modally in their own navigation
// controller.
- (void)presentSettings {
_settingsController = [SettingsNavigationController
inactiveTabsControllerForBrowser:self.browser
delegate:self];
[self.viewController presentViewController:_settingsController
animated:YES
completion:nil];
self.presentingSettings = YES;
}
// Called when Inactive Tabs settings are dismissed.
- (void)onSettingsDismissed {
self.presentingSettings = NO;
[_settingsController cleanUpSettings];
_settingsController = nil;
[self popIfNeeded];
}
// Tells the delegate this coordinator did finish if it was showing its view
// controller and had no item left.
- (void)popIfNeeded {
if ([self.mediator numberOfItems] == 0 && self.showing) {
[self didFinish];
}
}
@end