0

[ios] Add a moduleLayoutGuide to NTPViewController

This layout guide will initially be used to horizontally lay out all
the NTP "modules", including the MagicStack, the recent tab tile, MVTs,
and the "Feed Containment" module.

Using a layout guide allows the logic for module layout to be
centralized, and allows for easier maintenance of the code and an easier
path to implementing future changes.

Aligning the MagicStack with the layout guide required a number of
changes, in order to allow it to have a width that can change:
 * width constraints for modules are added when the module is added
 * module width is determined at the time it is needed, because it can
   change
 * a view was added to mask/clip the MagicStack in landscape, and a
   peek offset was used to ensure the module is aligned properly so that
   the modules on left and right peeking in looks right
 * when the module width changes, the MagicStackScrollView offset needs
   to be adjusted so that it is not left in the middle of a page

Demo of changes:
https://screenshot.googleplex.com/8zhB9SRjZAbphvP.png

Change-Id: I0fed92ddf67259deaf6e084aec1f64f5348494a0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5117205
Code-Coverage: findit-for-me@appspot.gserviceaccount.com <findit-for-me@appspot.gserviceaccount.com>
Reviewed-by: Chris Lu <thegreenfrog@chromium.org>
Commit-Queue: Scott Yoder <scottyoder@google.com>
Reviewed-by: Adam Arcaro <adamta@google.com>
Cr-Commit-Position: refs/heads/main@{#1238175}
This commit is contained in:
Scott Yoder
2023-12-15 19:01:34 +00:00
committed by Chromium LUCI CQ
parent 51bfc00eff
commit ced67f020f
13 changed files with 220 additions and 288 deletions

@ -261,7 +261,6 @@ source_set("content_suggestions_ui") {
"//ios/chrome/browser/ui/content_suggestions/set_up_list:utils",
"//ios/chrome/browser/ui/content_suggestions/tab_resumption",
"//ios/chrome/browser/ui/ntp:constants",
"//ios/chrome/browser/ui/ntp:ntp_ui_util",
"//ios/chrome/browser/ui/start_surface:feature_flags",
"//ios/chrome/browser/ui/toolbar/public",
"//ios/chrome/browser/url_loading/model",

@ -22,10 +22,6 @@ enum class ContentSuggestionsModuleType;
- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
- (instancetype)initWithCoder:(NSCoder*)aDecoder NS_UNAVAILABLE;
// Returns the module width depending on the horizontal `traitCollection`.
+ (CGFloat)moduleWidthForHorizontalTraitCollection:
(UITraitCollection*)traitCollection;
// Returns the title string for the module `type`.
+ (NSString*)titleStringForModule:(ContentSuggestionsModuleType)type;

@ -38,20 +38,11 @@ const CGFloat kContentVerticalSpacing = 16.0f;
// The corner radius of this container.
const float kCornerRadius = 24;
// The width of the modules.
const int kModuleWidthCompact = 343;
const int kModuleWidthRegular = 382;
// The max height of the modules.
const int kModuleMaxHeight = 150;
const CGFloat kSeparatorHeight = 0.5;
// The horizontal trailing spacing between the top horizontal StackView
// (containing the title and any subtitle/See More buttons) and the module's
// overall vertical container StackView when there is none between the overall
// vertical StackView and this container .
const CGFloat kTitleStackViewTrailingMargin = 16.0f;
} // namespace
@interface MagicStackModuleContainer () <UIContextMenuInteractionDelegate>
@ -62,7 +53,6 @@ const CGFloat kTitleStackViewTrailingMargin = 16.0f;
@end
@implementation MagicStackModuleContainer {
NSLayoutConstraint* _contentViewWidthAnchor;
id<MagicStackModuleContainerDelegate> _delegate;
UILabel* _title;
UILabel* _subtitle;
@ -199,25 +189,13 @@ const CGFloat kTitleStackViewTrailingMargin = 16.0f;
UIStackView* stackView = [[UIStackView alloc] init];
stackView.translatesAutoresizingMaskIntoConstraints = NO;
stackView.alignment = UIStackViewAlignmentLeading;
stackView.alignment = UIStackViewAlignmentFill;
stackView.axis = UILayoutConstraintAxisVertical;
stackView.spacing = kContentVerticalSpacing;
stackView.distribution = UIStackViewDistributionFill;
[stackView addSubview:contentView];
if ([_title.text length] > 0) {
[stackView addArrangedSubview:titleStackView];
// Ensure that there is horizontal trailing spacing between the title
// stackview content and the module. The overall StackView has no trailing
// spacing for kCompactedSetUpList.
CGFloat trailingSpacing =
_type == ContentSuggestionsModuleType::kCompactedSetUpList
? -kTitleStackViewTrailingMargin
: 0;
[NSLayoutConstraint activateConstraints:@[
[titleStackView.trailingAnchor
constraintEqualToAnchor:stackView.trailingAnchor
constant:trailingSpacing],
]];
}
if ([self shouldShowSeparator]) {
UIView* separator = [[UIView alloc] init];
@ -247,9 +225,6 @@ const CGFloat kTitleStackViewTrailingMargin = 16.0f;
}
self.accessibilityElements = accessibilityElements;
_contentViewWidthAnchor = [contentView.widthAnchor
constraintEqualToConstant:[self contentViewWidth]];
[NSLayoutConstraint activateConstraints:@[ _contentViewWidthAnchor ]];
// Configures `contentView` to be the view willing to expand if needed to
// fill extra vertical space in the container.
[contentView
@ -276,14 +251,6 @@ const CGFloat kTitleStackViewTrailingMargin = 16.0f;
return self;
}
// Returns the module width (CGFloat) given `traitCollection`.
+ (CGFloat)moduleWidthForHorizontalTraitCollection:
(UITraitCollection*)traitCollection {
return traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular
? kModuleWidthRegular
: kModuleWidthCompact;
}
// Returns the module's title, if any, given the Magic Stack module `type`.
+ (NSString*)titleStringForModule:(ContentSuggestionsModuleType)type {
switch (type) {
@ -348,9 +315,6 @@ const CGFloat kTitleStackViewTrailingMargin = 16.0f;
NSDirectionalEdgeInsetsMake(kContentTopInset, kContentHorizontalInset,
kContentBottomInset, kContentHorizontalInset);
switch (_type) {
case ContentSuggestionsModuleType::kCompactedSetUpList:
contentMargins.trailing = 0;
break;
case ContentSuggestionsModuleType::kMostVisited:
case ContentSuggestionsModuleType::kShortcuts:
case ContentSuggestionsModuleType::kSafetyCheckMultiRow:
@ -363,29 +327,6 @@ const CGFloat kTitleStackViewTrailingMargin = 16.0f;
return contentMargins;
}
// Returns the intrinsic content size.
- (CGSize)intrinsicContentSize {
// When the Most Visited Tiles module is not in the Magic Stack or if a module
// is the only module in the Magic Stack in a wider screen, the module should
// be wider to match the wider Magic Stack ScrollView.
BOOL MVTModuleShouldUseWideWidth =
(!_isPlaceholder && _type == ContentSuggestionsModuleType::kMostVisited &&
!ShouldPutMostVisitedSitesInMagicStack() &&
content_suggestions::ShouldShowWiderMagicStackLayer(self.traitCollection,
self.window));
BOOL moduleShouldUseWideWidth =
content_suggestions::ShouldShowWiderMagicStackLayer(self.traitCollection,
self.window) &&
[_delegate doesMagicStackShowOnlyOneModule:_type];
if (MVTModuleShouldUseWideWidth || moduleShouldUseWideWidth) {
return CGSizeMake(kMagicStackWideWidth, UIViewNoIntrinsicMetric);
}
return CGSizeMake(
[MagicStackModuleContainer
moduleWidthForHorizontalTraitCollection:self.traitCollection],
UIViewNoIntrinsicMetric);
}
#pragma mark - UITraitEnvironment
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
@ -394,11 +335,6 @@ const CGFloat kTitleStackViewTrailingMargin = 16.0f;
self.traitCollection.preferredContentSizeCategory) {
_title.font = [MagicStackModuleContainer fontForTitle];
}
_contentViewWidthAnchor.constant = [self contentViewWidth];
// Trigger relayout so intrinsic contentsize is recalculated.
[self invalidateIntrinsicContentSize];
[self sizeToFit];
[self layoutIfNeeded];
}
#pragma mark - UIContextMenuInteractionDelegate
@ -547,10 +483,4 @@ const CGFloat kTitleStackViewTrailingMargin = 16.0f;
}
}
// Returns the expected width of the contentView subview.
- (CGFloat)contentViewWidth {
NSDirectionalEdgeInsets insets = [self contentMargins];
return [self intrinsicContentSize].width - insets.leading - insets.trailing;
}
@end

