blob: e27efbf66cbfd2210f6f3ea24a8b87b20eaad837 [file] [log] [blame]
// Copyright 2022 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/content_suggestions/content_suggestions_view_controller.h"
#import "base/mac/foundation_util.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/drag_and_drop/url_drag_drop_handler.h"
#import "ios/chrome/browser/ntp/set_up_list_item.h"
#import "ios/chrome/browser/ntp/set_up_list_item_type.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_cells_constants.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_most_visited_action_item.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_most_visited_item.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_most_visited_tile_view.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_return_to_recent_tab_item.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_return_to_recent_tab_view.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_selection_actions.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_shortcut_tile_view.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_tile_layout_util.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/magic_stack_module_container.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/magic_stack_module_container_delegate.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/multi_row_container_view.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/query_suggestion_view.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_collection_utils.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_commands.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_constants.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_feature.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_menu_provider.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_metrics_recorder.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_view_controller_audience.h"
#import "ios/chrome/browser/ui/content_suggestions/ntp_home_constant.h"
#import "ios/chrome/browser/ui/content_suggestions/set_up_list/set_up_list_item_view.h"
#import "ios/chrome/browser/ui/content_suggestions/set_up_list/set_up_list_item_view_data.h"
#import "ios/chrome/browser/ui/content_suggestions/set_up_list/set_up_list_view.h"
#import "ios/chrome/browser/ui/content_suggestions/set_up_list/utils.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_header_constants.h"
#import "ios/chrome/browser/ui/start_surface/start_surface_features.h"
#import "ios/chrome/browser/ui/toolbar/public/toolbar_utils.h"
#import "ios/chrome/browser/url_loading/url_loading_browser_agent.h"
#import "ios/chrome/browser/url_loading/url_loading_params.h"
#import "ios/chrome/common/material_timing.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/favicon/favicon_view.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ui/base/device_form_factor.h"
#import "ui/base/l10n/l10n_util.h"
#import "url/gurl.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
// The bottom padding for the vertical stack view.
const float kBottomStackViewPadding = 6.0f;
const float kBottomStackViewExtraPadding = 14.0f;
// The minimum scroll velocity in order to swipe between modules in the Magic
// Stack.
const float kMagicStackMinimumPaginationScrollVelocity = 0.2f;
// The spacing between modules in the Magic Stack.
const float kMagicStackSpacing = 10.0f;
// The max width of the SetUpList on phone and tablet.
const CGFloat kSetUpListWidthRegular = 393;
const CGFloat kSetUpListWidthWide = 418;
// The duration of the animation that hides the Set Up List.
const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
} // namespace
@interface ContentSuggestionsViewController () <
UIGestureRecognizerDelegate,
ContentSuggestionsSelectionActions,
MagicStackModuleContainerDelegate,
SetUpListItemViewTapDelegate,
URLDropDelegate,
UIScrollViewDelegate,
UIScrollViewAccessibilityDelegate>
@property(nonatomic, strong) URLDragDropHandler* dragDropHandler;
// StackView holding all subviews.
@property(nonatomic, strong) UIStackView* verticalStackView;
// List of all UITapGestureRecognizers created for the Most Visisted tiles.
@property(nonatomic, strong)
NSMutableArray<UITapGestureRecognizer*>* mostVisitedTapRecognizers;
// The UITapGestureRecognizer for the Return To Recent Tab tile.
@property(nonatomic, strong)
UITapGestureRecognizer* returnToRecentTabTapRecognizer;
// The Return To Recent Tab view.
@property(nonatomic, strong)
ContentSuggestionsReturnToRecentTabView* returnToRecentTabTile;
// StackView holding all of `mostVisitedViews`.
@property(nonatomic, strong) UIStackView* mostVisitedStackView;
// Module Container for the `mostVisitedViews` when being shown in Magic Stack.
@property(nonatomic, strong)
MagicStackModuleContainer* mostVisitedModuleContainer;
// Width Anchor of the Most Visited Tiles container.
@property(nonatomic, strong)
NSLayoutConstraint* mostVisitedContainerWidthAnchor;
// List of all of the Most Visited views.
@property(nonatomic, strong)
NSMutableArray<ContentSuggestionsMostVisitedTileView*>* mostVisitedViews;
// Module Container for the Shortcuts when being shown in Magic Stack.
@property(nonatomic, strong)
MagicStackModuleContainer* shortcutsModuleContainer;
// StackView holding all of `shortcutsViews`.
@property(nonatomic, strong) UIStackView* shortcutsStackView;
// List of all of the Shortcut views.
@property(nonatomic, strong)
NSMutableArray<ContentSuggestionsShortcutTileView*>* shortcutsViews;
// The SetUpListView, if it is currently being displayed.
@property(nonatomic, strong) SetUpListView* setUpListView;
@end
@implementation ContentSuggestionsViewController {
// Width Anchor of the Return To Recent Tab tile.
NSLayoutConstraint* _returnToRecentTabWidthAnchor;
UIScrollView* _magicStackScrollView;
UIStackView* _magicStack;
BOOL _shouldShowMagicStack;
NSMutableArray<NSNumber*>* _magicStackModuleOrder;
NSLayoutConstraint* _magicStackScrollViewWidthAnchor;
NSArray<SetUpListItemViewData*>* _savedSetUpListItems;
SetUpListItemView* _setUpListSyncItemView;
SetUpListItemView* _setUpListDefaultBrowserItemView;
SetUpListItemView* _setUpListAutofillItemView;
SetUpListItemView* _setUpListAllSetItemView;
NSMutableArray<SetUpListItemView*>* _compactedSetUpListViews;
}
- (instancetype)init {
return [super initWithNibName:nil bundle:nil];
}
- (void)viewDidLoad {
[super viewDidLoad];
self.dragDropHandler = [[URLDragDropHandler alloc] init];
self.dragDropHandler.dropDelegate = self;
[self.view addInteraction:[[UIDropInteraction alloc]
initWithDelegate:self.dragDropHandler]];
if (IsMagicStackEnabled()) {
self.view.backgroundColor = [UIColor clearColor];
} else {
self.view.backgroundColor = ntp_home::NTPBackgroundColor();
}
self.view.accessibilityIdentifier = kContentSuggestionsCollectionIdentifier;
self.verticalStackView = [[UIStackView alloc] init];
self.verticalStackView.translatesAutoresizingMaskIntoConstraints = NO;
self.verticalStackView.axis = UILayoutConstraintAxisVertical;
// A centered alignment will ensure the views are centered.
self.verticalStackView.alignment = UIStackViewAlignmentCenter;
// A fill distribution allows for the custom spacing between elements and
// height/width configurations for each row.
self.verticalStackView.distribution = UIStackViewDistributionFill;
[self.view addSubview:self.verticalStackView];
// Add bottom spacing to the last module by applying it after
// `_verticalStackView`. If `IsContentSuggestionsUIModuleRefreshEnabled()` is
// YES, and ShouldMinimizeSpacingForModuleRefresh() is YES, then no space is
// added after the last module. Otherwise we add kModuleVerticalSpacing. If
// `IsContentSuggestionsUIModuleRefreshEnabled()` is NO, then we add
// `kBottomStackViewPadding`
CGFloat bottomSpacing = kBottomStackViewPadding;
if (IsMagicStackEnabled()) {
// Add more spacing between magic stack and feed header.
bottomSpacing = kBottomStackViewExtraPadding;
}
[NSLayoutConstraint activateConstraints:@[
[self.verticalStackView.leadingAnchor
constraintEqualToAnchor:self.view.leadingAnchor],
[self.verticalStackView.trailingAnchor
constraintEqualToAnchor:self.view.trailingAnchor],
[self.verticalStackView.topAnchor
constraintEqualToAnchor:self.view.topAnchor],
[self.verticalStackView.bottomAnchor
constraintEqualToAnchor:self.view.bottomAnchor
constant:-bottomSpacing]
]];
if (self.returnToRecentTabTile) {
[self addUIElement:self.returnToRecentTabTile
withCustomBottomSpacing:content_suggestions::
kReturnToRecentTabSectionBottomMargin];
[self layoutReturnToRecentTabTile];
}
if ([self.mostVisitedViews count] > 0) {
[self createAndInsertMostVisitedModule];
[self populateMostVisitedModule];
}
if (_savedSetUpListItems) {
[self showSetUpListWithItems:_savedSetUpListItems];
}
if (self.shortcutsViews) {
self.shortcutsStackView = [self createShortcutsStackView];
if (!_shouldShowMagicStack) {
[self addUIElement:self.shortcutsStackView
withCustomBottomSpacing:kMostVisitedBottomMargin];
CGFloat width =
MostVisitedTilesContentHorizontalSpace(self.traitCollection);
CGFloat height =
MostVisitedCellSize(self.traitCollection.preferredContentSizeCategory)
.height;
[NSLayoutConstraint activateConstraints:@[
[self.shortcutsStackView.widthAnchor constraintEqualToConstant:width],
[self.shortcutsStackView.heightAnchor
constraintGreaterThanOrEqualToConstant:height]
]];
}
}
if (_shouldShowMagicStack) {
CHECK(IsMagicStackEnabled());
[self createMagicStack];
}
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
if (ShouldShowReturnToMostRecentTabForStartSurface()) {
[self.audience viewWillDisappear];
}
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldReceiveTouch:(UITouch*)touch {
return touch.view.accessibilityIdentifier !=
ntp_home::FakeOmniboxAccessibilityID() &&
touch.view.superview.accessibilityIdentifier !=
ntp_home::FakeOmniboxAccessibilityID();
}
#pragma mark - URLDropDelegate
- (BOOL)canHandleURLDropInView:(UIView*)view {
return YES;
}
- (void)view:(UIView*)view didDropURL:(const GURL&)URL atPoint:(CGPoint)point {
self.urlLoadingBrowserAgent->Load(UrlLoadParams::InCurrentTab(URL));
}
#pragma mark - ContentSuggestionsConsumer
- (void)showReturnToRecentTabTileWithConfig:
(ContentSuggestionsReturnToRecentTabItem*)config {
if (self.returnToRecentTabTile) {
[self.returnToRecentTabTile removeFromSuperview];
}
self.returnToRecentTabTile = [[ContentSuggestionsReturnToRecentTabView alloc]
initWithConfiguration:config];
self.returnToRecentTabTapRecognizer = [[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(contentSuggestionsElementTapped:)];
[self.returnToRecentTabTile
addGestureRecognizer:self.returnToRecentTabTapRecognizer];
self.returnToRecentTabTapRecognizer.enabled = YES;
// If the Content Suggestions is already shown, add the Return to Recent Tab
// tile to the StackView, otherwise, add to the verticalStackView.
if (self.isViewLoaded) {
[self.verticalStackView insertArrangedSubview:self.returnToRecentTabTile
atIndex:0];
[self.verticalStackView
setCustomSpacing:content_suggestions::
kReturnToRecentTabSectionBottomMargin
afterView:self.returnToRecentTabTile];
[self layoutReturnToRecentTabTile];
[self.audience returnToRecentTabWasAdded];
}
// Trigger a relayout so that the Return To Recent Tab view will be counted in
// the Content Suggestions height. Upon app startup when this is often added
// asynchronously as the NTP is constructing the entire surface, so accurate
// height info is very important to prevent pushing content below the Return
// To Recent Tab view down as opposed to pushing the content above the view up
// if it is not counted in the height.
// This only has to happen after `-viewDidLoad` has completed since it is
// adding views after the initial layout construction in `-viewDidLoad`.
if (self.viewLoaded) {
[self.view setNeedsLayout];
[self.view layoutIfNeeded];
}
}
- (void)updateReturnToRecentTabTileWithConfig:
(ContentSuggestionsReturnToRecentTabItem*)config {
if (config.icon) {
self.returnToRecentTabTile.iconImageView.image = config.icon;
self.returnToRecentTabTile.iconImageView.hidden = NO;
}
if (config.title) {
self.returnToRecentTabTile.subtitleLabel.text = config.subtitle;
}
}
- (void)hideReturnToRecentTabTile {
[self.returnToRecentTabTile removeFromSuperview];
self.returnToRecentTabTile = nil;
}
- (void)setMostVisitedTilesWithConfigs:
(NSArray<ContentSuggestionsMostVisitedItem*>*)configs {
if (!configs) {
return;
}
if ([self.mostVisitedViews count]) {
for (ContentSuggestionsMostVisitedTileView* view in self.mostVisitedViews) {
[view removeFromSuperview];
}
[self.mostVisitedViews removeAllObjects];
[self.mostVisitedTapRecognizers removeAllObjects];
} else {
self.mostVisitedViews = [NSMutableArray array];
}
if ([configs count] == 0) {
// No Most Visited Tiles to show. Remove module.
[self.mostVisitedStackView removeFromSuperview];
return;
}
NSInteger index = 0;
for (ContentSuggestionsMostVisitedItem* item in configs) {
ContentSuggestionsMostVisitedTileView* view =
[[ContentSuggestionsMostVisitedTileView alloc]
initWithConfiguration:item];
view.menuProvider = self.menuProvider;
view.accessibilityIdentifier = [NSString
stringWithFormat:
@"%@%li",
kContentSuggestionsMostVisitedAccessibilityIdentifierPrefix, index];
[self.mostVisitedViews addObject:view];
index++;
}
// If viewDidLoad has been called before the first valid Most Visited Tiles
// are available, construct `mostVisitedStackView`.
if (self.verticalStackView && !self.mostVisitedStackView) {
[self createAndInsertMostVisitedModule];
}
[self populateMostVisitedModule];
[self.contentSuggestionsMetricsRecorder recordMostVisitedTilesShown];
if (IsMagicStackEnabled()) {
[self logTopModuleImpressionForType:ContentSuggestionsModuleType::
kMostVisited];
}
// Trigger a relayout so that the MVTs will be counted in the Content
// Suggestions height. Upon app startup when this is often added
// asynchronously as the NTP is constructing the entire surface, so accurate
// height info is very important to simulate pushing content below the MVT
// down as opposed to pushing the content above the MVT up if the MVTs are not
// counted in the height.
// This only has to happen after `-viewDidLoad` has completed since it is
// adding views after the initial layout construction in `-viewDidLoad`.
if (self.viewLoaded) {
[self.view setNeedsLayout];
[self.view layoutIfNeeded];
}
}
- (void)setShortcutTilesWithConfigs:
(NSArray<ContentSuggestionsMostVisitedActionItem*>*)configs {
if (!self.shortcutsViews) {
self.shortcutsViews = [NSMutableArray array];
}
// Assumes this only called before viewDidLoad, so there is no need to add the
// views into the view hierarchy here.
for (ContentSuggestionsMostVisitedActionItem* item in configs) {
ContentSuggestionsShortcutTileView* view =
[[ContentSuggestionsShortcutTileView alloc] initWithConfiguration:item];
[self.shortcutsViews addObject:view];
}
if (IsMagicStackEnabled()) {
[self
logTopModuleImpressionForType:ContentSuggestionsModuleType::kShortcuts];
}
}
- (void)updateShortcutTileConfig:
(ContentSuggestionsMostVisitedActionItem*)config {
for (ContentSuggestionsShortcutTileView* view in self.shortcutsViews) {
if (view.config == config) {
[view updateConfiguration:config];
return;
}
}
}
- (void)updateMostVisitedTileConfig:(ContentSuggestionsMostVisitedItem*)config {
for (ContentSuggestionsMostVisitedTileView* view in self.mostVisitedViews) {
if (view.config == config) {
dispatch_async(dispatch_get_main_queue(), ^{
[view.faviconView configureWithAttributes:config.attributes];
});
return;
}
}
}
- (void)setMagicStackOrder:(NSArray<NSNumber*>*)order {
CHECK([order count] > 0);
_shouldShowMagicStack = YES;
_magicStackModuleOrder = [order mutableCopy];
}
- (void)scrollToNextMagicStackModuleForCompletedModule:
(ContentSuggestionsModuleType)moduleType {
ContentSuggestionsModuleType currentModule = [self currentlyShownModule];
// Do not scroll if the completed module is not the currently shown module.
if (currentModule != moduleType) {
return;
}
CGFloat nextPageContentOffsetX = [self
getNextPageOffsetForOffset:_magicStackScrollView.contentOffset.x
velocity:kMagicStackMinimumPaginationScrollVelocity +
1];
[_magicStackScrollView
setContentOffset:CGPointMake(nextPageContentOffsetX,
_magicStackScrollView.contentOffset.y)
animated:YES];
}
- (void)showSetUpListWithItems:(NSArray<SetUpListItemViewData*>*)items {
if (!self.viewLoaded) {
_savedSetUpListItems = items;
return;
}
NSUInteger index = [self.verticalStackView.arrangedSubviews
indexOfObject:self.mostVisitedStackView];
if (index == NSNotFound && self.returnToRecentTabTile) {
index = [self.verticalStackView.arrangedSubviews
indexOfObject:self.returnToRecentTabTile];
}
if (index == NSNotFound) {
index = 0;
} else {
index++;
}
if (IsMagicStackEnabled()) {
BOOL shouldShowCompactedSetUpListModule =
set_up_list_utils::ShouldShowCompactedSetUpListModule();
if (shouldShowCompactedSetUpListModule) {
_compactedSetUpListViews = [NSMutableArray array];
}
ContentSuggestionsModuleType firstItemType =
SetUpListModuleTypeForSetUpListType([items firstObject].type);
[self logTopModuleImpressionForType:shouldShowCompactedSetUpListModule
? ContentSuggestionsModuleType::
kCompactedSetUpList
: firstItemType];
for (SetUpListItemViewData* data in items) {
data.compactLayout = shouldShowCompactedSetUpListModule;
data.heroCellMagicStackLayout = !shouldShowCompactedSetUpListModule;
SetUpListItemView* view = [[SetUpListItemView alloc] initWithData:data];
view.tapDelegate = self;
ContentSuggestionsModuleType type =
SetUpListModuleTypeForSetUpListType(data.type);
if (shouldShowCompactedSetUpListModule) {
[_compactedSetUpListViews addObject:view];
}
switch (type) {
case ContentSuggestionsModuleType::kSetUpListSync:
_setUpListSyncItemView = view;
break;
case ContentSuggestionsModuleType::kSetUpListDefaultBrowser:
_setUpListDefaultBrowserItemView = view;
break;
case ContentSuggestionsModuleType::kSetUpListAutofill:
_setUpListAutofillItemView = view;
break;
case ContentSuggestionsModuleType::kSetUpListAllSet:
_setUpListAllSetItemView = view;
break;
default:
break;
}
// Only add it to the Magic Stack here if it is after the inital
// construction of the Magic Stack.
if (_magicStack) {
if (shouldShowCompactedSetUpListModule) {
MultiRowContainerView* multiRowContainer =
[[MultiRowContainerView alloc]
initWithViews:_compactedSetUpListViews];
MagicStackModuleContainer* setUpListCompactedModule =
[[MagicStackModuleContainer alloc]
initWithContentView:multiRowContainer
type:ContentSuggestionsModuleType::
kCompactedSetUpList
delegate:self];
[_magicStack
insertArrangedSubview:setUpListCompactedModule
atIndex:[self indexForMagicStackModule:
ContentSuggestionsModuleType::
kCompactedSetUpList]];
} else {
MagicStackModuleContainer* setUpListModule =
[[MagicStackModuleContainer alloc] initWithContentView:view
type:type
delegate:self];
[_magicStack
insertArrangedSubview:setUpListModule
atIndex:[self indexForMagicStackModule:type]];
}
}
}
} else {
SetUpListView* setUpListView =
[[SetUpListView alloc] initWithItems:items rootView:self.view];
setUpListView.delegate = self.setUpListViewDelegate;
self.setUpListView = setUpListView;
[self.verticalStackView insertArrangedSubview:setUpListView atIndex:index];
// The width of the SetUpList should match the Discover Feed. This seems to
// closely match the feed's logic.
CGFloat width = kSetUpListWidthRegular;
CGSize viewSize = self.view.frame.size;
if (MIN(viewSize.width, viewSize.height) >= kSetUpListWidthWide) {
width = kSetUpListWidthWide;
}
// Since this view is put into a StackView, this width constraint acts as
// a max width constraint - if the StackView is narrower, it will make the
// SetUpListView narrower.
[NSLayoutConstraint activateConstraints:@[
[setUpListView.widthAnchor constraintEqualToConstant:width],
]];
}
}
- (void)markSetUpListItemComplete:(SetUpListItemType)type
completion:(ProceduralBlock)completion {
if (IsMagicStackEnabled()) {
switch (type) {
case SetUpListItemType::kSignInSync:
[_setUpListSyncItemView markCompleteWithCompletion:completion];
break;
case SetUpListItemType::kDefaultBrowser:
[_setUpListDefaultBrowserItemView
markCompleteWithCompletion:completion];
break;
case SetUpListItemType::kAutofill:
[_setUpListAutofillItemView markCompleteWithCompletion:completion];
break;
default:
break;
}
} else {
[self.setUpListView markItemComplete:type completion:completion];
}
}
- (void)hideSetUpListWithAnimations:(ProceduralBlock)animations {
if (IsMagicStackEnabled()) {
// Remove Modules with animation
[self removeSetUpListItemsWithNewModule:nil];
return;
}
CHECK(self.setUpListView);
NSInteger index = [self.verticalStackView.arrangedSubviews
indexOfObject:self.setUpListView];
CHECK_NE(index, NSNotFound);
__weak __typeof(self) weakSelf = self;
[UIView animateWithDuration:kSetUpListHideAnimationDuration.InSecondsF()
animations:^{
__typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
strongSelf.setUpListView.hidden = YES;
strongSelf.setUpListView.alpha = 0;
[strongSelf.view setNeedsLayout];
[strongSelf.view layoutIfNeeded];
if (animations) {
animations();
}
}
completion:^(BOOL finished) {
__typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf.setUpListView removeFromSuperview];
strongSelf.setUpListView.delegate = nil;
strongSelf.setUpListView = nil;
}];
}
- (void)showSetUpListDoneWithAnimations:(ProceduralBlock)animations {
if (IsMagicStackEnabled()) {
SetUpListItemViewData* allSetData =
[[SetUpListItemViewData alloc] initWithType:SetUpListItemType::kAllSet
complete:NO];
allSetData.heroCellMagicStackLayout =
!set_up_list_utils::ShouldShowCompactedSetUpListModule();
SetUpListItemView* view =
[[SetUpListItemView alloc] initWithData:allSetData];
MagicStackModuleContainer* allSetModule = [[MagicStackModuleContainer alloc]
initWithContentView:view
type:ContentSuggestionsModuleType::kSetUpListAllSet
delegate:self];
// Determine which module to swap out.
[self removeSetUpListItemsWithNewModule:allSetModule];
return;
}
__weak __typeof(self) weakSelf = self;
[self.setUpListView showDoneWithAnimations:^{
[weakSelf.view setNeedsLayout];
[weakSelf.view layoutIfNeeded];
if (animations) {
animations();
}
}];
}
- (CGFloat)contentSuggestionsHeight {
CGFloat height = 0;
if ([self.mostVisitedViews count] > 0 &&
!ShouldPutMostVisitedSitesInMagicStack()) {
height += MostVisitedCellSize(
UIApplication.sharedApplication.preferredContentSizeCategory)
.height +
kMostVisitedBottomMargin;
}
if (_shouldShowMagicStack) {
height += _magicStackScrollView.contentSize.height;
} else {
if ([self.shortcutsViews count] > 0) {
height +=
MostVisitedCellSize(
UIApplication.sharedApplication.preferredContentSizeCategory)
.height;
}
}
if (self.returnToRecentTabTile) {
height += ReturnToRecentTabHeight();
}
if (self.setUpListView && !self.setUpListView.isHidden) {
height += self.setUpListView.frame.size.height;
}
return height;
}
#pragma mark - SetUpListItemViewTapDelegate methods
- (void)didTapSetUpListItemView:(SetUpListItemView*)view {
[self.audience didSelectSetUpListItem:view.type];
}
#pragma mark - ContentSuggestionsSelectionActions
- (void)contentSuggestionsElementTapped:(UIGestureRecognizer*)sender {
if ([sender.view
isKindOfClass:[ContentSuggestionsMostVisitedTileView class]]) {
ContentSuggestionsMostVisitedTileView* mostVisitedView =
static_cast<ContentSuggestionsMostVisitedTileView*>(sender.view);
[self.suggestionCommandHandler
openMostVisitedItem:mostVisitedView.config
atIndex:mostVisitedView.config.index];
} else if ([sender.view
isKindOfClass:[ContentSuggestionsShortcutTileView class]]) {
ContentSuggestionsShortcutTileView* shortcutView =
static_cast<ContentSuggestionsShortcutTileView*>(sender.view);
int index = static_cast<int>(shortcutView.config.index);
[self.suggestionCommandHandler openMostVisitedItem:shortcutView.config
atIndex:index];
} else if ([sender.view isKindOfClass:[ContentSuggestionsReturnToRecentTabView
class]]) {
ContentSuggestionsReturnToRecentTabView* returnToRecentTabView =
static_cast<ContentSuggestionsReturnToRecentTabView*>(sender.view);
__weak ContentSuggestionsReturnToRecentTabView* weakRecentTabView =
returnToRecentTabView;
UIGestureRecognizerState state = sender.state;
if (state == UIGestureRecognizerStateChanged ||
state == UIGestureRecognizerStateCancelled) {
// Do nothing if isn't a gesture start or end.
// If the gesture was cancelled by the system, then reset the background
// color since UIGestureRecognizerStateEnded will not be received.
if (state == UIGestureRecognizerStateCancelled) {
returnToRecentTabView.backgroundColor = [UIColor clearColor];
}
return;
}
BOOL touchBegan = state == UIGestureRecognizerStateBegan;
[UIView transitionWithView:returnToRecentTabView
duration:kMaterialDuration8
options:UIViewAnimationOptionCurveEaseInOut
animations:^{
weakRecentTabView.backgroundColor =
touchBegan ? [UIColor colorNamed:kGrey100Color]
: [UIColor clearColor];
}
completion:nil];
if (state == UIGestureRecognizerStateEnded) {
CGPoint point = [sender locationInView:returnToRecentTabView];
if (point.x < 0 || point.y < 0 ||
point.x > kReturnToRecentTabSize.width ||
point.y > ReturnToRecentTabHeight()) {
// Reset the highlighted state and do nothing if the gesture ended
// outside of the tile.
returnToRecentTabView.backgroundColor = [UIColor clearColor];
return;
}
[self.suggestionCommandHandler openMostRecentTab];
}
}
}
#pragma mark - UITraitEnvironment
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
if (content_suggestions::ShouldShowWiderMagicStackLayer(self.traitCollection,
self.view.window)) {
if (_returnToRecentTabWidthAnchor) {
// Match Module width when Magic Stack is enabled.
_returnToRecentTabWidthAnchor.constant = kMagicStackWideWidth;
}
_magicStackScrollView.clipsToBounds = YES;
_magicStackScrollViewWidthAnchor.constant = kMagicStackWideWidth;
} else {
if (_returnToRecentTabWidthAnchor) {
_returnToRecentTabWidthAnchor.constant =
content_suggestions::SearchFieldWidth(self.view.bounds.size.width,
self.traitCollection);
}
_magicStackScrollView.clipsToBounds = NO;
_magicStackScrollViewWidthAnchor.constant = [MagicStackModuleContainer
moduleWidthForHorizontalTraitCollection:self.traitCollection];
}
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewWillEndDragging:(UIScrollView*)scrollView
withVelocity:(CGPoint)velocity
targetContentOffset:(inout CGPoint*)targetContentOffset {
DCHECK(IsMagicStackEnabled());
targetContentOffset->x =
[self getNextPageOffsetForOffset:scrollView.contentOffset.x
velocity:velocity.x];
}
#pragma mark - UIScrollViewAccessibilityDelegate
// This reads out the new page whenever the user scrolls in VoiceOver.
- (NSString*)accessibilityScrollStatusForScrollView:(UIScrollView*)scrollView {
return [MagicStackModuleContainer
titleStringForModule:[self currentlyShownModule]];
}
#pragma mark - MagicStackModuleContainerDelegate
- (BOOL)doesMagicStackShowOnlyOneModule:(ContentSuggestionsModuleType)type {
// Return NO if Most Visited Module is asking while it is not in the Magic
// Stack.
if (type == ContentSuggestionsModuleType::kMostVisited &&
!ShouldPutMostVisitedSitesInMagicStack()) {
return NO;
}
ContentSuggestionsModuleType firstModuleType = (ContentSuggestionsModuleType)[
[_magicStackModuleOrder objectAtIndex:0] intValue];
return [_magicStackModuleOrder count] == 1 && firstModuleType == type;
}
- (void)seeMoreWasTappedForModuleType:(ContentSuggestionsModuleType)type {
[self.audience showSetUpListShowMoreMenu];
}
- (void)neverShowModuleType:(ContentSuggestionsModuleType)type {
[self.audience neverShowModuleType:type];
}
#pragma mark - Private
- (void)addUIElement:(UIView*)view withCustomBottomSpacing:(CGFloat)spacing {
[self.verticalStackView addArrangedSubview:view];
if (spacing > 0) {
[self.verticalStackView setCustomSpacing:spacing afterView:view];
}
}
- (void)layoutReturnToRecentTabTile {
CGFloat cardWidth = content_suggestions::SearchFieldWidth(
self.view.bounds.size.width, self.traitCollection);
if (IsMagicStackEnabled() &&
content_suggestions::ShouldShowWiderMagicStackLayer(self.traitCollection,
self.view.window)) {
// Match Module width when Magic Stack is enabled.
cardWidth = kMagicStackWideWidth;
}
_returnToRecentTabWidthAnchor =
[_returnToRecentTabTile.widthAnchor constraintEqualToConstant:cardWidth];
[NSLayoutConstraint activateConstraints:@[
_returnToRecentTabWidthAnchor,
[_returnToRecentTabTile.heightAnchor
constraintEqualToConstant:ReturnToRecentTabHeight()]
]];
}
- (void)createAndInsertMostVisitedModule {
CGFloat horizontalSpacing =
ContentSuggestionsTilesHorizontalSpacing(self.traitCollection);
self.mostVisitedStackView = [[UIStackView alloc] init];
self.mostVisitedStackView.axis = UILayoutConstraintAxisHorizontal;
self.mostVisitedStackView.distribution = UIStackViewDistributionFillEqually;
self.mostVisitedStackView.spacing = horizontalSpacing;
self.mostVisitedStackView.alignment = UIStackViewAlignmentTop;
// Find correct insertion position in the stack.
int insertionIndex = 0;
if (self.returnToRecentTabTile) {
insertionIndex++;
}
if (IsMagicStackEnabled()) {
self.mostVisitedModuleContainer = [[MagicStackModuleContainer alloc]
initWithContentView:self.mostVisitedStackView
type:ContentSuggestionsModuleType::kMostVisited
delegate:self];
if (ShouldPutMostVisitedSitesInMagicStack()) {
// Only add it to the Magic Stack here if it is after the inital
// construction of the Magic Stack.
if (_magicStack) {
[_magicStack
insertArrangedSubview:self.mostVisitedModuleContainer
atIndex:[self indexForMagicStackModule:
ContentSuggestionsModuleType::
kMostVisited]];
}
} else {
[self.verticalStackView
insertArrangedSubview:self.mostVisitedModuleContainer
atIndex:insertionIndex];
[self.verticalStackView setCustomSpacing:kMostVisitedBottomMargin
afterView:self.mostVisitedModuleContainer];
}
} else {
[self.verticalStackView insertArrangedSubview:self.mostVisitedStackView
atIndex:insertionIndex];
[self.verticalStackView setCustomSpacing:kMostVisitedBottomMargin
afterView:self.mostVisitedStackView];
CGFloat width =
MostVisitedTilesContentHorizontalSpace(self.traitCollection);
CGSize size =
MostVisitedCellSize(self.traitCollection.preferredContentSizeCategory);
[NSLayoutConstraint activateConstraints:@[
[self.mostVisitedStackView.widthAnchor constraintEqualToConstant:width],
[self.mostVisitedStackView.heightAnchor
constraintEqualToConstant:size.height]
]];
}
}
// Add the elements in `mostVisitedViews` into `verticalStackView`.
- (void)populateMostVisitedModule {
for (ContentSuggestionsMostVisitedTileView* view in self.mostVisitedViews) {
view.menuProvider = self.menuProvider;
UITapGestureRecognizer* tapRecognizer = [[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(contentSuggestionsElementTapped:)];
[view addGestureRecognizer:tapRecognizer];
tapRecognizer.enabled = YES;
[self.mostVisitedTapRecognizers addObject:tapRecognizer];
[self.mostVisitedStackView addArrangedSubview:view];
}
}
- (UIStackView*)createShortcutsStackView {
UIStackView* shortcutsStackView = [[UIStackView alloc] init];
shortcutsStackView.axis = UILayoutConstraintAxisHorizontal;
shortcutsStackView.distribution = UIStackViewDistributionFillEqually;
shortcutsStackView.spacing =
ContentSuggestionsTilesHorizontalSpacing(self.traitCollection);
shortcutsStackView.alignment = UIStackViewAlignmentTop;
NSUInteger index = 0;
for (ContentSuggestionsShortcutTileView* view in self.shortcutsViews) {
view.accessibilityIdentifier = [NSString
stringWithFormat:
@"%@%li", kContentSuggestionsShortcutsAccessibilityIdentifierPrefix,
index];
UITapGestureRecognizer* tapRecognizer = [[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(contentSuggestionsElementTapped:)];
[view addGestureRecognizer:tapRecognizer];
[self.mostVisitedTapRecognizers addObject:tapRecognizer];
[shortcutsStackView addArrangedSubview:view];
index++;
}
return shortcutsStackView;
}
// Logs `type` as the top module shown if it is first in
// `_magicStackModuleOrder`
- (void)logTopModuleImpressionForType:(ContentSuggestionsModuleType)type {
ContentSuggestionsModuleType firstModuleType = (ContentSuggestionsModuleType)[
[_magicStackModuleOrder objectAtIndex:0] intValue];
if (firstModuleType == type) {
[self.contentSuggestionsMetricsRecorder
recordMagicStackTopModuleImpressionForType:type];
}
}
- (void)createMagicStack {
_magicStackScrollView = [[UIScrollView alloc] init];
[_magicStackScrollView setShowsHorizontalScrollIndicator:NO];
_magicStackScrollView.clipsToBounds =
content_suggestions::ShouldShowWiderMagicStackLayer(self.traitCollection,
self.view.window);
_magicStackScrollView.delegate = self;
_magicStackScrollView.decelerationRate = UIScrollViewDecelerationRateFast;
_magicStackScrollView.accessibilityIdentifier =
kMagicStackScrollViewAccessibilityIdentifier;
[self addUIElement:_magicStackScrollView
withCustomBottomSpacing:kMostVisitedBottomMargin];
_magicStack = [[UIStackView alloc] init];
_magicStack.translatesAutoresizingMaskIntoConstraints = NO;
_magicStack.axis = UILayoutConstraintAxisHorizontal;
_magicStack.distribution = UIStackViewDistributionEqualSpacing;
_magicStack.spacing = kMagicStackSpacing;
// Ensures modules take up entire height of the Magic Stack.
_magicStack.alignment = UIStackViewAlignmentFill;
[_magicStackScrollView addSubview:_magicStack];
// Add Magic Stack modules in order dictated by `_magicStackModuleOrder`.
for (NSNumber* moduleType in _magicStackModuleOrder) {
ContentSuggestionsModuleType type =
(ContentSuggestionsModuleType)[moduleType intValue];
switch (type) {
case ContentSuggestionsModuleType::kShortcuts: {
self.shortcutsModuleContainer = [[MagicStackModuleContainer alloc]
initWithContentView:self.shortcutsStackView
type:type
delegate:self];
[_magicStack addArrangedSubview:self.shortcutsModuleContainer];
break;
}
case ContentSuggestionsModuleType::kMostVisited: {
if (ShouldPutMostVisitedSitesInMagicStack()) {
[_magicStack addArrangedSubview:self.mostVisitedModuleContainer];
}
break;
}
case ContentSuggestionsModuleType::kSetUpListSync: {
MagicStackModuleContainer* setUpListSyncModule =
[[MagicStackModuleContainer alloc]
initWithContentView:_setUpListSyncItemView
type:type
delegate:self];
[_magicStack addArrangedSubview:setUpListSyncModule];
break;
}
case ContentSuggestionsModuleType::kSetUpListDefaultBrowser: {
MagicStackModuleContainer* setUpListDefaultBrowserModule =
[[MagicStackModuleContainer alloc]
initWithContentView:_setUpListDefaultBrowserItemView
type:type
delegate:self];
[_magicStack addArrangedSubview:setUpListDefaultBrowserModule];
break;
}
case ContentSuggestionsModuleType::kSetUpListAutofill: {
MagicStackModuleContainer* setUpListAutofillModule =
[[MagicStackModuleContainer alloc]
initWithContentView:_setUpListAutofillItemView
type:type
delegate:self];
[_magicStack addArrangedSubview:setUpListAutofillModule];
break;
}
case ContentSuggestionsModuleType::kCompactedSetUpList: {
MultiRowContainerView* multiRowContainer =
[[MultiRowContainerView alloc]
initWithViews:_compactedSetUpListViews];
MagicStackModuleContainer* setUpListCompactedModule =
[[MagicStackModuleContainer alloc]
initWithContentView:multiRowContainer
type:ContentSuggestionsModuleType::
kCompactedSetUpList
delegate:self];
[_magicStack addArrangedSubview:setUpListCompactedModule];
break;
}
case ContentSuggestionsModuleType::kSetUpListAllSet: {
MagicStackModuleContainer* setUpListAllSetModule =
[[MagicStackModuleContainer alloc]
initWithContentView:_setUpListAllSetItemView
type:type
delegate:self];
[_magicStack addArrangedSubview:setUpListAllSetModule];
break;
}
default:
break;
}
}
AddSameConstraints(_magicStack, _magicStackScrollView);
// Define width of ScrollView. Instrinsic content height of the
// StackView within the ScrollView will define the height of the
// ScrollView.
CGFloat width = [MagicStackModuleContainer
moduleWidthForHorizontalTraitCollection:self.traitCollection];
// Magic Stack has a wider width for wider screens so that clipToBounds can be
// YES with a peeking module still visible.
if (content_suggestions::ShouldShowWiderMagicStackLayer(self.traitCollection,
self.view.window)) {
width = kMagicStackWideWidth;
}
_magicStackScrollViewWidthAnchor =
[_magicStackScrollView.widthAnchor constraintEqualToConstant:width];
[NSLayoutConstraint activateConstraints:@[
// Ensures only horizontal scrolling
[_magicStack.heightAnchor
constraintEqualToAnchor:_magicStackScrollView.heightAnchor],
_magicStackScrollViewWidthAnchor
]];
}
// Returns the index position `moduleType` should be placed in the Magic Stack.
// This should only be used when looking to add a module after the inital Magic
// Stack construction.
- (NSUInteger)indexForMagicStackModule:
(ContentSuggestionsModuleType)moduleType {
NSUInteger index = 0;
for (NSNumber* moduleTypeNum in _magicStackModuleOrder) {
ContentSuggestionsModuleType type =
(ContentSuggestionsModuleType)[moduleTypeNum intValue];
if (type == moduleType) {
return index;
}
index++;
}
NOTREACHED_NORETURN();
}
// Returns the `ContentSuggestionsModuleType` type of the module being currently
// shown in the Magic Stack.
- (ContentSuggestionsModuleType)currentlyShownModule {
CGFloat offset = _magicStackScrollView.contentOffset.x;
CGFloat moduleWidth = [MagicStackModuleContainer
moduleWidthForHorizontalTraitCollection:self.traitCollection];
NSUInteger moduleCount = [_magicStackModuleOrder count];
// Find closest page to the current scroll offset.
CGFloat closestPage = roundf(offset / moduleWidth);
closestPage = fminf(closestPage, moduleCount - 1);
return (ContentSuggestionsModuleType)[_magicStackModuleOrder[(
NSUInteger)closestPage] intValue];
}
// Replaces the currently visible Set Up List module with `newModule` if valid
// and then removes all other Set Up List modules.
- (void)removeSetUpListItemsWithNewModule:
(MagicStackModuleContainer*)newModule {
int index = 0;
NSMutableArray* viewIndicesToRemove = [NSMutableArray array];
for (NSNumber* moduleType in _magicStackModuleOrder) {
ContentSuggestionsModuleType type =
(ContentSuggestionsModuleType)[moduleType intValue];
switch (type) {
case ContentSuggestionsModuleType::kSetUpListSync:
case ContentSuggestionsModuleType::kSetUpListDefaultBrowser:
case ContentSuggestionsModuleType::kSetUpListAutofill:
[viewIndicesToRemove addObject:@(index)];
break;
default:
break;
}
index++;
}
__weak __typeof(self) weakSelf = self;
ProceduralBlock removeRemainingModules = ^{
__typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
// Remove all non-visible modules in reverse order
int removedModuleCount = [viewIndicesToRemove count];
for (int i = removedModuleCount - 1; i >= 0; i--) {
NSUInteger moduleIndex = [viewIndicesToRemove[i] integerValue];
UIView* moduleToRemove =
[strongSelf->_magicStack arrangedSubviews][moduleIndex];
[moduleToRemove removeFromSuperview];
[strongSelf->_magicStackModuleOrder removeObjectAtIndex:moduleIndex];
}
};
if (newModule) {
// Replace last Set Up List item with "All Set" hero cell.
NSUInteger moduleIndexToReplace =
[[viewIndicesToRemove lastObject] integerValue];
// Do not remove the replaced module.
[viewIndicesToRemove removeObjectAtIndex:moduleIndexToReplace];
[self replaceModuleAtIndex:moduleIndexToReplace
withModule:newModule
completion:removeRemainingModules];
} else {
removeRemainingModules();
}
}
// Replaces the module at `index` with `newModule` in the Magic Stack, executing
// `completion` after the completion of the replace animation.
- (void)replaceModuleAtIndex:(NSUInteger)index
withModule:(MagicStackModuleContainer*)newModule
completion:(ProceduralBlock)completion {
newModule.alpha = 0;
UIView* moduleToHide = [_magicStack arrangedSubviews][index];
__weak __typeof(self) weakSelf = self;
[UIView animateWithDuration:1.0
delay:0.0
options:UIViewAnimationOptionTransitionCurlDown
animations:^{
__typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf->_magicStack removeArrangedSubview:moduleToHide];
[strongSelf->_magicStack insertArrangedSubview:newModule atIndex:index];
moduleToHide.alpha = 0;
newModule.alpha = 1;
}
completion:^(BOOL finished) {
__typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
if (completion) {
completion();
}
[moduleToHide removeFromSuperview];
[strongSelf->_magicStack setNeedsLayout];
[strongSelf->_magicStack layoutIfNeeded];
}];
}
// Determines the final page offset given the scroll `offset` and the `velocity`
// scroll. If the drag is slow enough, then the closest page is the final state.
// If the drag is in the negative direction, then go to the page previous to the
// closest current page. If the drag is in the positive direction, then go to
// the page after the closest current page.
- (CGFloat)getNextPageOffsetForOffset:(CGFloat)offset
velocity:(CGFloat)velocity {
CGFloat moduleWidth = [MagicStackModuleContainer
moduleWidthForHorizontalTraitCollection:self.traitCollection];
NSUInteger moduleCount = [_magicStackModuleOrder count];
// Find closest page to the current scroll offset.
CGFloat closestPage = roundf(offset / moduleWidth);
closestPage = fminf(closestPage, moduleCount);
if (fabs(velocity) < kMagicStackMinimumPaginationScrollVelocity) {
return closestPage * moduleWidth + (closestPage * 10);
}
if (velocity < 0) {
return (closestPage - 1) * moduleWidth +
((closestPage - 1) * kMagicStackSpacing);
}
return (closestPage + 1) * moduleWidth +
((closestPage + 1) * kMagicStackSpacing);
}
@end