// Copyright 2020 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/ntp/ui_bundled/new_tab_page_view_controller.h"
#import <UIKit/UIKit.h>
#import <algorithm>
#import "base/check.h"
#import "base/feature_list.h"
#import "base/ios/block_types.h"
#import "base/task/sequenced_task_runner.h"
#import "ios/chrome/browser/overscroll_actions/ui_bundled/overscroll_actions_controller.h"
#import "ios/chrome/browser/shared/model/utils/first_run_util.h"
#import "ios/chrome/browser/shared/public/commands/help_commands.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/content_suggestions_collection_utils.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_view_controller.h"
#import "ios/chrome/browser/ui/content_suggestions/magic_stack/magic_stack_collection_view.h"
#import "ios/chrome/browser/ui/content_suggestions/magic_stack/magic_stack_constants.h"
#import "ios/chrome/browser/ui/content_suggestions/ntp_home_constant.h"
#import "ios/chrome/browser/ntp/ui_bundled/discover_feed_constants.h"
#import "ios/chrome/browser/ntp/ui_bundled/feed_header_view_controller.h"
#import "ios/chrome/browser/ntp/ui_bundled/feed_wrapper_view_controller.h"
#import "ios/chrome/browser/ntp/shared/metrics/feed_metrics_constants.h"
#import "ios/chrome/browser/ntp/shared/metrics/feed_metrics_recorder.h"
#import "ios/chrome/browser/ntp/ui_bundled/new_tab_page_constants.h"
#import "ios/chrome/browser/ntp/ui_bundled/new_tab_page_content_delegate.h"
#import "ios/chrome/browser/ntp/ui_bundled/new_tab_page_feature.h"
#import "ios/chrome/browser/ntp/ui_bundled/new_tab_page_header_constants.h"
#import "ios/chrome/browser/ntp/ui_bundled/new_tab_page_header_view_controller.h"
#import "ios/chrome/browser/ntp/ui_bundled/new_tab_page_mutator.h"
#import "ios/chrome/browser/ui/toolbar/public/toolbar_utils.h"
#import "ios/chrome/common/material_timing.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/elements/gradient_view.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"
#import "ui/base/device_form_factor.h"
namespace {
// Animation time for the shift up/down animations to focus/defocus omnibox.
const CGFloat kShiftTilesUpAnimationDuration = 0.1;
// The minimum height of the feed container.
const CGFloat kFeedContainerMinimumHeight = 1000;
// Added height to the feed container so that it doesn't end abruptly on
// overscroll.
const CGFloat kFeedContainerExtraHeight = 500;
} // namespace
@interface NewTabPageViewController () <UICollectionViewDelegate,
UIGestureRecognizerDelegate>
// The overscroll actions controller managing accelerators over the toolbar.
@property(nonatomic, strong)
OverscrollActionsController* overscrollActionsController;
// Whether or not the user has scrolled into the feed, transferring ownership of
// the omnibox to allow it to stick to the top of the NTP.
// With Web Channels enabled, also determines if the feed header is stuck to the
// top.
@property(nonatomic, assign, getter=isScrolledIntoFeed) BOOL scrolledIntoFeed;
// Whether or not the fake omnibox is pinned to the top of the NTP. Redefined
// to make readwrite.
@property(nonatomic, assign) BOOL isFakeboxPinned;
// Array of constraints used to pin the fake Omnibox header into the top of the
// view.
@property(nonatomic, strong)
NSArray<NSLayoutConstraint*>* fakeOmniboxConstraints;
// Constraint that pins the fake Omnibox to the top of the view. A subset of
// `fakeOmniboxConstraints`.
@property(nonatomic, strong) NSLayoutConstraint* headerTopAnchor;
// Array of constraints used to pin the feed header to the top of the NTP. Only
// applicable with Web Channels enabled.
@property(nonatomic, strong)
NSArray<NSLayoutConstraint*>* feedHeaderConstraints;
// Constraint for the height of the container view surrounding the feed.
@property(nonatomic, strong) NSLayoutConstraint* feedContainerHeightConstraint;
// `YES` if the NTP starting content offset should be set to a previous scroll
// state (when navigating away and back), and `NO` if it should be the top of
// the NTP.
@property(nonatomic, assign) BOOL hasSavedOffsetFromPreviousScrollState;
// The content offset saved from a previous scroll state in the NTP. If this is
// set, `hasSavedOffsetFromPreviousScrollState` should be YES.
@property(nonatomic, assign) CGFloat savedScrollOffset;
// The scroll position when a scrolling event starts.
@property(nonatomic, assign) int scrollStartPosition;
// Whether the omnibox should be focused once the collection view appears.
@property(nonatomic, assign) BOOL shouldFocusFakebox;
// Array of all view controllers above the feed.
@property(nonatomic, strong)
NSMutableArray<UIViewController*>* viewControllersAboveFeed;
// Identity disc shown in the NTP.
// TODO(crbug.com/40165977): Remove once the Feed header properly supports
// ContentSuggestions.
@property(nonatomic, weak) UIButton* identityDiscButton;
// Tap gesture recognizer when the omnibox is focused.
@property(nonatomic, strong) UITapGestureRecognizer* tapGestureRecognizer;
// Animator for the `shiftTilesUpToFocusOmnibox` animation.
@property(nonatomic, strong) UIViewPropertyAnimator* animator;
// When the omnibox is focused, this value represents the shift distance of the
// collection needed to pin the omnibox to the top. It is 0 if the omnibox has
// not been moved when focused (i.e. the collection was already scrolled to
// top).
@property(nonatomic, assign, readwrite) CGFloat collectionShiftingOffset;
// `YES` if the collection is scrolled to the point where the omnibox is stuck
// to the top of the NTP. Used to lock this position in place on various frame
// changes.
@property(nonatomic, assign, readwrite) BOOL scrolledToMinimumHeight;
// If YES the animations of the fake omnibox triggered when the collection is
// scrolled (expansion) are disabled. This is used for the fake omnibox focus
// animations so the constraints aren't changed while the ntp is scrolled.
@property(nonatomic, assign) BOOL disableScrollAnimation;
// `YES` if the fakebox header should be animated on scroll.
@property(nonatomic, assign) BOOL shouldAnimateHeader;
// Keeps track of how long the shift down animation has taken. Used to update
// the Content Suggestions header as the animation progresses.
@property(nonatomic, assign) CFTimeInterval shiftTileStartTime;
// YES if `-viewDidLoad:` has finished executing. This is used to ensure that
// constraints are not set before the views have been added to view hierarchy.
@property(nonatomic, assign) BOOL viewDidFinishLoading;
// YES if the NTP is in the middle of animating an omnibox focus.
@property(nonatomic, assign) BOOL isAnimatingOmniboxFocus;
// `YES` when notifications indicate the omnibox is focused.
@property(nonatomic, assign) BOOL omniboxFocused;
// When set to YES, the scroll position wont be updated.
@property(nonatomic, assign) BOOL inhibitScrollPositionUpdates;
// YES if there is a currently running "shift down" / omnibox defocus animation
// running.
@property(nonatomic, assign) BOOL shiftDownInProgress;
@end
@implementation NewTabPageViewController {
// Background gradient when Modular Home is enabled.
GradientView* _backgroundGradientView;
// Container view surrounding the feed.
UIView* _feedContainer;
// 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;
}
// Properties synthesized from NewTabPageConsumer.
@synthesize mostVisitedVisible = _mostVisitedVisible;
@synthesize magicStackVisible = _magicStackVisible;
- (instancetype)init {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_viewControllersAboveFeed = [[NSMutableArray alloc] init];
_tapGestureRecognizer = [[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(unfocusOmnibox)];
_collectionShiftingOffset = 0;
_shouldAnimateHeader = YES;
_focusAccessibilityOmniboxWhenViewAppears = YES;
_inhibitScrollPositionUpdates = NO;
_shiftTileStartTime = -1;
_appearing = YES;
}
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
DCHECK(self.feedWrapperViewController);
self.view.accessibilityIdentifier = kNTPViewIdentifier;
// TODO(crbug.com/40799579): Remove this when bug is fixed.
[self.feedWrapperViewController loadViewIfNeeded];
[self.contentSuggestionsViewController loadViewIfNeeded];
// Prevent the NTP from spilling behind the toolbar and tab strip.
self.view.clipsToBounds = YES;
// TODO(crbug.com/40251609): The contentCollectionView width might be narrower
// than the ContentSuggestions view. This causes elements to be hidden. A
// gesture recognizer is added to allow these elements to be interactable.
UITapGestureRecognizer* singleTapRecognizer = [[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(handleSingleTapInView:)];
singleTapRecognizer.delegate = self;
[self.view addGestureRecognizer:singleTapRecognizer];
_backgroundGradientView = [[GradientView alloc]
initWithTopColor:[UIColor colorNamed:kSecondaryBackgroundColor]
bottomColor:[UIColor colorNamed:kPrimaryBackgroundColor]];
_backgroundGradientView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:_backgroundGradientView];
AddSameConstraints(_backgroundGradientView, self.view);
[self updateModularHomeBackgroundColorForUserInterfaceStyle:
self.traitCollection.userInterfaceStyle];
self.view.backgroundColor = [UIColor colorNamed:@"ntp_background_color"];
[self registerNotifications];
[self layoutContentInParentCollectionView];
self.identityDiscButton = [self.headerViewController identityDiscButton];
DCHECK(self.identityDiscButton);
self.viewDidFinishLoading = YES;
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
_appearing = YES;
self.headerViewController.view.alpha = 1;
self.headerViewController.showing = YES;
[self updateNTPLayout];
// Scroll to the top before coming into view to minimize sudden visual jerking
// for startup instances showing the NTP.
if (!self.viewDidAppear && !self.hasSavedOffsetFromPreviousScrollState) {
[self setContentOffsetToTop];
}
if (self.focusAccessibilityOmniboxWhenViewAppears && !self.omniboxFocused) {
[self.headerViewController focusAccessibilityOnOmnibox];
}
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
// `-feedLayoutDidEndUpdates` handles the need to either scroll to the top of
// go back to a previous scroll state when the feed is enabled. This handles
// the instance when the feed is not enabled.
// `-viewWillAppear:` is not the suitable place for this as long as the user
// can open a new tab while an NTP is currently visible. `-viewWillAppear:` is
// called before the offset can be saved, so `-setContentOffsetToTop` will
// reset any scrolled position.
// It is NOT safe to reset `hasSavedOffsetFromPreviousScrollState` to NO here
// because -updateHeightAboveFeedAndScrollToTopIfNeeded calls from async
// updates to the Content Suggestions (i.e. MVT, Doodle) can happen after
// this.
if (!self.feedVisible) {
if (self.hasSavedOffsetFromPreviousScrollState) {
[self setContentOffset:self.savedScrollOffset];
} else {
[self setContentOffsetToTop];
}
}
// Updates omnibox to ensure that the dimensions are correct when navigating
// back to the NTP.
[self updateFakeOmniboxForScrollPosition];
if (self.feedVisible) {
[self updateFeedInsetsForMinimumHeight];
} else {
[self setMinimumHeight];
}
[self.helpHandler
presentInProductHelpWithType:InProductHelpType::kDiscoverFeedMenu];
if (IsHomeCustomizationEnabled() && !IsFirstRunRecent(base::Days(3))) {
[self.helpHandler
presentInProductHelpWithType:InProductHelpType::kHomeCustomizationMenu];
}
// Scrolls NTP into feed initially if `shouldScrollIntoFeed`.
if (self.shouldScrollIntoFeed) {
[self scrollIntoFeed];
self.shouldScrollIntoFeed = NO;
}
[self updateFeedSigninPromoIsVisible];
// Since this VC is shared across web states, the stickiness might have
// changed in another tab. This ensures that the sticky elements are correct
// whenever an NTP reappears.
[self handleStickyElementsForScrollPosition:[self scrollPosition] force:YES];
if (self.shouldFocusFakebox) {
self.shouldFocusFakebox = NO;
__weak __typeof(self) weakSelf = self;
// Since a focus was requested before the view appeared, the shift up to
// focus should be performed without animation so that the NTP appears and
// is immediately ready to focus the omnibox. The actual focus animation
// will still happen.
[UIView performWithoutAnimation:^{
[weakSelf shiftTilesUpToFocusOmnibox];
}];
}
self.viewDidAppear = YES;
_appearing = NO;
}
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
self.headerViewController.showing = NO;
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
[self updateModuleWidth];
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
__weak NewTabPageViewController* weakSelf = self;
CGFloat yOffsetBeforeRotation = [self scrollPosition];
CGFloat heightAboveFeedBeforeRotation = [self heightAboveFeed];
void (^alongsideBlock)(id<UIViewControllerTransitionCoordinatorContext>) = ^(
id<UIViewControllerTransitionCoordinatorContext> context) {
[self updateModuleWidth];
[weakSelf handleStickyElementsForScrollPosition:[weakSelf scrollPosition]
force:YES];
CGFloat heightAboveFeedDifference =
[weakSelf heightAboveFeed] - heightAboveFeedBeforeRotation;
// Rotating the device can change the content suggestions height. This
// ensures that it is adjusted if necessary.
if (yOffsetBeforeRotation < 0) {
weakSelf.collectionView.contentOffset =
CGPointMake(0, yOffsetBeforeRotation - heightAboveFeedDifference);
[weakSelf updateNTPLayout];
}
[weakSelf.view setNeedsLayout];
[weakSelf.view layoutIfNeeded];
// Pinned offset is different based on the orientation, so we reevaluate the
// minimum scroll position upon device rotation.
CGFloat pinnedOffsetY = [weakSelf pinnedOffsetY];
if (weakSelf.omniboxFocused && [weakSelf scrollPosition] < pinnedOffsetY) {
weakSelf.collectionView.contentOffset = CGPointMake(0, pinnedOffsetY);
}
if (!weakSelf.feedVisible) {
[weakSelf setMinimumHeight];
}
};
[coordinator
animateAlongsideTransition:alongsideBlock
completion:^(
id<UIViewControllerTransitionCoordinatorContext>) {
[self updateNTPLayout];
if (self.feedVisible) {
[self updateFeedInsetsForMinimumHeight];
}
[self updateFeedContainerHeight];
}];
}
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
if (previousTraitCollection.userInterfaceStyle !=
self.traitCollection.userInterfaceStyle) {
[self updateModularHomeBackgroundColorForUserInterfaceStyle:
self.traitCollection.userInterfaceStyle];
}
if (previousTraitCollection.horizontalSizeClass !=
self.traitCollection.horizontalSizeClass) {
// Update header constant to cover rotation instances. When the omnibox is
// pinned to the top, the fake omnibox is the one shown only in portrait
// mode, so if the NTP is opened in landscape mode, a rotation to portrait
// mode needs to update the top anchor constant based on the correct header
// height.
self.headerTopAnchor.constant =
-([self stickyOmniboxHeight] + [self feedHeaderHeight]);
[self.contentSuggestionsViewController.view setNeedsLayout];
[self.contentSuggestionsViewController.view layoutIfNeeded];
[self updateOverscrollActionsState];
[self updateHeightAboveFeed];
}
if (previousTraitCollection.preferredContentSizeCategory !=
self.traitCollection.preferredContentSizeCategory) {
[self updateFakeOmniboxForScrollPosition];
[self updateOverscrollActionsState];
// Subviews will receive traitCollectionDidChange after this call, so the
// only way to ensure that the scrollview isn't scrolled up too far is to
// circle back afterwards and adjust if needed.
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(^{
[self updateHeightAboveFeed];
}));
}
}
#pragma mark - Public
- (void)focusOmnibox {
// Do nothing if the omnibox is already focused or is in the middle of a
// focus. This prevents `collectionShiftingOffset` from being reset to close
// to 0, which would result in the defocus animation not returning to the top
// of the NTP if that was the original position.
// This is relevant beacuse the omnibox logic signals the NTP to focus the
// omnibox when it becomes the keyboard first responder, but that happens
// during the NTP focus animation, which results in -focusOmnibox being called
// twice.
if (self.omniboxFocused || self.isAnimatingOmniboxFocus) {
return;
}
// If the feed is meant to be visible and its contents have not loaded yet,
// then any omnibox focus animations (i.e. opening app from search widget
// action) needs to wait until it is ready. viewDidAppear: currently serves as
// this proxy as there is no specific signal given from the feed that its
// contents have loaded.
if (self.feedVisible && _appearing) {
self.shouldFocusFakebox = YES;
} else {
[self shiftTilesUpToFocusOmnibox];
}
}
- (void)layoutContentInParentCollectionView {
DCHECK(self.feedWrapperViewController);
// Ensure the view is loaded so we can set the accessibility identifier.
[self.feedWrapperViewController loadViewIfNeeded];
self.collectionView.accessibilityIdentifier = kNTPCollectionViewIdentifier;
if (self.feedVisible) {
_feedContainer = [[UIView alloc] initWithFrame:CGRectZero];
_feedContainer.userInteractionEnabled = YES;
_feedContainer.translatesAutoresizingMaskIntoConstraints = NO;
_feedContainer.backgroundColor = [UIColor colorNamed:kBackgroundColor];
// Add corner radius to the top border.
_feedContainer.clipsToBounds = YES;
_feedContainer.layer.cornerRadius = kHomeModuleContainerCornerRadius;
_feedContainer.layer.maskedCorners =
kCALayerMaxXMinYCorner | kCALayerMinXMinYCorner;
_feedContainer.layer.masksToBounds = YES;
_feedContainer.layer.zPosition = -CGFLOAT_MAX;
[self.collectionView insertSubview:_feedContainer atIndex:0];
}
// Configures the feed and wrapper in the view hierarchy.
UIView* feedView = self.feedWrapperViewController.view;
[self.feedWrapperViewController willMoveToParentViewController:self];
[self addChildViewController:self.feedWrapperViewController];
[self.view addSubview:feedView];
[self.feedWrapperViewController didMoveToParentViewController:self];
feedView.translatesAutoresizingMaskIntoConstraints = NO;
AddSameConstraints(feedView, self.view);
// Configures the content suggestions in the view hierarchy.
// TODO(crbug.com/40799579): Remove this when issue is fixed.
if (self.contentSuggestionsViewController.parentViewController) {
[self.contentSuggestionsViewController willMoveToParentViewController:nil];
[self.contentSuggestionsViewController.view removeFromSuperview];
[self.contentSuggestionsViewController removeFromParentViewController];
[self.feedMetricsRecorder
recordBrokenNTPHierarchy:BrokenNTPHierarchyRelationship::
kContentSuggestionsReset];
}
// Adds the feed top section to the view hierarchy if it exists.
if (self.feedTopSectionViewController) {
[self addViewControllerAboveFeed:self.feedTopSectionViewController];
}
// Configures the feed header in the view hierarchy if it is visible. Add it
// in the order that guarantees it is behind `headerViewController` and in
// front of all other views.
if (self.feedHeaderViewController) {
[self addViewControllerAboveFeed:self.feedHeaderViewController];
}
if (!IsHomeCustomizationEnabled() || self.magicStackVisible) {
[self addViewControllerAboveFeed:self.magicStackCollectionView];
}
if (!ShouldPutMostVisitedSitesInMagicStack() &&
(!IsHomeCustomizationEnabled() || self.mostVisitedVisible)) {
[self addViewControllerAboveFeed:self.contentSuggestionsViewController];
}
[self addViewControllerAboveFeed:self.headerViewController];
DCHECK(
[self.headerViewController.view isDescendantOfView:self.containerView]);
self.headerViewController.view.translatesAutoresizingMaskIntoConstraints = NO;
// The view controllers have to be added in reverse order, so the array is
// then reversed to reflect the visible order.
self.viewControllersAboveFeed =
[[[self.viewControllersAboveFeed reverseObjectEnumerator] allObjects]
mutableCopy];
// TODO(crbug.com/40165977): The contentCollectionView width might be
// narrower than the ContentSuggestions view. This causes elements to be
// hidden, so we set clipsToBounds to ensure that they remain visible. The
// collection view changes, so we must set this property each time it does.
self.collectionView.clipsToBounds = NO;
[self.overscrollActionsController invalidate];
// Only re-configure `overscrollActionsController`.
if (self.overscrollActionsController) {
[self configureOverscrollActionsController];
}
// Update NTP collection view constraints to ensure the layout adapts to
// changes in feed visibility.
[self applyCollectionViewConstraints];
// Force relayout so that the views added in this method are rendered ASAP,
// ensuring it is showing in the new tab animation.
[self.view setNeedsLayout];
[self.view layoutIfNeeded];
// If the feed is not visible, we control the delegate ourself (since it is
// otherwise controlled by the feed service).
if (!self.feedVisible) {
self.feedWrapperViewController.contentCollectionView.delegate = self;
[self setMinimumHeight];
}
[self updateAccessibilityElements];
}
- (void)willUpdateSnapshot {
[self.overscrollActionsController clear];
}
- (BOOL)isNTPScrolledToTop {
return [self scrollPosition] <= -[self heightAboveFeed];
}
- (void)updateNTPLayout {
[self updateFeedInsetsForContentAbove];
if (self.feedVisible) {
[self updateFeedInsetsForMinimumHeight];
}
// Reload data to ensure the Most Visited tiles and fake omnibox are correctly
// positioned, in particular during a rotation while a ViewController is
// presented in front of the NTP.
[self updateFakeOmniboxOnNewWidth:self.collectionView.bounds.size.width];
// Ensure initial fake omnibox layout.
[self updateFakeOmniboxForScrollPosition];
}
- (void)updateHeightAboveFeed {
if (self.viewDidFinishLoading) {
CGFloat oldHeightAboveFeed = self.collectionView.contentInset.top;
CGFloat oldOffset = self.collectionView.contentOffset.y;
[self updateFeedInsetsForContentAbove];
CGFloat newHeightAboveFeed = self.collectionView.contentInset.top;
CGFloat change = newHeightAboveFeed - oldHeightAboveFeed;
// Offset the change by subtracting it from the content offset, in order to
// visually keep the same scroll position, but don't allow an offset that
// is lower than the top.
[self setContentOffset:MAX(oldOffset - change, -newHeightAboveFeed)];
}
}
- (void)resetViewHierarchy {
if (_feedContainer) {
[_feedContainer removeFromSuperview];
_feedContainer = nil;
}
[self removeFromViewHierarchy:self.feedWrapperViewController];
[self removeFromViewHierarchy:self.magicStackCollectionView];
if (!ShouldPutMostVisitedSitesInMagicStack()) {
[self removeFromViewHierarchy:self.contentSuggestionsViewController];
}
for (UIViewController* viewController in self.viewControllersAboveFeed) {
[self removeFromViewHierarchy:viewController];
}
[self.viewControllersAboveFeed removeAllObjects];
}
- (void)resetStateUponReload {
self.hasSavedOffsetFromPreviousScrollState = NO;
}
- (void)setContentOffsetToTop {
// There are many instances during NTP startup where the NTP layout is reset
// (e.g. calling -updateNTPLayout), which involves resetting the scroll
// offset. Some come from mutliple layout calls from the BVC, some come from
// an ambifuous source (likely the Feed). Particularly, the mediator's
// -setContentOffsetForWebState: call happens late in the cycle, which can
// clash with an already focused omnibox state. That call to reset the content
// offset to the top is important since the MVTiles and Google doodle are aync
// fetched/displayed, thus needed a reset. However, in the instance where the
// omnibox is focused, it is more important to keep that focused state and not
// show a "double" omibox state.
// TODO(crbug.com/40241297): Replace the -setContentOffsetForWebState: call
// with calls directly from all async updates to the NTP.
if (self.omniboxFocused) {
return;
}
[self setContentOffset:-[self heightAboveFeed]];
// TODO(crbug.com/40252945): Constraint updating should not be necessary since
// scrollViewDidScroll: calls this if needed.
[self setInitialFakeOmniboxConstraints];
if ([self.NTPContentDelegate isContentHeaderSticky]) {
[self setInitialFeedHeaderConstraints];
}
// Reset here since none of the view lifecycle callbacks (e.g.
// viewDidDisappear) can be reliably used (it seems) (i.e. switching between
// NTPs where there is saved scroll state in the destination tab). If the
// content offset is being set to the top, it is safe to assume this can be
// set to NO. Being called before setSavedContentOffset: is no problem since
// then it will be subsequently overriden to YES.
self.hasSavedOffsetFromPreviousScrollState = NO;
}
- (CGFloat)heightAboveFeed {
CGFloat heightAboveFeed = 0;
for (UIViewController* viewController in self.viewControllersAboveFeed) {
heightAboveFeed += viewController.view.frame.size.height;
// If the current view controller represents a module, account for the
// vertical spacing between modules.
if (IsHomeCustomizationEnabled() &&
(viewController == self.magicStackCollectionView ||
viewController == self.contentSuggestionsViewController ||
viewController == self.feedHeaderViewController)) {
heightAboveFeed += kSpaceBetweenModules;
}
}
if (!IsHomeCustomizationEnabled()) {
heightAboveFeed += kBottomMagicStackPadding;
if (!self.contentSuggestionsViewController) {
heightAboveFeed += content_suggestions::HeaderBottomPadding();
}
}
return heightAboveFeed;
}
- (void)setContentOffsetToTopOfFeedOrLess:(CGFloat)contentOffset {
if (contentOffset < [self offsetWhenScrolledIntoFeed]) {
[self setContentOffset:contentOffset];
} else {
[self scrollIntoFeed];
}
}
- (void)updateFeedInsetsForMinimumHeight {
DCHECK(self.feedVisible);
CGFloat minimumNTPHeight = self.collectionView.bounds.size.height;
minimumNTPHeight -= [self feedHeaderHeight];
if ([self shouldPinFakeOmnibox]) {
minimumNTPHeight -= ([self.headerViewController headerHeight] +
ntp_header::kScrolledToTopOmniboxBottomMargin);
}
if (self.collectionView.contentSize.height > minimumNTPHeight) {
self.collectionView.contentInset =
UIEdgeInsetsMake(self.collectionView.contentInset.top, 0, 0, 0);
} else {
CGFloat bottomInset =
minimumNTPHeight - self.collectionView.contentSize.height;
self.collectionView.contentInset = UIEdgeInsetsMake(
self.collectionView.contentInset.top, 0, bottomInset, 0);
}
}
- (void)updateScrollPositionForFeedTopSectionClosed {
if (self.isFakeboxPinned) {
[self setContentOffset:[self scrollPosition] + [self feedTopSectionHeight]];
}
}
- (void)feedLayoutDidEndUpdatesWithType:(FeedLayoutUpdateType)type {
if (_feedContainer) {
// Feed content gets added to the top of the subview array, so after content
// loads the feed container needs to be sent to the back so that it isn't
// in front of the new content and doesn't intercept taps / interactions
// that are meant for the feed content.
[self.collectionView sendSubviewToBack:_feedContainer];
}
[self updateFeedInsetsForMinimumHeight];
// Updating insets can influence contentOffset, so update saved scroll state
// after it. This handles what the starting offset be with the feed enabled,
// `-viewWillAppear:` handles when the feed is not enabled.
// It is NOT safe to reset `hasSavedOffsetFromPreviousScrollState` to NO here
// because -updateHeightAboveFeedAndScrollToTopIfNeeded calls from async
// updates to the Content Suggestions (i.e. MVT, Doodle) can happen after
// this.
if (self.hasSavedOffsetFromPreviousScrollState) {
[self setContentOffset:self.savedScrollOffset];
}
[self updateFeedContainerHeight];
}
- (void)invalidate {
_viewControllersAboveFeed = nil;
[self.overscrollActionsController invalidate];
self.overscrollActionsController = nil;
self.NTPContentDelegate = nil;
self.contentSuggestionsViewController = nil;
self.feedMetricsRecorder = nil;
self.feedHeaderViewController = nil;
self.feedWrapperViewController = nil;
self.mutator = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (UILayoutGuide*)moduleLayoutGuide {
if (!_moduleLayoutGuide) {
_moduleLayoutGuide = [[UILayoutGuide alloc] init];
UIView* view = self.view;
[view addLayoutGuide:_moduleLayoutGuide];
[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 {
[self.view layoutIfNeeded];
if (scrollPosition > -[self heightAboveFeed]) {
[self setSavedContentOffset:scrollPosition];
} else {
// Remove this if NTPs are ever scoped back to the WebState.
[self setContentOffsetToTop];
// Refresh NTP content if there is is no saved scrolled state or when a new
// NTP is opened. Since the same NTP is being shared across tabs, this
// ensures that new content is being fetched.
[self.NTPContentDelegate refreshNTPContent];
}
}
- (void)restoreScrollPositionToTopOfFeed {
[self setSavedContentOffset:[self offsetWhenScrolledIntoFeed]];
}
- (CGFloat)scrollPosition {
return self.collectionView.contentOffset.y;
}
- (CGFloat)pinnedOffsetY {
return [self.headerViewController pinnedOffsetY] - [self heightAboveFeed];
}
- (void)omniboxDidBecomeFirstResponder {
self.omniboxFocused = YES;
self.headerViewController.view.alpha = 0.01;
}
- (void)omniboxWillResignFirstResponder {
self.omniboxFocused = NO;
if ([self isFakeboxPinned]) {
// Return early to allow the omnibox defocus animation to show.
return;
}
[self omniboxDidResignFirstResponder];
}
- (void)omniboxDidResignFirstResponder {
if (![self.headerViewController isShowing] && !self.scrolledToMinimumHeight) {
return;
}
// Do not trigger defocus animation if the user is already navigating away
// from the NTP.
if (self.NTPVisible) {
[self.headerViewController omniboxDidResignFirstResponder];
[self shiftTilesDownForOmniboxDefocus];
}
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
// If `feedWrapperViewController` is nil, then the NTP is either being created
// or updated and is not ready to handle scroll events. Doing so could cause
// unexpected behavior, such as breaking the layout or causing crashes.
if (!self.feedWrapperViewController) {
return;
}
// Scroll events might still be queued for a previous scroll view which was
// now replaced. In these cases, ignore the scroll event.
if (scrollView != self.collectionView) {
return;
}
[self.overscrollActionsController scrollViewDidScroll:scrollView];
[self updateFakeOmniboxForScrollPosition];
[self updateScrolledToMinimumHeight];
CGFloat scrollPosition = scrollView.contentOffset.y;
[self handleStickyElementsForScrollPosition:scrollPosition force:NO];
if (self.viewDidAppear) {
[self updateFeedSigninPromoIsVisible];
}
[self updateScrollPositionToSave];
// The feed model callbacks don't always reliably tell us that the content has
// paginated, so check if the container should be extended.
if (self.collectionView.contentSize.height >
self.feedContainerHeightConstraint.constant) {
[self updateFeedContainerHeight];
}
}
- (void)scrollViewWillBeginDragging:(UIScrollView*)scrollView {
// Scroll events might still be queued for a previous scroll view which was
// now replaced. In these cases, ignore the scroll event.
if (scrollView != self.collectionView) {
return;
}
if (!self.overscrollActionsController) {
[self configureOverscrollActionsController];
}
// User has interacted with the surface, so it is safe to assume that a saved
// scroll position can now be overriden.
self.hasSavedOffsetFromPreviousScrollState = NO;
[self.overscrollActionsController scrollViewWillBeginDragging:scrollView];
self.scrollStartPosition = scrollView.contentOffset.y;
}
- (void)scrollViewWillEndDragging:(UIScrollView*)scrollView
withVelocity:(CGPoint)velocity
targetContentOffset:(inout CGPoint*)targetContentOffset {
// Scroll events might still be queued for a previous scroll view which was
// now replaced. In these cases, ignore the scroll event.
if (scrollView != self.collectionView) {
return;
}
[self.overscrollActionsController
scrollViewWillEndDragging:scrollView
withVelocity:velocity
targetContentOffset:targetContentOffset];
}
- (void)scrollViewDidEndDragging:(UIScrollView*)scrollView
willDecelerate:(BOOL)decelerate {
// Scroll events might still be queued for a previous scroll view which was
// now replaced. In these cases, ignore the scroll event.
if (scrollView != self.collectionView) {
return;
}
[self.overscrollActionsController scrollViewDidEndDragging:scrollView
willDecelerate:decelerate];
if (self.feedVisible) {
[self.feedMetricsRecorder recordFeedScrolled:scrollView.contentOffset.y -
self.scrollStartPosition];
}
}
- (void)scrollViewDidScrollToTop:(UIScrollView*)scrollView {
// TODO(crbug.com/40710989): Handle scrolling.
}
- (void)scrollViewWillBeginDecelerating:(UIScrollView*)scrollView {
// TODO(crbug.com/40710989): Handle scrolling.
}
- (void)scrollViewDidEndDecelerating:(UIScrollView*)scrollView {
// TODO(crbug.com/40710989): Handle scrolling.
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView*)scrollView {
// TODO(crbug.com/40710989): Handle scrolling.
}
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView {
// Scroll events might still be queued for a previous scroll view which was
// now replaced. In these cases, ignore the scroll event.
if (scrollView != self.collectionView) {
return YES;
}
// User has tapped the status bar to scroll to the top.
// Prevent scrolling back to pre-focus state, making sure we don't have
// two scrolling animations running at the same time.
self.collectionShiftingOffset = 0;
// Reset here since none of the view lifecycle callbacks are called reliably
// to be able to be used (it seems) (i.e. switching between NTPs where there
// is saved scroll state in the destination tab). If the content offset is
// being set to the top, it is safe to assume this can be set to NO. Being
// called before setSavedContentOffset: is no problem since then it will be
// subsequently overriden to YES.
self.hasSavedOffsetFromPreviousScrollState = NO;
// Unfocus omnibox without scrolling back.
[self unfocusOmnibox];
return YES;
}
#pragma mark - UIGestureRecognizerDelegate
// TODO(crbug.com/40165977): Remove once the Feed header properly supports
// ContentSuggestions.
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldReceiveTouch:(UITouch*)touch {
// Ignore all touches inside the Feed CollectionView, which includes
// ContentSuggestions.
UIView* viewToIgnoreTouches = self.collectionView;
CGRect ignoreBoundsInView =
[viewToIgnoreTouches convertRect:viewToIgnoreTouches.bounds
toView:self.view];
return !(CGRectContainsPoint(ignoreBoundsInView,
[touch locationInView:self.view]));
}
#pragma mark - Scrolling Animations
- (void)shiftTilesUpToFocusOmnibox {
// Add gesture recognizer to collection view when the omnibox is focused.
[self.view addGestureRecognizer:self.tapGestureRecognizer];
// Stop any existing focus/defocus animation.
if (self.animator.running) {
[self.animator stopAnimation:NO];
[self.animator finishAnimationAtPosition:UIViewAnimatingPositionStart];
self.animator = nil;
}
if (self.collectionView.decelerating) {
// Stop the scrolling if the scroll view is decelerating to prevent the
// focus to be immediately lost.
[self.collectionView setContentOffset:self.collectionView.contentOffset
animated:NO];
}
self.shouldAnimateHeader = YES;
CGFloat pinnedOffsetBeforeAnimation = [self pinnedOffsetY];
if (CGSizeEqualToSize(self.collectionView.contentSize, CGSizeZero)) {
[self.collectionView layoutIfNeeded];
}
if (!self.scrolledToMinimumHeight) {
// Save the scroll position prior to the animation to allow the user to
// return to it on defocus.
self.collectionShiftingOffset =
MAX(-[self heightAboveFeed],
AlignValueToPixel([self.headerViewController pinnedOffsetY] -
[self adjustedOffset].y));
}
// If the fake omnibox is already at the final position, just focus it and
// return early.
if ([self shouldSkipScrollToFocusOmnibox]) {
self.shouldAnimateHeader = NO;
if (!self.scrolledToMinimumHeight) {
// Scroll up to pinned position if it is not pinned already, but don't
// wait for it to finish to focus the omnibox.
__weak __typeof(self) weakSelf = self;
[UIView animateWithDuration:kMaterialDuration6
animations:^{
weakSelf.collectionView.contentOffset =
CGPoint(0, pinnedOffsetBeforeAnimation);
[weakSelf resetFakeOmniboxConstraints];
}];
}
[self.headerViewController
completeHeaderFakeOmniboxFocusAnimationWithFinalPosition:
UIViewAnimatingPositionEnd];
[self.NTPContentDelegate focusOmnibox];
return;
}
__weak __typeof(self) weakSelf = self;
ProceduralBlock shiftOmniboxToTop = ^{
__typeof(weakSelf) strongSelf = weakSelf;
// Changing the contentOffset of the collection results in a
// scroll and a change in the constraints of the header.
strongSelf.collectionView.contentOffset =
CGPointMake(0, [strongSelf pinnedOffsetY]);
// Layout the header for the constraints to be animated.
[strongSelf.headerViewController layoutHeader];
};
self.animator = [[UIViewPropertyAnimator alloc]
initWithDuration:kShiftTilesUpAnimationDuration
curve:UIViewAnimationCurveEaseInOut
animations:^{
NewTabPageViewController* strongSelf = weakSelf;
if (!strongSelf) {
return;
}
if (strongSelf.collectionView.contentOffset.y <
[strongSelf pinnedOffsetY]) {
self.disableScrollAnimation = YES;
[strongSelf.headerViewController expandHeaderForFocus];
shiftOmniboxToTop();
[strongSelf.headerViewController
completeHeaderFakeOmniboxFocusAnimationWithFinalPosition:
UIViewAnimatingPositionEnd];
[strongSelf.NTPContentDelegate focusOmnibox];
}
}];
[self.animator addCompletion:^(UIViewAnimatingPosition finalPosition) {
NewTabPageViewController* strongSelf = weakSelf;
if (!strongSelf) {
return;
}
if (finalPosition == UIViewAnimatingPositionEnd) {
// Content suggestion headers can be updated during the scroll, causing
// `pinnedOffsetY` to be invalid. When this happens during the animation,
// the tiles are not scrolled to the top causing the omnibox to be hidden
// by the `PrimaryToolbarView`. In that state, the omnibox's popup and the
// keyboard are still visible.
// If the animation is not interrupted and `pinnedOffsetY` changed
// during the animation, shift the omnibox to the top at the end of the
// animation.
if ([strongSelf pinnedOffsetY] != pinnedOffsetBeforeAnimation &&
strongSelf.collectionView.contentOffset.y <
[strongSelf pinnedOffsetY]) {
shiftOmniboxToTop();
}
strongSelf.shouldAnimateHeader = NO;
}
strongSelf.scrolledToMinimumHeight = YES;
strongSelf.disableScrollAnimation = NO;
[strongSelf.headerViewController
completeHeaderFakeOmniboxFocusAnimationWithFinalPosition:finalPosition];
strongSelf.isAnimatingOmniboxFocus = NO;
}];
self.animator.interruptible = YES;
self.isAnimatingOmniboxFocus = YES;
[self.animator startAnimation];
}
#pragma mark - Private
// Returns YES if scroll should be skipped when focusing the omnibox.
- (BOOL)shouldSkipScrollToFocusOmnibox {
return self.scrolledToMinimumHeight || IsSplitToolbarMode(self);
}
// Returns the collection view containing all NTP content.
- (UICollectionView*)collectionView {
return self.feedWrapperViewController.contentCollectionView;
}
// Returns the height of the fake omnibox to stick to the top of the NTP.
- (CGFloat)stickyOmniboxHeight {
return content_suggestions::FakeToolbarHeight();
}
// Sets the feed collection contentOffset from the saved state to `offset` to
// set the initial scroll position.
- (void)setSavedContentOffset:(CGFloat)offset {
self.hasSavedOffsetFromPreviousScrollState = YES;
self.savedScrollOffset = offset;
[self setContentOffset:offset];
}
// Configures overscroll actions controller.
- (void)configureOverscrollActionsController {
// Ensure the feed's scroll view exists to prevent crashing the overscroll
// controller.
if (!self.collectionView) {
return;
}
// Overscroll action does not work well with content offset, so set this
// to never and internally offset the UI to account for safe area insets.
self.collectionView.contentInsetAdjustmentBehavior =
UIScrollViewContentInsetAdjustmentNever;
self.overscrollActionsController = [[OverscrollActionsController alloc]
initWithScrollView:self.collectionView];
[self.overscrollActionsController
setStyle:OverscrollStyle::NTP_NON_INCOGNITO];
self.overscrollActionsController.delegate = self.overscrollDelegate;
[self updateOverscrollActionsState];
}
// Enables or disables overscroll actions.
- (void)updateOverscrollActionsState {
if (IsSplitToolbarMode(self)) {
[self.overscrollActionsController enableOverscrollActions];
} else {
[self.overscrollActionsController disableOverscrollActions];
}
}
// Either signals to the omnibox to cancel its focused state or just update the
// NTP state for an unfocused state.
- (void)unfocusOmnibox {
if (self.omniboxFocused) {
[self.NTPContentDelegate cancelOmniboxEdit];
} else {
[self omniboxDidResignFirstResponder];
}
}
// Shifts tiles down when defocusing the omnibox.
- (void)shiftTilesDownForOmniboxDefocus {
if (self.shiftDownInProgress) {
return;
}
self.shiftDownInProgress = YES;
if (IsSplitToolbarMode(self)) {
[self.NTPContentDelegate onFakeboxBlur];
}
[self.view removeGestureRecognizer:self.tapGestureRecognizer];
self.shouldAnimateHeader = YES;
if (self.animator.running) {
[self.animator stopAnimation:NO];
[self.animator finishAnimationAtPosition:UIViewAnimatingPositionStart];
self.animator = nil;
}
if (self.collectionShiftingOffset == 0 || self.collectionView.dragging) {
self.collectionShiftingOffset = 0;
[self updateFakeOmniboxForScrollPosition];
self.shiftDownInProgress = NO;
return;
}
// Use a simple animation to scroll back into position.
CGFloat yOffset = MAX([self pinnedOffsetY] - self.collectionShiftingOffset,
-[self heightAboveFeed]);
self.headerViewController.view.alpha = 1;
__weak __typeof(self) weakSelf = self;
self.inhibitScrollPositionUpdates = YES;
self.headerViewController.allowFontScaleAnimation = YES;
[self updateFakeOmniboxForScrollPosition];
[self.headerViewController layoutHeader];
self.animator = [[UIViewPropertyAnimator alloc]
initWithDuration:kMaterialDuration6
curve:UIViewAnimationCurveEaseInOut
animations:^{
weakSelf.collectionView.contentOffset = CGPoint(0, yOffset);
[weakSelf.headerViewController layoutHeader];
}];
[self.animator addCompletion:^(UIViewAnimatingPosition finalPosition) {
weakSelf.inhibitScrollPositionUpdates = NO;
weakSelf.collectionShiftingOffset = 0;
weakSelf.headerViewController.view.alpha = 1;
weakSelf.collectionView.contentOffset = CGPoint(0, yOffset);
weakSelf.scrolledToMinimumHeight = NO;
weakSelf.headerViewController.allowFontScaleAnimation = NO;
weakSelf.shiftDownInProgress = NO;
}];
self.animator.interruptible = YES;
[self.animator startAnimation];
}
// Pins the fake omnibox to the top of the NTP.
- (void)pinFakeOmniboxToTop {
self.isFakeboxPinned = YES;
[self stickFakeOmniboxToTop];
}
// Resets the fake omnibox to its original position.
- (void)resetFakeOmniboxConstraints {
self.isFakeboxPinned = NO;
[self setInitialFakeOmniboxConstraints];
}
// Lets this view own the fake omnibox and sticks it to the top of the NTP.
- (void)stickFakeOmniboxToTop {
// If `self.headerViewController` is nil after removing it from the view
// hierarchy it means its no longer owned by anyone (e.g. The coordinator
// might have been stopped.) and we shouldn't try to add it again.
if (!self.headerViewController) {
return;
}
[NSLayoutConstraint deactivateConstraints:self.fakeOmniboxConstraints];
self.headerTopAnchor = [self.headerViewController.view.bottomAnchor
constraintEqualToAnchor:self.feedWrapperViewController.view
.safeAreaLayoutGuide.topAnchor
constant:[self stickyOmniboxHeight]];
// This issue fundamentally comes down to the topAnchor being set just once
// and if it is set in landscape mode, it never is updated upon rotation.
// And landscape is when it doesn't matter.
self.fakeOmniboxConstraints = @[
self.headerTopAnchor,
[self.headerViewController.view.leadingAnchor
constraintEqualToAnchor:self.feedWrapperViewController.view
.leadingAnchor],
[self.headerViewController.view.trailingAnchor
constraintEqualToAnchor:self.feedWrapperViewController.view
.trailingAnchor],
];
[NSLayoutConstraint activateConstraints:self.fakeOmniboxConstraints];
}
// Gives content suggestions collection view ownership of the fake omnibox for
// the width animation.
- (void)setInitialFakeOmniboxConstraints {
[NSLayoutConstraint deactivateConstraints:self.fakeOmniboxConstraints];
if (IsHomeCustomizationEnabled()) {
// If all modules are disabled, the fake omnibox doesn't need additional
// constraints.
if ([self.viewControllersAboveFeed lastObject] ==
self.headerViewController) {
self.fakeOmniboxConstraints = @[];
} else {
// Otherwise, anchor the header to the module below it.
NSInteger headerIndex = [self.viewControllersAboveFeed
indexOfObject:self.headerViewController];
UIView* viewBelowHeader =
[self.viewControllersAboveFeed objectAtIndex:(headerIndex + 1)].view;
self.fakeOmniboxConstraints = @[
[viewBelowHeader.topAnchor
constraintEqualToAnchor:self.headerViewController.view.bottomAnchor
constant:kSpaceBetweenModules],
];
}
} else {
if (self.contentSuggestionsViewController) {
self.fakeOmniboxConstraints = @[
[self.contentSuggestionsViewController.view.topAnchor
constraintEqualToAnchor:self.headerViewController.view
.bottomAnchor],
];
} else {
// If `contentSuggestionsViewController` is nil, that means MVTs are in
// the Magic Stack.
self.fakeOmniboxConstraints = @[
[self.magicStackCollectionView.view.topAnchor
constraintEqualToAnchor:self.headerViewController.view.bottomAnchor
constant:content_suggestions::HeaderBottomPadding()],
];
}
}
[NSLayoutConstraint activateConstraints:self.fakeOmniboxConstraints];
}
// Update the header for a new width size depending on if the change needs to be
// animated.
- (void)updateFakeOmniboxOnNewWidth:(CGFloat)width {
if (self.shouldAnimateHeader) {
// We check -superview here because in certain scenarios (such as when the
// VC is rotated underneath another presented VC), in a
// UICollectionViewController -viewSafeAreaInsetsDidChange the VC.view has
// updated safeAreaInsets, but VC.collectionView does not until a layer
// -viewDidLayoutSubviews. Since self.collectionView and it's superview
// should always have the same safeArea, this should be safe.
UIEdgeInsets insets = self.collectionView.superview.safeAreaInsets;
[self.headerViewController
updateFakeOmniboxForOffset:[self adjustedOffset].y
screenWidth:width
safeAreaInsets:insets
animateScrollAnimation:!self.disableScrollAnimation];
} else {
[self.headerViewController updateFakeOmniboxForWidth:width];
}
}
// Update the header state for a change in scroll position. This could mean
// unfocusing the omnibox and/or updating its shape if `shouldAnimateHeader` is
// YES.
- (void)updateFakeOmniboxForScrollPosition {
// Unfocus the omnibox when the scroll view is scrolled by the user (but not
// when a scroll is triggered by layout/UIKit).
if (self.omniboxFocused && !self.shouldAnimateHeader &&
self.collectionView.dragging) {
[self unfocusOmnibox];
}
if (self.shouldAnimateHeader) {
UIEdgeInsets insets = self.collectionView.safeAreaInsets;
[self.headerViewController
updateFakeOmniboxForOffset:[self adjustedOffset].y
screenWidth:self.collectionView.frame.size.width
safeAreaInsets:insets
animateScrollAnimation:!self.disableScrollAnimation];
}
}
// Pins feed header to top of the NTP when scrolled into the feed, below the
// omnibox.
- (void)stickFeedHeaderToTop {
DCHECK(self.feedHeaderViewController);
DCHECK(IsWebChannelsEnabled());
[NSLayoutConstraint deactivateConstraints:self.feedHeaderConstraints];
NSMutableArray* constraints = [NSMutableArray array];
[constraints
addObject:[self.collectionView.topAnchor
constraintEqualToAnchor:self.magicStackCollectionView.view
.bottomAnchor
constant:kBottomMagicStackPadding]];
// If the fake omnibox is pinned to the top, we pin the feed header below it.
// Otherwise, the feed header gets pinned to the top.
if ([self shouldPinFakeOmnibox]) {
[constraints
addObject:
[self.feedHeaderViewController.view.topAnchor
constraintEqualToAnchor:self.headerViewController.view
.bottomAnchor
constant:
-(content_suggestions::
HeaderBottomPadding() +
[self.feedHeaderViewController
customSearchEngineViewHeight])]];
} else {
[constraints
addObject:
[self.feedHeaderViewController.view.topAnchor
constraintEqualToAnchor:self.view.topAnchor
constant:-[self.feedHeaderViewController
customSearchEngineViewHeight]]];
}
self.feedHeaderConstraints = constraints;
[self.feedHeaderViewController
toggleBackgroundBlur:[self.NTPContentDelegate isContentHeaderSticky]
animated:YES];
[NSLayoutConstraint activateConstraints:self.feedHeaderConstraints];
}
// Sets initial feed header constraints, between content suggestions and feed.
- (void)setInitialFeedHeaderConstraints {
DCHECK(self.feedHeaderViewController);
[NSLayoutConstraint deactivateConstraints:self.feedHeaderConstraints];
// If Feed top section is enabled, the header bottom anchor should be set to
// its top anchor instead of the feed collection's top anchor.
UIView* bottomView = self.collectionView;
if (self.feedTopSectionViewController) {
bottomView = self.feedTopSectionViewController.view;
}
NSLayoutConstraint* feedHeaderTopAnchor;
feedHeaderTopAnchor = [self.feedHeaderViewController.view.topAnchor
constraintEqualToAnchor:self.magicStackCollectionView.view.bottomAnchor
constant:kBottomMagicStackPadding];
self.feedHeaderConstraints = @[
feedHeaderTopAnchor,
[bottomView.topAnchor constraintEqualToAnchor:self.feedHeaderViewController
.view.bottomAnchor],
];
[self.feedHeaderViewController toggleBackgroundBlur:NO animated:YES];
[NSLayoutConstraint activateConstraints:self.feedHeaderConstraints];
}
// Sets an top inset to the feed collection view to fit the content above it.
- (void)updateFeedInsetsForContentAbove {
// Setting the contentInset will cause a scroll, which will call
// scrollViewDidScroll which calls updateScrolledToMinimumHeight. So no need
// to call here.
self.collectionView.contentInset = UIEdgeInsetsMake(
[self heightAboveFeed], 0, self.collectionView.contentInset.bottom, 0);
}
// Checks whether the feed top section is visible and updates the
// `NTPContentDelegate`.
// TODO(crbug.com/40843602): This function currently checks the visibility of
// the entire feed top section, but it should only check the visibility of the
// promo within it.
- (void)updateFeedSigninPromoIsVisible {
if (!self.feedTopSectionViewController) {
return;
}
// The y-position where NTP content starts being visible.
CGFloat visibleContentStartingPoint =
[self scrollPosition] + self.view.frame.size.height;
// Signin promo is logged as visible when at least the top 2/3 or bottom 1/3
// of it can be seen. This is not logged if the user focuses the omnibox since
// the suggestion sheet covers the NTP content.
BOOL isFeedSigninPromoVisible =
(visibleContentStartingPoint > -([self feedTopSectionHeight] * 2) / 3 &&
([self scrollPosition] <
-([self stickyContentHeight] + [self feedTopSectionHeight] / 3))) &&
!self.omniboxFocused;
[self.NTPContentDelegate
signinPromoHasChangedVisibility:isFeedSigninPromoVisible];
}
// TODO(crbug.com/40251609): Remove once the Feed header properly supports
// ContentSuggestions.
- (void)handleSingleTapInView:(UITapGestureRecognizer*)recognizer {
CGPoint location = [recognizer locationInView:[recognizer.view superview]];
CGRect discBoundsInView =
[self.identityDiscButton convertRect:self.identityDiscButton.bounds
toView:self.view];
if (CGRectContainsPoint(discBoundsInView, location)) {
[self.identityDiscButton
sendActionsForControlEvents:UIControlEventTouchUpInside];
} else {
[self unfocusOmnibox];
}
if (IsHomeCustomizationEnabled()) {
CGRect customizationMenuBounds =
[[self.headerViewController customizationMenuButton]
convertRect:[self.headerViewController customizationMenuButton]
.bounds
toView:self.view];
if (CGRectContainsPoint(customizationMenuBounds, location)) {
[[self.headerViewController customizationMenuButton]
sendActionsForControlEvents:UIControlEventTouchUpInside];
}
}
}
// Handles the pinning of the sticky elements to the top of the NTP. This
// includes the fake omnibox and if Web Channels is enabled, the feed header. If
// `force` is YES, the sticky elements will always be set based on the scroll
// position. If `force` is NO, the sticky elements will only based on
// `isScrolledIntoFeed` to prevent pinning them multiple times.
- (void)handleStickyElementsForScrollPosition:(CGFloat)scrollPosition
force:(BOOL)force {
// Handles the sticky omnibox. Does not stick for iPads.
CGFloat offsetToStickOmnibox = [self offsetToStickOmnibox];
if ([self shouldPinFakeOmnibox]) {
if (scrollPosition >= offsetToStickOmnibox &&
(!self.isFakeboxPinned || force)) {
[self pinFakeOmniboxToTop];
} else if (scrollPosition < offsetToStickOmnibox &&
(self.isFakeboxPinned || force)) {
[self resetFakeOmniboxConstraints];
}
} else if (self.isFakeboxPinned) {
[self resetFakeOmniboxConstraints];
}
// Handles the sticky feed header.
if ([self.NTPContentDelegate isContentHeaderSticky] &&
self.feedHeaderViewController) {
if ((!self.isScrolledIntoFeed || force) &&
scrollPosition > [self offsetWhenScrolledIntoFeed]) {
[self setIsScrolledIntoFeed:YES];
[self stickFeedHeaderToTop];
} else if ((self.isScrolledIntoFeed || force) &&
scrollPosition <= [self offsetWhenScrolledIntoFeed]) {
[self setIsScrolledIntoFeed:NO];
[self setInitialFeedHeaderConstraints];
}
}
// Content suggestions header will sometimes glitch when swiping quickly from
// inside the feed to the top of the NTP. This check safeguards this action to
// make sure the header is properly positioned. (crbug.com/1261458)
if ([self isNTPScrolledToTop]) {
[self setInitialFakeOmniboxConstraints];
if ([self.NTPContentDelegate isContentHeaderSticky]) {
[self setInitialFeedHeaderConstraints];
}
}
}
// Registers notifications for certain actions on the NTP.
- (void)registerNotifications {
NSNotificationCenter* center = [NSNotificationCenter defaultCenter];
[center addObserver:self
selector:@selector(deviceOrientationDidChange)
name:UIDeviceOrientationDidChangeNotification
object:nil];
}
// Handles device rotation.
- (void)deviceOrientationDidChange {
if (self.viewDidAppear && self.feedVisible) {
[self.feedMetricsRecorder
recordDeviceOrientationChanged:[[UIDevice currentDevice] orientation]];
}
}
// The Discover Feed component seems to add an unwanted width constraint
// (<= 360) in some circumstances, including multiwindow on iPad. This
// cleans up the width constraints so proper constraints can be added.
- (void)cleanUpCollectionViewConstraints {
auto* collectionWidthAnchor = self.collectionView.widthAnchor;
auto predicate =
[NSPredicate predicateWithBlock:^BOOL(NSLayoutConstraint* constraint,
NSDictionary* bindings) {
return constraint.firstAnchor == collectionWidthAnchor;
}];
auto collectionWidthConstraints =
[self.collectionView.constraints filteredArrayUsingPredicate:predicate];
[NSLayoutConstraint deactivateConstraints:collectionWidthConstraints];
}
// Applies constraints to the NTP collection view, along with the constraints
// for the content suggestions within it.
- (void)applyCollectionViewConstraints {
UIView* contentSuggestionsView = self.contentSuggestionsViewController.view;
contentSuggestionsView.translatesAutoresizingMaskIntoConstraints = NO;
self.magicStackCollectionView.view.translatesAutoresizingMaskIntoConstraints =
NO;
if (self.feedHeaderViewController) {
[self cleanUpCollectionViewConstraints];
// When the feed is turned off, do not constrain the width of the empty
// collection view, in order to allow vertical scrolling gestures to
// happen on the side margins. The width of the feed header is controlled
// by the collectionView's contentLayoutGuide.
if (self.feedWrapperViewController.feedViewController) {
[self.collectionView.widthAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.widthAnchor]
.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.frameLayoutGuide
.centerXAnchor],
[self.feedHeaderViewController.view.widthAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.widthAnchor],
]];
if (!IsHomeCustomizationEnabled()) {
[self setInitialFeedHeaderConstraints];
}
if (self.feedTopSectionViewController) {
[NSLayoutConstraint activateConstraints:@[
[self.feedTopSectionViewController.view.centerXAnchor
constraintEqualToAnchor:self.collectionView.centerXAnchor],
[self.feedTopSectionViewController.view.widthAnchor
constraintEqualToAnchor:self.collectionView.widthAnchor],
[self.feedTopSectionViewController.view.topAnchor
constraintEqualToAnchor:self.feedHeaderViewController.view
.bottomAnchor],
[self.collectionView.topAnchor
constraintEqualToAnchor:self.feedTopSectionViewController.view
.bottomAnchor],
]];
}
} else {
if (!IsHomeCustomizationEnabled()) {
[NSLayoutConstraint activateConstraints:@[
[self.collectionView.topAnchor
constraintEqualToAnchor:self.magicStackCollectionView.view
.bottomAnchor],
]];
}
}
if (IsHomeCustomizationEnabled()) {
UIView* lastView = [self.viewControllersAboveFeed lastObject].view;
[NSLayoutConstraint activateConstraints:@[
[self.collectionView.topAnchor
constraintEqualToAnchor:lastView.bottomAnchor],
]];
}
if (_feedContainer) {
[NSLayoutConstraint activateConstraints:@[
[_feedContainer.widthAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.widthAnchor],
[_feedContainer.centerXAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.centerXAnchor],
[_feedContainer.topAnchor
constraintEqualToAnchor:self.feedHeaderViewController.view.topAnchor],
]];
[self updateFeedContainerHeight];
}
[NSLayoutConstraint activateConstraints:@[
[[self containerView].safeAreaLayoutGuide.leadingAnchor
constraintEqualToAnchor:self.headerViewController.view.leadingAnchor],
[[self containerView].safeAreaLayoutGuide.trailingAnchor
constraintEqualToAnchor:self.headerViewController.view.trailingAnchor],
]];
if (self.contentSuggestionsViewController &&
(!IsHomeCustomizationEnabled() || self.mostVisitedVisible)) {
[NSLayoutConstraint activateConstraints:@[
[self.contentSuggestionsViewController.view.leadingAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.leadingAnchor],
[self.contentSuggestionsViewController.view.trailingAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.trailingAnchor],
]];
}
if (!IsHomeCustomizationEnabled() || self.magicStackVisible) {
[NSLayoutConstraint activateConstraints:@[
[self.magicStackCollectionView.view.leadingAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.leadingAnchor],
[self.magicStackCollectionView.view.trailingAnchor
constraintEqualToAnchor:self.moduleLayoutGuide.trailingAnchor],
]];
}
if (!ShouldPutMostVisitedSitesInMagicStack()) {
if (!IsHomeCustomizationEnabled()) {
[NSLayoutConstraint activateConstraints:@[
[self.magicStackCollectionView.view.topAnchor
constraintEqualToAnchor:self.contentSuggestionsViewController.view
.bottomAnchor],
]];
}
}
// Anchor each module except the one directly below the header, since it will
// dynamically update its top anchor when the fake omnibox is pinned.
if (IsHomeCustomizationEnabled() &&
[self.viewControllersAboveFeed lastObject] != self.headerViewController) {
// Start with the bottom module's index, which is either the feed header if
// enabled, or the last object of the module array if not.
NSUInteger startIndex =
self.feedHeaderViewController
? [self.viewControllersAboveFeed
indexOfObject:self.feedHeaderViewController]
: self.viewControllersAboveFeed.count - 1;
// While the current module's index is not the view directly below the
// header, anchor to the module above it.
NSUInteger headerIndex =
[self.viewControllersAboveFeed indexOfObject:self.headerViewController];
for (NSUInteger index = startIndex; index > headerIndex + 1; --index) {
UIView* view = self.viewControllersAboveFeed[index].view;
UIView* viewAbove = self.viewControllersAboveFeed[index - 1].view;
[NSLayoutConstraint activateConstraints:@[
[view.topAnchor constraintEqualToAnchor:viewAbove.bottomAnchor
constant:kSpaceBetweenModules],
]];
}
}
[self setInitialFakeOmniboxConstraints];
}
// Sets minimum height for the NTP collection view, allowing it to scroll enough
// to focus the omnibox.
- (void)setMinimumHeight {
CGFloat minimumNTPHeight = [self minimumNTPHeight] - [self heightAboveFeed];
self.collectionView.contentSize =
CGSizeMake(self.collectionView.frame.size.width, minimumNTPHeight);
}
// Sets the content offset to the top of the feed.
- (void)scrollIntoFeed {
[self setContentOffset:[self offsetWhenScrolledIntoFeed]];
}
// The total height of all sticky content.
- (CGFloat)stickyContentHeight {
CGFloat stickyContentHeight = [self stickyOmniboxHeight];
if ([self.NTPContentDelegate isContentHeaderSticky]) {
stickyContentHeight += [self feedHeaderHeight];
}
return stickyContentHeight;
}
// Returns y-offset compensated for any content insets that might be set for the
// content above the feed.
- (CGPoint)adjustedOffset {
CGPoint adjustedOffset = self.collectionView.contentOffset;
adjustedOffset.y += [self heightAboveFeed];
return adjustedOffset;
}
// Background gradient view will be used when in dark mode, the assigned
// background color to this view's otherwise.
- (void)updateModularHomeBackgroundColorForUserInterfaceStyle:
(UIUserInterfaceStyle)style {
_backgroundGradientView.hidden = style == UIUserInterfaceStyleLight;
}
// Signal to the ViewController that the height above the feed needs to be
// recalculated and thus also likely needs to be scrolled up to accommodate for
// the new height. Nothing may happen if the ViewController determines that the
// current scroll state should not change.
- (void)updateHeightAboveFeedAndScrollToTopIfNeeded {
if (self.viewDidFinishLoading &&
!self.hasSavedOffsetFromPreviousScrollState) {
// Do not scroll to the top if there is a saved scroll state. Also,
// `-setContentOffsetToTop` potentially updates constaints, and if
// viewDidLoad has not finished, some views may not in the view hierarchy
// yet.
[self updateFeedInsetsForContentAbove];
[self setContentOffsetToTop];
}
}
// Updates the accessibilityElements used by VoiceOver / Switch Control to
// iterate through on-screen elements. The feed collectionView does not seem to
// include non-feed items in its `accessibilityElements` so they are added here.
- (void)updateAccessibilityElements {
NSMutableArray* elements = [[NSMutableArray alloc] init];
for (UIViewController* viewController in self.viewControllersAboveFeed) {
[elements addObject:viewController.view];
}
[elements addObject:self.collectionView];
self.view.accessibilityElements = elements;
}
// Calculate the scroll position that should be saved in the NTP state and
// update the mutator.
- (void)updateScrollPositionToSave {
if (self.inhibitScrollPositionUpdates) {
return;
}
CGFloat scrollPositionToSave = [self scrollPosition];
scrollPositionToSave -= self.collectionShiftingOffset;
self.mutator.scrollPositionToSave = scrollPositionToSave;
}
// Updates the feed container's height constraint.
- (void)updateFeedContainerHeight {
if (!_feedContainer) {
return;
}
self.feedContainerHeightConstraint.active = NO;
// Container either takes the actual height of all feed components, or a
// minimum value of `kFeedContainerMinimumHeight` if the content hasn't
// loaded.
CGFloat containerHeight =
std::max((self.collectionView.contentSize.height +
[self feedHeaderHeight] + [self feedTopSectionHeight]),
kFeedContainerMinimumHeight) +
kFeedContainerExtraHeight;
self.feedContainerHeightConstraint =
[_feedContainer.heightAnchor constraintEqualToConstant:containerHeight];
self.feedContainerHeightConstraint.active = YES;
}
// Updates the width constraint of `moduleLayoutGuide`.
- (void)updateModuleWidth {
CGFloat oldWidth = _moduleWidth.constant;
CGFloat widthMultiplier = (100 - kHomeModuleMinimumPadding) / 100;
CGFloat width = MIN(self.view.frame.size.width * widthMultiplier,
kDiscoverFeedContentMaxWidth);
BOOL existingConstraintUpdated = NO;
if (!_moduleWidth) {
_moduleWidth =
[self.moduleLayoutGuide.widthAnchor constraintEqualToConstant:width];
_moduleWidth.active = YES;
} else {
_moduleWidth.constant = width;
existingConstraintUpdated = YES;
}
if (width != oldWidth) {
[self.view layoutIfNeeded];
if (existingConstraintUpdated) {
[self.magicStackCollectionView moduleWidthDidUpdate];
}
}
}
#pragma mark - Helpers
- (UIViewController*)contentSuggestionsViewController {
return _contentSuggestionsViewController;
}
- (CGFloat)minimumNTPHeight {
CGFloat collectionViewHeight = self.collectionView.bounds.size.height;
CGFloat headerHeight = [self.headerViewController headerHeight];
// The minimum height for the collection view content should be the height
// of the header plus the height of the collection view minus the height of
// the NTP bottom bar. This allows the Most Visited cells to be scrolled up
// to the top of the screen. Also computes the total NTP scrolling height
// for Discover infinite feed.
CGFloat minimumHeight = collectionViewHeight + headerHeight;
if (!IsRegularXRegularSizeClass(self.collectionView)) {
minimumHeight -= self.collectionView.contentInset.bottom;
if (IsSplitToolbarMode(self)) {
minimumHeight -= [self stickyOmniboxHeight];
} else {
// Add in half of the margin between the fakebox and the rest of the
// content suggestions, to ensure there is enough height to fully
// finish the fakebox to omnibox transition.
minimumHeight += content_suggestions::HeaderBottomPadding() / 2;
}
}
return minimumHeight;
}
// Height of the feed header, returns 0 if it is not visible.
- (CGFloat)feedHeaderHeight {
return self.feedHeaderViewController
? self.feedHeaderViewController.view.frame.size.height
: 0;
}
// Height of the feed top section, returns 0 if not visible.
- (CGFloat)feedTopSectionHeight {
return self.feedTopSectionViewController
? self.feedTopSectionViewController.view.frame.size.height
: 0;
}
// The y-position content offset for when the user has completely scrolled into
// the Feed.
- (CGFloat)offsetWhenScrolledIntoFeed {
CGFloat offset = -[self feedHeaderHeight];
if ([self shouldPinFakeOmnibox]) {
offset -= [self stickyOmniboxHeight];
}
return offset;
}
// The y-position content offset for when the fake omnibox
// should stick to the top of the NTP.
- (CGFloat)offsetToStickOmnibox {
return AlignValueToPixel(-([self heightAboveFeed] -
[self.headerViewController headerHeight] +
[self stickyOmniboxHeight]));
}
// Whether the collection view has attained its minimum height.
// The fake omnibox never actually disappears; the NTP just scrolls enough so
// that it's hidden behind the real one when it's focused. When the NTP hasn't
// fully loaded yet, there isn't enough height to scroll it behind the real
// omnibox, so they would both show.
- (BOOL)collectionViewHasLoaded {
return self.collectionView.contentSize.height > 0;
}
// TODO(crbug.com/40799579): Temporary fix to compensate for the view hierarchy
// sometimes breaking. Use DCHECKs to investigate what exactly is broken and
// find a fix.
- (void)verifyNTPViewHierarchy {
// The view hierarchy with the feed enabled should be: self.view ->
// self.feedWrapperViewController.view ->
// self.feedWrapperViewController.feedViewController.view ->
// self.collectionView -> self.contentSuggestionsViewController.view.
if (self.contentSuggestionsViewController) {
if (![self.collectionView.subviews
containsObject:self.contentSuggestionsViewController.view]) {
// Remove child VC from old parent.
[self.contentSuggestionsViewController
willMoveToParentViewController:nil];
[self.contentSuggestionsViewController removeFromParentViewController];
[self.contentSuggestionsViewController.view removeFromSuperview];
[self.contentSuggestionsViewController didMoveToParentViewController:nil];
if (!IsHomeCustomizationEnabled() || self.mostVisitedVisible) {
// Add child VC to new parent.
[self.contentSuggestionsViewController
willMoveToParentViewController:self.feedWrapperViewController
.feedViewController];
[self.feedWrapperViewController.feedViewController
addChildViewController:self.contentSuggestionsViewController];
[self.collectionView
addSubview:self.contentSuggestionsViewController.view];
[self.contentSuggestionsViewController
didMoveToParentViewController:self.feedWrapperViewController
.feedViewController];
[self.feedMetricsRecorder
recordBrokenNTPHierarchy:BrokenNTPHierarchyRelationship::
kContentSuggestionsParent];
}
}
}
[self ensureView:self.headerViewController.view
isSubviewOf:self.collectionView
withRelationshipID:BrokenNTPHierarchyRelationship::
kContentSuggestionsHeaderParent];
[self ensureView:self.feedHeaderViewController.view
isSubviewOf:self.collectionView
withRelationshipID:BrokenNTPHierarchyRelationship::kFeedHeaderParent];
[self ensureView:self.collectionView
isSubviewOf:self.feedWrapperViewController.feedViewController.view
withRelationshipID:BrokenNTPHierarchyRelationship::kELMCollectionParent];
[self ensureView:self.feedWrapperViewController.feedViewController.view
isSubviewOf:self.feedWrapperViewController.view
withRelationshipID:BrokenNTPHierarchyRelationship::kDiscoverFeedParent];
[self ensureView:self.feedWrapperViewController.view
isSubviewOf:self.view
withRelationshipID:BrokenNTPHierarchyRelationship::
kDiscoverFeedWrapperParent];
}
// Ensures that `subView` is a descendent of `parentView`. If not, logs a DCHECK
// and adds the subview. Includes `relationshipID` for metrics recorder to log
// which part of the view hierarchy was broken.
// TODO(crbug.com/40799579): Remove this once bug is fixed.
- (void)ensureView:(UIView*)subView
isSubviewOf:(UIView*)parentView
withRelationshipID:(BrokenNTPHierarchyRelationship)relationship {
if (![parentView.subviews containsObject:subView]) {
DCHECK([parentView.subviews containsObject:subView]);
[subView removeFromSuperview];
[parentView addSubview:subView];
[self.feedMetricsRecorder recordBrokenNTPHierarchy:relationship];
}
}
// Checks if the collection view is scrolled at least to the minimum height and
// updates property.
- (void)updateScrolledToMinimumHeight {
CGFloat scrollPosition = [self scrollPosition];
CGFloat minimumHeightOffset = AlignValueToPixel([self pinnedOffsetY]);
self.scrolledToMinimumHeight = scrollPosition >= minimumHeightOffset;
}
// Adds `viewController` as a child of `parentViewController` and adds
// `viewController`'s view as a subview of `self.collectionView`.
- (void)addViewControllerAboveFeed:(UIViewController*)viewController {
// Gets the current parent view controller based on feed visibility.
UIViewController* parentViewController =
self.feedVisible ? self.feedWrapperViewController.feedViewController
: self.feedWrapperViewController;
// Adds view controller and its view as children of the parent view
// controller.
[viewController willMoveToParentViewController:parentViewController];
[parentViewController addChildViewController:viewController];
[self.collectionView addSubview:viewController.view];
[viewController didMoveToParentViewController:parentViewController];
// Adds view controller to array of view controllers above feed.
[self.viewControllersAboveFeed addObject:viewController];
}
// Removes `viewController` and its corresponding view from the view hierarchy.
- (void)removeFromViewHierarchy:(UIViewController*)viewController {
[viewController willMoveToParentViewController:nil];
[viewController.view removeFromSuperview];
[viewController removeFromParentViewController];
[viewController didMoveToParentViewController:nil];
}
// Whether the fake omnibox gets pinned to the top, or becomes the real primary
// toolbar. The former is for narrower devices like portait iPhones, and the
// latter is for wider devices like iPads and landscape iPhones.
- (BOOL)shouldPinFakeOmnibox {
return !IsRegularXRegularSizeClass(self) && IsSplitToolbarMode(self);
}
#pragma mark - Getters
// Returns the container view of the NTP content, depending on prefs and flags.
- (UIView*)containerView {
UIView* containerView;
if (self.feedVisible) {
// TODO(crbug.com/40799579): Remove this when the bug is fixed.
if (IsNTPViewHierarchyRepairEnabled()) {
[self verifyNTPViewHierarchy];
}
containerView = self.feedWrapperViewController.feedViewController.view;
} else {
containerView = self.view;
}
return containerView;
}
#pragma mark - Setters
// Sets whether or not the NTP is scrolled into the feed and notifies the
// content suggestions layout to avoid it changing the omnibox frame when this
// view controls its position.
- (void)setIsScrolledIntoFeed:(BOOL)scrolledIntoFeed {
_scrolledIntoFeed = scrolledIntoFeed;
}
// Sets the y content offset of the NTP collection view.
- (void)setContentOffset:(CGFloat)offset {
UICollectionView* collectionView = self.collectionView;
if (!self.feedVisible) {
// When the feed is not visible, enforce a max scroll position so that it
// doesn't end up scrolled down when no content is there. When the feed is
// visible, its content might load after the content offset is restored.
CGFloat maxOffset = collectionView.contentSize.height +
collectionView.contentInset.bottom -
collectionView.bounds.size.height;
offset = MIN(maxOffset, offset);
}
collectionView.contentOffset = CGPointMake(0, offset);
self.scrolledIntoFeed = offset > [self offsetWhenScrolledIntoFeed];
[self handleStickyElementsForScrollPosition:offset force:YES];
if (self.feedHeaderViewController) {
[self.feedHeaderViewController
toggleBackgroundBlur:(self.scrolledIntoFeed &&
[self.NTPContentDelegate isContentHeaderSticky])
animated:NO];
}
[self updateScrollPositionToSave];
}
@end