@ -17,7 +17,6 @@ class WebState;
@protocol NewTabPageControllerDelegate;
@protocol NewTabPageDelegate;
@protocol NewTabPageMetricsDelegate;
@protocol NewTabPageViewDelegate;
// Coordinator to manage the Suggestions UI via a
// ContentSuggestionsViewController.
@ -50,9 +49,6 @@ class WebState;
// recorder.
@property(nonatomic, weak) id<NewTabPageMetricsDelegate> NTPMetricsDelegate;
// Delegate for getting information about NTP views.
@property(nonatomic, weak) id<NewTabPageViewDelegate> NTPViewDelegate;
// Configure Content Suggestions if showing the Start Surface. NOTE: this should
// only be called once for every Start configuration. Calling it multiple times
// in sequence can lead to unpredictable outcomes.

@ -274,7 +274,6 @@
self.contentSuggestionsViewController.parcelTrackingCommandHandler =
HandlerForProtocol(self.browser->GetCommandDispatcher(),
ParcelTrackingOptInCommands);
self.contentSuggestionsViewController.NTPViewDelegate = self.NTPViewDelegate;
self.contentSuggestionsMediator.consumer =
self.contentSuggestionsViewController;

@ -12,7 +12,6 @@
@protocol ContentSuggestionsCommands;
@protocol ContentSuggestionsMenuProvider;
@protocol ContentSuggestionsViewControllerAudience;
@protocol NewTabPageViewDelegate;
@protocol ParcelTrackingOptInCommands;
@protocol SafetyCheckViewDelegate;
@protocol SetUpListViewDelegate;
@ -42,9 +41,6 @@ class UrlLoadingBrowserAgent;
@property(nonatomic, weak) id<ContentSuggestionsMenuProvider> menuProvider;
@property(nonatomic, assign) UrlLoadingBrowserAgent* urlLoadingBrowserAgent;
// Delegate for getting information about NTP views.
@property(nonatomic, weak) id<NewTabPageViewDelegate> NTPViewDelegate;
// Recorder for content suggestions metrics.
@property(nonatomic, weak)
ContentSuggestionsMetricsRecorder* contentSuggestionsMetricsRecorder;
@ -59,6 +55,9 @@ class UrlLoadingBrowserAgent;
// The layout guide center to use to refer to the Magic Stack.
@property(nonatomic, strong) LayoutGuideCenter* layoutGuideCenter;
// Called when the module width has changed.
- (void)moduleWidthDidUpdate;
@end
#endif // IOS_CHROME_BROWSER_UI_CONTENT_SUGGESTIONS_CONTENT_SUGGESTIONS_VIEW_CONTROLLER_H_

@ -55,7 +55,6 @@
#import "ios/chrome/browser/ui/content_suggestions/tab_resumption/tab_resumption_view.h"
#import "ios/chrome/browser/ui/content_suggestions/tab_resumption/tab_resumption_view_delegate.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_header_constants.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_view_delegate.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/model/url_loading_browser_agent.h"
@ -81,6 +80,14 @@ const float kMagicStackMinimumPaginationScrollVelocity = 0.2f;
// The spacing between modules in the Magic Stack.
const float kMagicStackSpacing = 10.0f;
// The reduction in width of MagicStack modules from NTP modules. This
// reduction allows the next module to peek in from the side.
const float kMagicStackPeekInset = 16.0f;
// Controls the size of the view that masks/clips the MagicStack on wide
// screens. Defined as a multiplier of the NTP module width.
const CGFloat kMagicStackMaskWidth = 1.05;
// The corner radius of the Magic Stack.
const float kMagicStackCornerRadius = 16.0f;
@ -165,13 +172,13 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
@end
@implementation ContentSuggestionsViewController {
// Width Anchor of the Return To Recent Tab tile.
NSLayoutConstraint* _returnToRecentTabWidthAnchor;
UIScrollView* _magicStackScrollView;
UIStackView* _magicStack;
// View that masks the MagicStack in landscape, allowing the next module to
// peek in.
UIView* _magicStackMask;
BOOL _magicStackRankReceived;
NSMutableArray<NSNumber*>* _magicStackModuleOrder;
NSLayoutConstraint* _magicStackScrollViewWidthAnchor;
NSArray<SetUpListItemViewData*>* _savedSetUpListItems;
SetUpListItemView* _setUpListSyncItemView;
SetUpListItemView* _setUpListDefaultBrowserItemView;
@ -186,6 +193,8 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
NSMutableArray<MagicStackModuleContainer*>* _parcelTrackingModuleContainers;
NSLayoutConstraint* _mostVisitedTilesStackviewHeightAnchor;
NSLayoutConstraint* _shortcutsStackviewHeightAnchor;
// The most recently selected MagicStack module's page index.
NSUInteger _magicStackPage;
}
- (instancetype)init {
@ -298,6 +307,13 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
[self.audience viewWillDisappear];
}
- (void)moduleWidthDidUpdate {
if (_magicStackScrollView) {
_magicStackMask.clipsToBounds = [self shouldMaskMagicStack];
[self snapToNearestMagicStackModule];
}
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
@ -952,24 +968,6 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
_shortcutsStackviewHeightAnchor.constant = height;
}
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
@ -1057,21 +1055,9 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
}
- (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()]
]];
[_returnToRecentTabTile.widthAnchor
constraintEqualToAnchor:self.view.widthAnchor]
.active = YES;
}
- (void)createAndInsertMostVisitedModule {
@ -1105,21 +1091,12 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
atIndex:insertionIndex];
[self.verticalStackView setCustomSpacing:kMostVisitedBottomMargin
afterView:self.mostVisitedModuleContainer];
// When feed containment is enabled, the module on top of the magic stack
// should match the width of the feed module.
if (IsFeedContainmentEnabled()) {
[NSLayoutConstraint activateConstraints:@[
[self.mostVisitedModuleContainer.widthAnchor
constraintEqualToConstant:self.view.frame.size.width -
[self.NTPViewDelegate
homeModulePadding]],
[self.mostVisitedModuleContainer.centerXAnchor
constraintEqualToAnchor:self.view.centerXAnchor],
[self.mostVisitedStackView.centerXAnchor
constraintEqualToAnchor:self.mostVisitedModuleContainer
.centerXAnchor],
]];
}
[NSLayoutConstraint activateConstraints:@[
[self.mostVisitedModuleContainer.widthAnchor
constraintEqualToAnchor:self.view.widthAnchor],
[self.mostVisitedModuleContainer.centerXAnchor
constraintEqualToAnchor:self.view.centerXAnchor],
]];
}
} else {
[self.verticalStackView insertArrangedSubview:self.mostVisitedStackView
@ -1219,17 +1196,33 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
- (void)createMagicStack {
_magicStackScrollView = [[UIScrollView alloc] init];
[_magicStackScrollView setShowsHorizontalScrollIndicator:NO];
_magicStackScrollView.clipsToBounds =
content_suggestions::ShouldShowWiderMagicStackLayer(self.traitCollection,
self.view.window);
_magicStackScrollView.layer.cornerRadius = kMagicStackCornerRadius;
_magicStackScrollView.clipsToBounds = NO;
_magicStackScrollView.delegate = self;
_magicStackScrollView.decelerationRate = UIScrollViewDecelerationRateFast;
_magicStackScrollView.accessibilityIdentifier =
kMagicStackScrollViewAccessibilityIdentifier;
// Allow MagicStack modules to peek in from the sides, when scrolled.
_magicStackMask = [[UIView alloc] init];
[_magicStackMask addSubview:_magicStackScrollView];
_magicStackMask.clipsToBounds = [self shouldMaskMagicStack];
_magicStackMask.layer.cornerRadius = kMagicStackCornerRadius;
_magicStackMask.translatesAutoresizingMaskIntoConstraints = NO;
[NSLayoutConstraint activateConstraints:@[
[_magicStackMask.topAnchor
constraintEqualToAnchor:_magicStackScrollView.topAnchor],
[_magicStackMask.bottomAnchor
constraintEqualToAnchor:_magicStackScrollView.bottomAnchor],
[_magicStackMask.centerXAnchor
constraintEqualToAnchor:_magicStackScrollView.centerXAnchor],
[_magicStackMask.widthAnchor
constraintEqualToAnchor:_magicStackScrollView.widthAnchor
multiplier:kMagicStackMaskWidth],
]];
[self.verticalStackView addSubview:_magicStackMask];
[self addUIElement:_magicStackScrollView
withCustomBottomSpacing:kMostVisitedBottomMargin];
_magicStackPage = 0;
_magicStack = [[UIStackView alloc] init];
_magicStack.translatesAutoresizingMaskIntoConstraints = NO;
_magicStack.axis = UILayoutConstraintAxisHorizontal;
@ -1251,30 +1244,14 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
[_magicStack.heightAnchor
constraintEqualToAnchor:_magicStackScrollView.heightAnchor],
]];
// Define width of ScrollView.
// With feed containment enabled, the magic stack should be left aligned with
// the other modules.
if (IsFeedContainmentEnabled()) {
[NSLayoutConstraint activateConstraints:@[
[_magicStackScrollView.trailingAnchor
constraintEqualToAnchor:self.view.trailingAnchor
constant:-([self.NTPViewDelegate homeModulePadding] /
2)],
]];
} else {
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:@[ _magicStackScrollViewWidthAnchor ]];
}
[NSLayoutConstraint activateConstraints:@[
[_magicStackScrollView.leadingAnchor
constraintEqualToAnchor:self.view.leadingAnchor],
[_magicStackScrollView.trailingAnchor
constraintEqualToAnchor:self.view.trailingAnchor],
]];
}
// Resets and fills the Magic Stack with modules using `_magicStackModuleOrder`.
@ -1355,6 +1332,7 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
}
if (moduleContainer) {
[_magicStack addArrangedSubview:moduleContainer];
[self addWidthConstraintToMagicStackModule:moduleContainer];
[self logTopModuleImpressionForType:moduleContainer.type];
}
}
@ -1456,14 +1434,19 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
// first found module with a rank higher than `moduleToInsert` or just before
// the last arrangedSubview (e.g. edit button).
[_magicStack insertArrangedSubview:moduleToInsert atIndex:magicStackIndex];
[self addWidthConstraintToMagicStackModule:moduleToInsert];
}
// Returns the current width of MagicStack modules.
- (CGFloat)magicStackModuleWidth {
return _magicStackScrollView.frame.size.width - kMagicStackPeekInset;
}
// 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];
CGFloat moduleWidth = [self magicStackModuleWidth];
NSUInteger moduleCount = [_magicStackModuleOrder count];
// Find closest page to the current scroll offset.
CGFloat closestPage = roundf(offset / moduleWidth);
@ -1495,6 +1478,7 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
}
__weak __typeof(self) weakSelf = self;
CGFloat moduleWidth = [self magicStackModuleWidth];
ProceduralBlock removeRemainingModules = ^{
__typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
@ -1512,8 +1496,6 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
}
// Compensate for removed module count so the currently visible module is
// still displayed.
CGFloat moduleWidth = [MagicStackModuleContainer
moduleWidthForHorizontalTraitCollection:self.traitCollection];
CGFloat offsetRemoved = (removedModuleCount)*moduleWidth +
((removedModuleCount)*kMagicStackSpacing);
[strongSelf->_magicStackScrollView
@ -1565,6 +1547,7 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
completion:(ProceduralBlock)completion {
UIView* moduleToHide = [_magicStack arrangedSubviews][index];
__weak __typeof(self) weakSelf = self;
__weak __typeof(_magicStack) weakMagicStack = _magicStack;
ProceduralBlock animateInNewModule = ^{
[UIView animateWithDuration:0.5
@ -1610,19 +1593,38 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
// Remove module to hide, add the new module with an initial position to
// the left and hidden from view in preparation for a fade in.
newModule.alpha = 0;
[strongSelf->_magicStack removeArrangedSubview:moduleToHide];
[strongSelf->_magicStack insertArrangedSubview:newModule atIndex:index];
[weakMagicStack removeArrangedSubview:moduleToHide];
[weakMagicStack insertArrangedSubview:newModule atIndex:index];
[strongSelf addWidthConstraintToMagicStackModule:newModule];
newModule.transform = CGAffineTransformTranslate(
CGAffineTransformIdentity,
-kMagicStackReplaceModuleFadeAnimationDistance, 0);
[moduleToHide removeFromSuperview];
[strongSelf->_magicStack setNeedsLayout];
[strongSelf->_magicStack layoutIfNeeded];
[weakMagicStack setNeedsLayout];
[weakMagicStack layoutIfNeeded];
animateInNewModule();
}];
}
// Returns the extra offset needed to have a MagicStack module be left, center,
// or right aligned depending on whether the module is first, in the middle, or
// last.
- (CGFloat)peekOffsetForMagicStackPage:(NSUInteger)page {
if (page == 0) {
// The first module should be leading aligned so that the next module peeks
// in from the trailing edge.
return 0;
} else if (page == [_magicStackModuleOrder count] - 1) {
// The last module should be trailing aligned so the previous module peeks.
return kMagicStackPeekInset;
} else {
// Modules in the middle should show peek on both sides.
return kMagicStackPeekInset / 2;
}
}
// 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
@ -1630,23 +1632,51 @@ const base::TimeDelta kSetUpListHideAnimationDuration = base::Milliseconds(250);
// the page after the closest current page.
- (CGFloat)getNextPageOffsetForOffset:(CGFloat)offset
velocity:(CGFloat)velocity {
CGFloat moduleWidth = [MagicStackModuleContainer
moduleWidthForHorizontalTraitCollection:self.traitCollection];
CGFloat moduleWidth = [self magicStackModuleWidth];
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 (fabs(velocity) >= kMagicStackMinimumPaginationScrollVelocity) {
if (velocity < 0) {
closestPage--;
} else {
closestPage++;
}
}
if (velocity < 0) {
return (closestPage - 1) * moduleWidth +
((closestPage - 1) * kMagicStackSpacing);
}
return (closestPage + 1) * moduleWidth +
((closestPage + 1) * kMagicStackSpacing);
_magicStackPage = closestPage;
return _magicStackPage * (moduleWidth + kMagicStackSpacing) -
[self peekOffsetForMagicStackPage:_magicStackPage];
}
// Snaps the MagicStack ScrollView's contentOffset to the nearest module. Can
// be used after the width of the MagicStack changes to ensure that it doesn't
// end up scrolled to the middle of a module.
- (void)snapToNearestMagicStackModule {
CGFloat moduleWidth = [self magicStackModuleWidth];
CGPoint offset = _magicStackScrollView.contentOffset;
offset.x = _magicStackPage * (moduleWidth + kMagicStackSpacing) -
[self peekOffsetForMagicStackPage:_magicStackPage];
_magicStackScrollView.contentOffset = offset;
}
// Returns YES if the MagicStack should be masked so that modules only peek in
// from the sides. This is needed in landscape and on iPads.
- (BOOL)shouldMaskMagicStack {
return self.traitCollection.horizontalSizeClass ==
UIUserInterfaceSizeClassRegular ||
IsLandscape(self.view.window);
}
// Adds a layout constraint on the width of the given MagicStack module
// `container`.
- (void)addWidthConstraintToMagicStackModule:
(MagicStackModuleContainer*)container {
[container.widthAnchor
constraintEqualToAnchor:_magicStackScrollView.widthAnchor
constant:-kMagicStackPeekInset]
.active = YES;
}
@end

@ -1209,12 +1209,14 @@ id<GREYMatcher> mostlyNotVisible() {
[[AppLaunchManager sharedManager] ensureAppLaunchedWithConfiguration:config];
if (![ChromeEarlGrey isIPadIdiom]) {
[EarlGrey rotateDeviceToOrientation:UIDeviceOrientationLandscapeLeft
error:nil];
[[EarlGrey selectElementWithMatcher:chrome_test_util::NTPCollectionView()]
performAction:grey_scrollInDirection(kGREYDirectionDown, 100)];
}
id<GREYMatcher> magicStackScrollView =
grey_accessibilityID(kMagicStackScrollViewAccessibilityIdentifier);
// Scroll down to find the MagicStack.
[[[EarlGrey selectElementWithMatcher:magicStackScrollView]
usingSearchAction:grey_scrollInDirection(kGREYDirectionDown, 100.0f)
onElementWithMatcher:chrome_test_util::NTPCollectionView()]
assertWithMatcher:grey_notNil()];
// Verify Most Visited Tiles module title is visible.
[[EarlGrey selectElementWithMatcher:
@ -1223,10 +1225,11 @@ id<GREYMatcher> mostlyNotVisible() {
assertWithMatcher:grey_sufficientlyVisible()];
// Swipe to next module
[[EarlGrey
selectElementWithMatcher:
grey_accessibilityID(kMagicStackScrollViewAccessibilityIdentifier)]
performAction:grey_scrollInDirection(kGREYDirectionRight, 343)];
// Need to swipe at least half of the widest a module can be.
CGFloat moduleSwipeAmount = 250;
[[EarlGrey selectElementWithMatcher:magicStackScrollView]
performAction:GREYScrollInDirectionWithStartPoint(
kGREYDirectionRight, moduleSwipeAmount, 0.9, 0.5)];
// Verify Shortcuts module title is visible.
[[EarlGrey selectElementWithMatcher:
@ -1259,10 +1262,9 @@ id<GREYMatcher> mostlyNotVisible() {
assertWithMatcher:grey_sufficientlyVisible()];
// Swipe back to first module
[[EarlGrey
selectElementWithMatcher:
grey_accessibilityID(kMagicStackScrollViewAccessibilityIdentifier)]
performAction:grey_swipeFastInDirection(kGREYDirectionRight)];
[[EarlGrey selectElementWithMatcher:magicStackScrollView]
performAction:GREYScrollInDirectionWithStartPoint(
kGREYDirectionLeft, moduleSwipeAmount, 0.10, 0.5)];
// Verify Most Visited Tiles module title is visible.
[[EarlGrey selectElementWithMatcher:

@ -195,7 +195,6 @@ source_set("ntp_internal") {
":feature_flags",
":logo",
":ntp",
":ntp_ui_util",
"resources:fake_omnibox_background_color",
"resources:fake_omnibox_bottom_gradient_color",
"resources:fake_omnibox_solid_background_color",
@ -292,11 +291,6 @@ source_set("feed_menu") {
]
}
source_set("ntp_ui_util") {
sources = [ "new_tab_page_view_delegate.h" ]
deps = []
}
source_set("unit_tests") {
testonly = true
sources = [

@ -699,7 +699,6 @@
self.contentSuggestionsCoordinator.NTPDelegate = self;
self.contentSuggestionsCoordinator.delegate = self;
self.contentSuggestionsCoordinator.NTPMetricsDelegate = self;
self.contentSuggestionsCoordinator.NTPViewDelegate = self.NTPViewController;
[self.contentSuggestionsCoordinator start];
}

@ -9,7 +9,6 @@
#import "ios/chrome/browser/ui/ntp/new_tab_page_consumer.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_header_view_controller_delegate.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_view_delegate.h"
@class BubblePresenter;
@class ContentSuggestionsViewController;
@ -26,7 +25,6 @@
@interface NewTabPageViewController
: UIViewController <NewTabPageConsumer,
NewTabPageHeaderViewControllerDelegate,
NewTabPageViewDelegate,
UIScrollViewDelegate>
// View controller wrapping the feed.
@ -80,6 +78,9 @@
// Whether or not the fake omnibox is pinned to the top of the NTP.
@property(nonatomic, readonly) BOOL isFakeboxPinned;
// Layout guide for NTP modules.
@property(nonatomic, readonly) UILayoutGuide* moduleLayoutGuide;
// Initializes the new tab page view controller.
- (instancetype)init NS_DESIGNATED_INITIALIZER;

@ -44,6 +44,8 @@ const CGFloat kShiftTilesDownAnimationDuration = 0.2;
const CGFloat kShiftTilesUpAnimationDuration = 0.1;
// The minimum height of the feed container.
const CGFloat kFeedContainerMinimumHeight = 1000;
// The width of NTP modules, as a multiplier of the view width.
const CGFloat kModuleWidth = 0.92;
} // namespace
@interface NewTabPageViewController () <UICollectionViewDelegate,
@ -158,6 +160,10 @@ const CGFloat kFeedContainerMinimumHeight = 1000;
// YES if the view is in the process of appearing, but viewDidAppear hasn't
// finished yet.
BOOL _appearing;
// Layout Guide for NTP modules.
UILayoutGuide* _moduleLayoutGuide;
// Constraint controlling the width of modules on the NTP.
NSLayoutConstraint* _moduleWidth;
}
- (instancetype)init {
@ -326,6 +332,7 @@ const CGFloat kFeedContainerMinimumHeight = 1000;
void (^alongsideBlock)(id<UIViewControllerTransitionCoordinatorContext>) = ^(
id<UIViewControllerTransitionCoordinatorContext> context) {
[self updateModuleWidth];
[weakSelf handleStickyElementsForScrollPosition:[weakSelf scrollPosition]
force:YES];
@ -693,6 +700,23 @@ const CGFloat kFeedContainerMinimumHeight = 1000;
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (UILayoutGuide*)moduleLayoutGuide {
if (!_moduleLayoutGuide) {
_moduleLayoutGuide = [[UILayoutGuide alloc] init];
UIView* view = self.view;
[view addLayoutGuide:_moduleLayoutGuide];
[self updateModuleWidth];
[NSLayoutConstraint activateConstraints:@[
[_moduleLayoutGuide.centerXAnchor
constraintEqualToAnchor:view.centerXAnchor],
[_moduleLayoutGuide.topAnchor constraintEqualToAnchor:view.topAnchor],
[_moduleLayoutGuide.bottomAnchor
constraintEqualToAnchor:view.bottomAnchor],
]];
}
return _moduleLayoutGuide;
}
#pragma mark - NewTabPageConsumer
- (void)restoreScrollPosition:(CGFloat)scrollPosition {
@ -1001,19 +1025,6 @@ const CGFloat kFeedContainerMinimumHeight = 1000;
[self.animator startAnimation];
}
#pragma mark - NewTabPageViewDelegate
- (CGFloat)homeModulePadding {
if (!IsFeedContainmentEnabled()) {
return 0;
}
int screenWidth = self.view.frame.size.width;
int minPadding = HomeModuleMinimumPadding();
return minPadding - std::clamp(static_cast<int>(screenWidth -
kDiscoverFeedContentMaxWidth),
0, minPadding);
}
#pragma mark - Private
// Returns YES if scroll should be skipped when focusing the omnibox.
@ -1439,46 +1450,35 @@ const CGFloat kFeedContainerMinimumHeight = 1000;
if (self.feedHeaderViewController) {
[self cleanUpCollectionViewConstraints];
// Apply parent collection view constraints.
[NSLayoutConstraint activateConstraints:@[
[self.collectionView.centerXAnchor
constraintEqualToAnchor:[self containerView].centerXAnchor],
[self.collectionView.widthAnchor
constraintLessThanOrEqualToConstant:kDiscoverFeedContentMaxWidth],
]];
// Apply feed header constraints.
if (IsFeedContainmentEnabled()) {
[NSLayoutConstraint activateConstraints:@[
[self.feedHeaderViewController.view.centerXAnchor
constraintEqualToAnchor:self.collectionView.centerXAnchor],
[self.feedHeaderViewController.view.widthAnchor
constraintEqualToAnchor:self.collectionView.widthAnchor
constant:-[self homeModulePadding]],
]];
[self.collectionView.widthAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.widthAnchor]
.active = YES;
} else {
NSLayoutConstraint* headerWidthConstraint =
[self.feedHeaderViewController.view.widthAnchor
constraintEqualToAnchor:self.collectionView.widthAnchor];
headerWidthConstraint.priority = UILayoutPriorityDefaultHigh;
[NSLayoutConstraint activateConstraints:@[
[self.feedHeaderViewController.view.centerXAnchor
constraintEqualToAnchor:self.collectionView.centerXAnchor],
[self.feedHeaderViewController.view.widthAnchor
constraintLessThanOrEqualToConstant:kDiscoverFeedContentMaxWidth],
headerWidthConstraint,
]];
[self.collectionView.widthAnchor
constraintLessThanOrEqualToConstant:kDiscoverFeedContentMaxWidth]
.active = YES;
}
[NSLayoutConstraint activateConstraints:@[
// Apply parent collection view constraints.
[self.collectionView.centerXAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.centerXAnchor],
// Apply feed header constraints.
[self.feedHeaderViewController.view.centerXAnchor
constraintEqualToAnchor:self.collectionView.centerXAnchor],
[self.feedHeaderViewController.view.widthAnchor
constraintEqualToAnchor:self.collectionView.widthAnchor],
]];
[self setInitialFeedHeaderConstraints];
if (self.feedTopSectionViewController) {
[NSLayoutConstraint activateConstraints:@[
[self.feedTopSectionViewController.view.centerXAnchor
constraintEqualToAnchor:self.collectionView.centerXAnchor],
[self.feedTopSectionViewController.view.widthAnchor
constraintEqualToAnchor:self.collectionView.widthAnchor
constant:-[self homeModulePadding]],
constraintEqualToAnchor:self.collectionView.widthAnchor],
[self.feedTopSectionViewController.view.topAnchor
constraintEqualToAnchor:self.feedHeaderViewController.view
.bottomAnchor],
@ -1498,10 +1498,9 @@ const CGFloat kFeedContainerMinimumHeight = 1000;
CHECK(IsFeedContainmentEnabled());
[NSLayoutConstraint activateConstraints:@[
[_feedContainer.widthAnchor
constraintEqualToAnchor:self.collectionView.widthAnchor
constant:-[self homeModulePadding]],
constraintEqualToAnchor:self.moduleLayoutGuide.widthAnchor],
[_feedContainer.centerXAnchor
constraintEqualToAnchor:self.collectionView.centerXAnchor],
constraintEqualToAnchor:self.moduleLayoutGuide.centerXAnchor],
[_feedContainer.topAnchor
constraintEqualToAnchor:self.feedHeaderViewController.view.topAnchor],
]];
@ -1513,27 +1512,12 @@ const CGFloat kFeedContainerMinimumHeight = 1000;
constraintEqualToAnchor:self.headerViewController.view.leadingAnchor],
[[self containerView].safeAreaLayoutGuide.trailingAnchor
constraintEqualToAnchor:self.headerViewController.view.trailingAnchor],
[contentSuggestionsView.leadingAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.leadingAnchor],
[contentSuggestionsView.trailingAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.trailingAnchor],
]];
[self setInitialFakeOmniboxConstraints];
if (IsFeedContainmentEnabled()) {
// This should be an objective improvement since it prevents the width of
// the Content Suggestions from surpassing their parent, but the flag will
// guard the change for now to be safe.
[NSLayoutConstraint activateConstraints:@[
[contentSuggestionsView.safeAreaLayoutGuide.leadingAnchor
constraintEqualToAnchor:self.collectionView.leadingAnchor],
[contentSuggestionsView.safeAreaLayoutGuide.trailingAnchor
constraintEqualToAnchor:self.collectionView.trailingAnchor],
]];
} else {
[NSLayoutConstraint activateConstraints:@[
[[self containerView].safeAreaLayoutGuide.leadingAnchor
constraintEqualToAnchor:contentSuggestionsView.leadingAnchor],
[[self containerView].safeAreaLayoutGuide.trailingAnchor
constraintEqualToAnchor:contentSuggestionsView.trailingAnchor],
]];
}
}
// Sets minimum height for the NTP collection view, allowing it to scroll enough
@ -1643,6 +1627,27 @@ const CGFloat kFeedContainerMinimumHeight = 1000;
self.feedContainerHeightConstraint.active = YES;
}
// Updates the width constraint of `moduleLayoutGuide`.
- (void)updateModuleWidth {
BOOL existingConstraintActive = _moduleWidth.active;
_moduleWidth.active = NO;
CGFloat width;
if (IsFeedContainmentEnabled()) {
width = MIN(self.view.frame.size.width * kModuleWidth,
kDiscoverFeedContentMaxWidth);
} else {
width = content_suggestions::SearchFieldWidth(self.view.frame.size.width,
self.traitCollection);
}
_moduleWidth =
[self.moduleLayoutGuide.widthAnchor constraintEqualToConstant:width];
_moduleWidth.active = YES;
if (existingConstraintActive) {
[self.view layoutIfNeeded];
[self.contentSuggestionsViewController moduleWidthDidUpdate];
}
}
#pragma mark - Helpers
- (UIViewController*)contentSuggestionsViewController {

@ -1,18 +0,0 @@
// 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.
#ifndef IOS_CHROME_BROWSER_UI_NTP_NEW_TAB_PAGE_VIEW_DELEGATE_H_
#define IOS_CHROME_BROWSER_UI_NTP_NEW_TAB_PAGE_VIEW_DELEGATE_H_
// Delegate for NTP view information to be passed to the Content Suggestions.
@protocol NewTabPageViewDelegate
// Returns the necessary padding between the Home modules and the sides of the
// screen. This can range anywhere between 0 and `HomeModuleMinimumPadding()`,
// depending on the screen size.
- (CGFloat)homeModulePadding;
@end
#endif // IOS_CHROME_BROWSER_UI_NTP_NEW_TAB_PAGE_VIEW_DELEGATE_H_