blob: ed05e341645a61228c0427916a4f0fdeb4ba265d [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/ui/ntp/google_landing_view_controller.h"
#include <algorithm>
#include "base/mac/foundation_util.h"
#include "base/metrics/user_metrics.h"
#include "base/strings/sys_string_conversions.h"
#include "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/ui/commands/UIKit+ChromeExecuteCommand.h"
#import "ios/chrome/browser/ui/commands/browser_commands.h"
#import "ios/chrome/browser/ui/commands/generic_chrome_command.h"
#include "ios/chrome/browser/ui/commands/ios_command_ids.h"
#import "ios/chrome/browser/ui/commands/new_tab_command.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_collection_utils.h"
#import "ios/chrome/browser/ui/context_menu/context_menu_coordinator.h"
#import "ios/chrome/browser/ui/ntp/google_landing_data_source.h"
#import "ios/chrome/browser/ui/ntp/most_visited_cell.h"
#import "ios/chrome/browser/ui/ntp/most_visited_layout.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_header_constants.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_header_view.h"
#import "ios/chrome/browser/ui/ntp/whats_new_header_view.h"
#import "ios/chrome/browser/ui/overscroll_actions/overscroll_actions_controller.h"
#import "ios/chrome/browser/ui/toolbar/web_toolbar_controller.h"
#include "ios/chrome/browser/ui/ui_util.h"
#import "ios/chrome/browser/ui/uikit_ui_util.h"
#import "ios/chrome/browser/ui/url_loader.h"
#include "ios/chrome/common/string_util.h"
#include "ios/chrome/grit/ios_strings.h"
#import "ios/third_party/material_components_ios/src/components/Snackbar/src/MaterialSnackbar.h"
#import "ios/third_party/material_components_ios/src/components/Typography/src/MaterialTypography.h"
#import "ios/web/public/web_state/context_menu_params.h"
#import "net/base/mac/url_conversions.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/page_transition_types.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
using base::UserMetricsAction;
namespace {
enum {
SectionWithOmnibox,
SectionWithMostVisited,
NumberOfCollectionViewSections,
};
const UIEdgeInsets kSearchBoxStretchInsets = {3, 3, 3, 3};
const CGFloat kHintLabelSidePadding = 12;
const CGFloat kWhatsNewHeaderHiddenHeight = 8;
const NSInteger kMaxNumMostVisitedFaviconRows = 2;
const CGFloat kShiftTilesDownAnimationDuration = 0.2;
} // namespace
@interface GoogleLandingViewController ()<OverscrollActionsControllerDelegate,
UICollectionViewDataSource,
UICollectionViewDelegate,
UICollectionViewDelegateFlowLayout,
UIGestureRecognizerDelegate,
WhatsNewHeaderViewDelegate> {
// Fake omnibox.
UIButton* _searchTapTarget;
// A collection view for the most visited sites.
UICollectionView* _mostVisitedView;
// The overscroll actions controller managing accelerators over the toolbar.
OverscrollActionsController* _overscrollActionsController;
// |YES| when notifications indicate the omnibox is focused.
BOOL _omniboxFocused;
// Tap and swipe gesture recognizers when the omnibox is focused.
UITapGestureRecognizer* _tapGestureRecognizer;
UISwipeGestureRecognizer* _swipeGestureRecognizer;
// Handles displaying the context menu for all form factors.
ContextMenuCoordinator* _contextMenuCoordinator;
// URL of the last deleted most viewed entry. If present the UI to restore it
// is shown.
NSURL* _deletedUrl;
// |YES| if the view has finished its first layout. This is useful when
// determining if the view has sized itself for tablet.
BOOL _viewLoaded;
// |YES| if the fakebox header should be animated on scroll.
BOOL _animateHeader;
// |YES| if the collection scrollView is scrolled all the way to the top. Used
// to lock this position in place on various frame changes.
BOOL _scrolledToTop;
// |YES| if this NTP panel is visible. When set to |NO| various UI updates
// are ignored.
BOOL _isShowing;
CFTimeInterval _shiftTilesDownStartTime;
CGSize _mostVisitedCellSize;
NSLayoutConstraint* _hintLabelLeadingConstraint;
NSLayoutConstraint* _voiceTapTrailingConstraint;
NSLayoutConstraint* _doodleHeightConstraint;
NSLayoutConstraint* _doodleTopMarginConstraint;
NSLayoutConstraint* _searchFieldWidthConstraint;
NSLayoutConstraint* _searchFieldHeightConstraint;
NSLayoutConstraint* _searchFieldTopMarginConstraint;
NewTabPageHeaderView* _headerView;
WhatsNewHeaderView* _promoHeaderView;
__weak id<GoogleLandingDataSource> _dataSource;
__weak id<UrlLoader, OmniboxFocuser> _dispatcher;
}
// Whether the Google logo or doodle is being shown.
@property(nonatomic, assign) BOOL logoIsShowing;
// Exposes view and methods to drive the doodle.
@property(nonatomic, weak) id<LogoVendor> logoVendor;
// |YES| if this consumer is has voice search enabled.
@property(nonatomic, assign) BOOL voiceSearchIsEnabled;
// Gets the maximum number of sites shown.
@property(nonatomic, assign) NSUInteger maximumMostVisitedSitesShown;
// Gets the text of a what's new promo.
@property(nonatomic, strong) NSString* promoText;
// Gets the icon of a what's new promo.
// TODO(crbug.com/694750): This should not be WhatsNewIcon.
@property(nonatomic, assign) WhatsNewIcon promoIcon;
// |YES| if a what's new promo can be displayed.
@property(nonatomic, assign) BOOL promoCanShow;
// The number of tabs to show in the google landing fake toolbar.
@property(nonatomic, assign) int tabCount;
// |YES| if the google landing toolbar can show the forward arrow, cached and
// pushed into the header view.
@property(nonatomic, assign) BOOL canGoForward;
// |YES| if the google landing toolbar can show the back arrow, cached and
// pushed into the header view.
@property(nonatomic, assign) BOOL canGoBack;
// Left margin to center the items. Used for the inset.
@property(nonatomic, assign) CGFloat leftMargin;
// Returns the height to use for the What's New promo view.
- (CGFloat)promoHeaderHeight;
// Add fake search field and voice search microphone.
- (void)addSearchField;
// Add most visited collection view.
- (void)addMostVisited;
// Update the iPhone fakebox's frame based on the current scroll view offset.
- (void)updateSearchField;
// Scrolls most visited to the top of the view when the omnibox is focused.
- (void)locationBarBecomesFirstResponder;
// Scroll the view back to 0,0 when the omnibox loses focus.
- (void)locationBarResignsFirstResponder;
// When the search field is tapped.
- (void)searchFieldTapped:(id)sender;
// Tells WebToolbarController to resign focus to the omnibox.
- (void)blurOmnibox;
// Called when a user does a long press on a most visited item.
- (void)handleMostVisitedLongPress:
(UILongPressGestureRecognizer*)longPressGesture;
// When the user removes a most visited a bubble pops up to undo the action.
- (void)showMostVisitedUndoForURL:(NSURL*)url;
// If Google is not the default search engine, hide the logo, doodle and
// fakebox.
- (void)updateLogoAndFakeboxDisplay;
// Instructs the UICollectionView and UIView to reload it's data and layout.
- (void)reloadData;
// Adds the constraints for the |logoView|, the |searchField| related to the
// |headerView|. It also creates the ivar constraints related to those views.
- (void)addConstraintsForLogoView:(UIView*)logoView
searchField:(UIView*)searchField
andHeaderView:(UIView*)headerView;
// Updates the constraints of the headers to fit |width|.
- (void)updateConstraintsForWidth:(CGFloat)width;
// Returns the size of |self.mostVisitedData|.
- (NSUInteger)numberOfItems;
// Returns the number of non empty tiles (as opposed to the placeholder tiles).
- (NSInteger)numberOfNonEmptyTilesShown;
// Returns the URL for the mosted visited item in |self.mostVisitedData|.
- (GURL)urlForIndex:(NSUInteger)index;
// Returns the nearest ancestor view that is kind of |aClass|.
- (UIView*)nearestAncestorOfView:(UIView*)view withClass:(Class)aClass;
// Updates the collection view's scroll view offset for the next frame of the
// shiftTilesDown animation.
- (void)shiftTilesDownAnimationDidFire:(CADisplayLink*)link;
// Returns the size to use for Most Visited cells in the NTP.
- (CGSize)mostVisitedCellSize;
@end
@implementation GoogleLandingViewController
@synthesize logoVendor = _logoVendor;
// Property declared in NewTabPagePanelProtocol.
@synthesize delegate = _delegate;
@synthesize logoIsShowing = _logoIsShowing;
@synthesize promoText = _promoText;
@synthesize promoIcon = _promoIcon;
@synthesize promoCanShow = _promoCanShow;
@synthesize maximumMostVisitedSitesShown = _maximumMostVisitedSitesShown;
@synthesize tabCount = _tabCount;
@synthesize canGoForward = _canGoForward;
@synthesize canGoBack = _canGoBack;
@synthesize voiceSearchIsEnabled = _voiceSearchIsEnabled;
@synthesize leftMargin = _leftMargin;
- (void)viewDidLoad {
[super viewDidLoad];
[self.view setAutoresizingMask:UIViewAutoresizingFlexibleHeight |
UIViewAutoresizingFlexibleWidth];
// Initialise |shiftTilesDownStartTime| to a sentinel value to indicate that
// the animation has not yet started.
_shiftTilesDownStartTime = -1;
_mostVisitedCellSize = [self mostVisitedCellSize];
_isShowing = YES;
_scrolledToTop = NO;
_animateHeader = YES;
_tapGestureRecognizer =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(blurOmnibox)];
[_tapGestureRecognizer setDelegate:self];
_swipeGestureRecognizer =
[[UISwipeGestureRecognizer alloc] initWithTarget:self
action:@selector(blurOmnibox)];
[_swipeGestureRecognizer setDirection:UISwipeGestureRecognizerDirectionDown];
self.leftMargin =
content_suggestions::centeredTilesMarginForWidth([self viewWidth]);
[self addSearchField];
[self addMostVisited];
[self addOverscrollActions];
[self reload];
_viewLoaded = YES;
[self.logoVendor fetchDoodle];
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
self.leftMargin =
content_suggestions::centeredTilesMarginForWidth(size.width);
// Reload the data to have the right number of items for the new orientation.
[self reloadData];
void (^alongsideBlock)(id<UIViewControllerTransitionCoordinatorContext>) = ^(
id<UIViewControllerTransitionCoordinatorContext> context) {
if (IsIPadIdiom() && _scrolledToTop) {
// Keep the most visited thumbnails scrolled to the top.
[_mostVisitedView setContentOffset:CGPointMake(0, [self pinnedOffsetY])];
return;
};
// Call -scrollViewDidScroll: so that the omnibox's frame is adjusted for
// the scroll view's offset.
[self scrollViewDidScroll:_mostVisitedView];
// Updates the constraints.
[self updateConstraintsForWidth:size.width];
BOOL isScrollableNTP = !IsIPadIdiom() || IsCompactTablet();
if (isScrollableNTP && _scrolledToTop) {
// Set the scroll view's offset to the pinned offset to keep the omnibox
// at the top of the screen if it isn't already.
CGFloat pinnedOffsetY = [self pinnedOffsetY];
if ([_mostVisitedView contentOffset].y < pinnedOffsetY) {
[_mostVisitedView setContentOffset:CGPointMake(0, pinnedOffsetY)];
} else {
[self updateSearchField];
}
}
};
[coordinator animateAlongsideTransition:alongsideBlock completion:nil];
}
- (void)viewDidLayoutSubviews {
self.leftMargin =
content_suggestions::centeredTilesMarginForWidth([self viewWidth]);
[self updateConstraintsForWidth:[self viewWidth]];
// Invalidate layout to handle the cases where the layout is changed when the
// NTP is not presented (e.g. tab backgrounded).
[[_mostVisitedView collectionViewLayout] invalidateLayout];
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
[_mostVisitedView setDelegate:nil];
[_mostVisitedView setDataSource:nil];
[_overscrollActionsController invalidate];
}
#pragma mark - Properties
- (id<GoogleLandingDataSource>)dataSource {
return _dataSource;
}
- (void)setDataSource:(id<GoogleLandingDataSource>)dataSource {
_dataSource = dataSource;
}
- (id<UrlLoader, OmniboxFocuser>)dispatcher {
return _dispatcher;
}
- (void)setDispatcher:(id<UrlLoader, OmniboxFocuser>)dispatcher {
_dispatcher = dispatcher;
}
#pragma mark - Private
- (CGSize)mostVisitedCellSize {
if (IsIPadIdiom()) {
// On iPads, split-screen and slide-over may require showing smaller cells.
CGSize maximumCellSize = [MostVisitedCell maximumSize];
CGSize viewSize = self.view.bounds.size;
CGFloat smallestDimension =
viewSize.height > viewSize.width ? viewSize.width : viewSize.height;
CGFloat cellWidth = AlignValueToPixel(
(smallestDimension - 3 * content_suggestions::spacingBetweenTiles()) /
2);
if (cellWidth < maximumCellSize.width) {
return CGSizeMake(cellWidth, cellWidth);
} else {
return maximumCellSize;
}
} else {
return [MostVisitedCell maximumSize];
}
}
- (CGFloat)viewWidth {
return [self.view frame].size.width;
}
- (int)numberOfColumns {
CGFloat width = [self viewWidth];
NSUInteger columns = content_suggestions::numberOfTilesForWidth(
width - 2 * content_suggestions::spacingBetweenTiles());
DCHECK(columns > 1);
return columns;
}
- (CGFloat)promoHeaderHeight {
CGFloat promoMaxWidth =
[self viewWidth] -
2 * content_suggestions::centeredTilesMarginForWidth([self viewWidth]);
NSString* text = self.promoText;
return [WhatsNewHeaderView heightToFitText:text inWidth:promoMaxWidth];
}
- (void)updateLogoAndFakeboxDisplay {
if (self.logoVendor.showingLogo != self.logoIsShowing) {
self.logoVendor.showingLogo = self.logoIsShowing;
if (_viewLoaded) {
[_doodleHeightConstraint
setConstant:content_suggestions::doodleHeight(self.logoIsShowing)];
}
if (IsIPadIdiom())
[_searchTapTarget setHidden:!self.logoIsShowing];
[[_mostVisitedView collectionViewLayout] invalidateLayout];
}
}
// Initialize and add a search field tap target and a voice search button.
- (void)addSearchField {
_searchTapTarget = [[UIButton alloc] init];
if (IsIPadIdiom()) {
UIImage* searchBoxImage = [[UIImage imageNamed:@"ntp_google_search_box"]
resizableImageWithCapInsets:kSearchBoxStretchInsets];
[_searchTapTarget setBackgroundImage:searchBoxImage
forState:UIControlStateNormal];
}
[_searchTapTarget setAdjustsImageWhenHighlighted:NO];
[_searchTapTarget addTarget:self
action:@selector(searchFieldTapped:)
forControlEvents:UIControlEventTouchUpInside];
[_searchTapTarget
setAccessibilityLabel:l10n_util::GetNSString(IDS_OMNIBOX_EMPTY_HINT)];
// Set isAccessibilityElement to NO so that Voice Search button is accessible.
[_searchTapTarget setIsAccessibilityElement:NO];
// Set up fakebox hint label.
UILabel* searchHintLabel = [[UILabel alloc] init];
content_suggestions::configureSearchHintLabel(searchHintLabel,
_searchTapTarget);
_hintLabelLeadingConstraint = [searchHintLabel.leadingAnchor
constraintEqualToAnchor:[_searchTapTarget leadingAnchor]
constant:kHintLabelSidePadding];
[_hintLabelLeadingConstraint setActive:YES];
// Add a voice search button.
UIButton* voiceTapTarget = [[UIButton alloc] init];
content_suggestions::configureVoiceSearchButton(voiceTapTarget,
_searchTapTarget);
_voiceTapTrailingConstraint = [voiceTapTarget.trailingAnchor
constraintEqualToAnchor:[_searchTapTarget trailingAnchor]];
[NSLayoutConstraint activateConstraints:@[
[searchHintLabel.trailingAnchor
constraintEqualToAnchor:voiceTapTarget.leadingAnchor],
_voiceTapTrailingConstraint
]];
if (self.voiceSearchIsEnabled) {
[voiceTapTarget addTarget:self
action:@selector(loadVoiceSearch:)
forControlEvents:UIControlEventTouchUpInside];
[voiceTapTarget addTarget:self
action:@selector(preloadVoiceSearch:)
forControlEvents:UIControlEventTouchDown];
} else {
[voiceTapTarget setEnabled:NO];
}
}
- (void)loadVoiceSearch:(id)sender {
DCHECK(self.voiceSearchIsEnabled);
base::RecordAction(UserMetricsAction("MobileNTPMostVisitedVoiceSearch"));
[sender chromeExecuteCommand:sender];
}
- (void)preloadVoiceSearch:(id)sender {
DCHECK(self.voiceSearchIsEnabled);
[sender removeTarget:self
action:@selector(preloadVoiceSearch:)
forControlEvents:UIControlEventTouchDown];
// Use a GenericChromeCommand because |sender| already has a tag set for a
// different command.
GenericChromeCommand* command =
[[GenericChromeCommand alloc] initWithTag:IDC_PRELOAD_VOICE_SEARCH];
[sender chromeExecuteCommand:command];
}
// Initialize and add a panel with most visited sites.
- (void)addMostVisited {
CGRect mostVisitedFrame = [self.view bounds];
UICollectionViewFlowLayout* flowLayout;
if (IsIPadIdiom())
flowLayout = [[UICollectionViewFlowLayout alloc] init];
else
flowLayout = [[MostVisitedLayout alloc] init];
[flowLayout setScrollDirection:UICollectionViewScrollDirectionVertical];
[flowLayout setItemSize:_mostVisitedCellSize];
[flowLayout setMinimumInteritemSpacing:8];
[flowLayout setMinimumLineSpacing:content_suggestions::spacingBetweenTiles()];
DCHECK(!_mostVisitedView);
_mostVisitedView = [[UICollectionView alloc] initWithFrame:mostVisitedFrame
collectionViewLayout:flowLayout];
[_mostVisitedView setAutoresizingMask:UIViewAutoresizingFlexibleHeight |
UIViewAutoresizingFlexibleWidth];
[_mostVisitedView setDelegate:self];
[_mostVisitedView setDataSource:self];
[_mostVisitedView registerClass:[MostVisitedCell class]
forCellWithReuseIdentifier:@"classCell"];
[_mostVisitedView setBackgroundColor:[UIColor clearColor]];
[_mostVisitedView setBounces:YES];
[_mostVisitedView setShowsHorizontalScrollIndicator:NO];
[_mostVisitedView setShowsVerticalScrollIndicator:NO];
[_mostVisitedView registerClass:[UICollectionReusableView class]
forSupplementaryViewOfKind:UICollectionElementKindSectionHeader
withReuseIdentifier:@"header"];
[_mostVisitedView setAccessibilityIdentifier:@"Google Landing"];
[self.view addSubview:_mostVisitedView];
}
- (void)updateSearchField {
NSArray* constraints =
@[ _hintLabelLeadingConstraint, _voiceTapTrailingConstraint ];
[_headerView updateSearchFieldWidth:_searchFieldWidthConstraint
height:_searchFieldHeightConstraint
topMargin:_searchFieldTopMarginConstraint
subviewConstraints:constraints
logoIsShowing:self.logoIsShowing
forOffset:[_mostVisitedView contentOffset].y];
}
- (void)addOverscrollActions {
if (!IsIPadIdiom()) {
_overscrollActionsController = [[OverscrollActionsController alloc]
initWithScrollView:_mostVisitedView];
[_overscrollActionsController setStyle:OverscrollStyle::NTP_NON_INCOGNITO];
[_overscrollActionsController setDelegate:self];
}
}
// Check to see if the promo label should be hidden.
- (void)hideWhatsNewIfNecessary {
if (![_promoHeaderView isHidden] && !self.promoCanShow) {
[_promoHeaderView setHidden:YES];
[self.view setNeedsLayout];
}
}
- (void)locationBarBecomesFirstResponder {
if (!_isShowing)
return;
_omniboxFocused = YES;
[self shiftTilesUp];
}
- (void)shiftTilesUp {
_scrolledToTop = YES;
// Add gesture recognizer to background |self.view| when omnibox is focused.
[self.view addGestureRecognizer:_tapGestureRecognizer];
[self.view addGestureRecognizer:_swipeGestureRecognizer];
CGFloat pinnedOffsetY = [self pinnedOffsetY];
_animateHeader = !IsIPadIdiom();
[UIView animateWithDuration:0.25
animations:^{
if ([_mostVisitedView contentOffset].y < pinnedOffsetY) {
[_mostVisitedView setContentOffset:CGPointMake(0, pinnedOffsetY)];
[[_mostVisitedView collectionViewLayout] invalidateLayout];
}
}
completion:^(BOOL finished) {
// Check to see if we are still scrolled to the top -- it's possible
// (and difficult) to resign the first responder and initiate a
// -shiftTilesDown before the animation here completes.
if (_scrolledToTop) {
_animateHeader = NO;
if (!IsIPadIdiom()) {
[self.dispatcher onFakeboxAnimationComplete];
[_headerView fadeOutShadow];
[_searchTapTarget setHidden:YES];
}
}
}];
}
- (void)searchFieldTapped:(id)sender {
[self.dispatcher focusFakebox];
}
- (void)blurOmnibox {
if (_omniboxFocused) {
[self.dispatcher cancelOmniboxEdit];
} else {
[self locationBarResignsFirstResponder];
}
}
- (void)locationBarResignsFirstResponder {
if (!_isShowing && !_scrolledToTop)
return;
_omniboxFocused = NO;
if ([_contextMenuCoordinator isVisible]) {
return;
}
[self shiftTilesDown];
}
- (void)shiftTilesDown {
_animateHeader = YES;
_scrolledToTop = NO;
if (!IsIPadIdiom()) {
[_searchTapTarget setHidden:NO];
[self.dispatcher onFakeboxBlur];
}
// Reload most visited sites in case the number of placeholder cells needs to
// be updated after an orientation change.
[_mostVisitedView reloadData];
// Reshow views that are within range of the most visited collection view
// (if necessary).
[self.view removeGestureRecognizer:_tapGestureRecognizer];
[self.view removeGestureRecognizer:_swipeGestureRecognizer];
// CADisplayLink is used for this animation instead of the standard UIView
// animation because the standard animation did not properly convert the
// fakebox from its scrolled up mode to its scrolled down mode. Specifically,
// calling |UICollectionView reloadData| adjacent to the standard animation
// caused the fakebox's views to jump incorrectly. CADisplayLink avoids this
// problem because it allows |shiftTilesDownAnimationDidFire| to directly
// control each frame.
CADisplayLink* link = [CADisplayLink
displayLinkWithTarget:self
selector:@selector(shiftTilesDownAnimationDidFire:)];
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
// Dismisses modal UI elements if displayed. Must be called at the end of
// -locationBarResignsFirstResponder since it could result in -dealloc being
// called.
[self dismissModals];
}
- (void)shiftTilesDownAnimationDidFire:(CADisplayLink*)link {
// If this is the first frame of the animation, store the starting timestamp
// and do nothing.
if (_shiftTilesDownStartTime == -1) {
_shiftTilesDownStartTime = link.timestamp;
return;
}
CFTimeInterval timeElapsed = link.timestamp - _shiftTilesDownStartTime;
double percentComplete = timeElapsed / kShiftTilesDownAnimationDuration;
// Ensure that the percentage cannot be above 1.0.
if (percentComplete > 1.0)
percentComplete = 1.0;
// Find how much the collection view should be scrolled up in the next frame.
CGFloat yOffset = (1.0 - percentComplete) * [self pinnedOffsetY];
[_mostVisitedView setContentOffset:CGPointMake(0, yOffset)];
if (percentComplete == 1.0) {
[link invalidate];
// Reset |shiftTilesDownStartTime to its sentinal value.
_shiftTilesDownStartTime = -1;
[[_mostVisitedView collectionViewLayout] invalidateLayout];
}
}
- (void)reloadData {
// -reloadData updates from |self.mostVisitedData|.
// -invalidateLayout is necessary because sometimes the flowLayout has the
// wrong cached size and will throw an internal exception if the
// -numberOfItems shrinks. -setNeedsLayout is needed in case
// -numberOfItems increases enough to add a new row and change the height
// of _mostVisitedView.
[_mostVisitedView reloadData];
[[_mostVisitedView collectionViewLayout] invalidateLayout];
[self.view setNeedsLayout];
}
- (void)addConstraintsForLogoView:(UIView*)logoView
searchField:(UIView*)searchField
andHeaderView:(UIView*)headerView {
_doodleTopMarginConstraint = [logoView.topAnchor
constraintEqualToAnchor:headerView.topAnchor
constant:content_suggestions::doodleTopMargin()];
_doodleHeightConstraint = [logoView.heightAnchor
constraintEqualToConstant:content_suggestions::doodleHeight(
self.logoIsShowing)];
_searchFieldWidthConstraint = [searchField.widthAnchor
constraintEqualToConstant:content_suggestions::searchFieldWidth(
[self viewWidth])];
_searchFieldHeightConstraint = [searchField.heightAnchor
constraintEqualToConstant:content_suggestions::kSearchFieldHeight];
_searchFieldTopMarginConstraint = [searchField.topAnchor
constraintEqualToAnchor:logoView.bottomAnchor
constant:content_suggestions::searchFieldTopMargin()];
[NSLayoutConstraint activateConstraints:@[
_doodleTopMarginConstraint,
_doodleHeightConstraint,
_searchFieldWidthConstraint,
_searchFieldHeightConstraint,
_searchFieldTopMarginConstraint,
[logoView.widthAnchor constraintEqualToAnchor:headerView.widthAnchor],
[logoView.leadingAnchor constraintEqualToAnchor:headerView.leadingAnchor],
[searchField.centerXAnchor
constraintEqualToAnchor:headerView.centerXAnchor],
]];
}
- (void)updateConstraintsForWidth:(CGFloat)width {
[_promoHeaderView
setSideMargin:content_suggestions::centeredTilesMarginForWidth(width)
forWidth:width];
[_doodleTopMarginConstraint
setConstant:content_suggestions::doodleTopMargin()];
[_searchFieldWidthConstraint
setConstant:content_suggestions::searchFieldWidth(width)];
[_searchFieldTopMarginConstraint
setConstant:content_suggestions::searchFieldTopMargin()];
}
#pragma mark - ToolbarOwner
- (ToolbarController*)relinquishedToolbarController {
return [_headerView relinquishedToolbarController];
}
- (void)reparentToolbarController {
[_headerView reparentToolbarController];
}
#pragma mark - UICollectionView Methods.
- (UIEdgeInsets)collectionView:(UICollectionView*)collectionView
layout:(UICollectionViewLayout*)collectionViewLayout
insetForSectionAtIndex:(NSInteger)section {
return UIEdgeInsetsMake(0, self.leftMargin, 0, self.leftMargin);
}
- (CGSize)collectionView:(UICollectionView*)collectionView
layout:
(UICollectionViewLayout*)collectionViewLayout
referenceSizeForHeaderInSection:(NSInteger)section {
CGFloat headerHeight = 0;
if (section == SectionWithOmnibox) {
headerHeight = content_suggestions::heightForLogoHeader(self.logoIsShowing,
self.promoCanShow);
((UICollectionViewFlowLayout*)collectionViewLayout).headerReferenceSize =
CGSizeMake(0, headerHeight);
} else if (section == SectionWithMostVisited) {
if (self.promoCanShow) {
headerHeight = [self promoHeaderHeight];
} else {
headerHeight = kWhatsNewHeaderHiddenHeight;
}
}
return CGSizeMake(0, headerHeight);
}
#pragma mark - UICollectionViewDelegate
- (BOOL)collectionView:(UICollectionView*)collectionView
shouldSelectItemAtIndexPath:(NSIndexPath*)indexPath {
return indexPath.row < static_cast<NSInteger>([self numberOfItems]);
}
- (void)collectionView:(UICollectionView*)collectionView
didSelectItemAtIndexPath:(NSIndexPath*)indexPath {
MostVisitedCell* cell =
(MostVisitedCell*)[collectionView cellForItemAtIndexPath:indexPath];
// Keep the UICollectionView alive for one second while the screen
// reader does its thing.
// TODO(jif): This needs a radar, since it is almost certainly a
// UIKit accessibility bug. crbug.com/529271
if (UIAccessibilityIsVoiceOverRunning()) {
__block UICollectionView* blockView = _mostVisitedView;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
static_cast<int64_t>(1 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
blockView = nil;
});
}
const NSUInteger visitedIndex = indexPath.row;
[self blurOmnibox];
DCHECK(visitedIndex < [self numberOfItems]);
[self.dataSource logMostVisitedClick:visitedIndex tileType:cell.tileType];
[self.dispatcher loadURL:[self urlForIndex:visitedIndex]
referrer:web::Referrer()
transition:ui::PAGE_TRANSITION_AUTO_BOOKMARK
rendererInitiated:NO];
}
#pragma mark - UICollectionViewDataSource
- (UICollectionReusableView*)collectionView:(UICollectionView*)collectionView
viewForSupplementaryElementOfKind:(NSString*)kind
atIndexPath:(NSIndexPath*)indexPath {
DCHECK(kind == UICollectionElementKindSectionHeader);
if (indexPath.section == SectionWithOmnibox) {
UICollectionReusableView* reusableView =
[collectionView dequeueReusableSupplementaryViewOfKind:
UICollectionElementKindSectionHeader
withReuseIdentifier:@"header"
forIndexPath:indexPath];
if (!_headerView) {
_headerView = [[NewTabPageHeaderView alloc] init];
[_headerView addSubview:[self.logoVendor view]];
[_headerView addSubview:_searchTapTarget];
self.logoVendor.view.translatesAutoresizingMaskIntoConstraints = NO;
[_searchTapTarget setTranslatesAutoresizingMaskIntoConstraints:NO];
[self addConstraintsForLogoView:self.logoVendor.view
searchField:_searchTapTarget
andHeaderView:_headerView];
[_headerView addViewsToSearchField:_searchTapTarget];
if (!IsIPadIdiom()) {
// iPhone header also contains a toolbar since the normal toolbar is
// hidden.
[_headerView
addToolbarWithReadingListModel:[self.dataSource readingListModel]
dispatcher:self.dispatcher];
[_headerView setToolbarTabCount:self.tabCount];
[_headerView setCanGoForward:self.canGoForward];
[_headerView setCanGoBack:self.canGoBack];
}
[_headerView setTranslatesAutoresizingMaskIntoConstraints:NO];
}
[reusableView addSubview:_headerView];
AddSameConstraints(reusableView, _headerView);
return reusableView;
}
if (indexPath.section == SectionWithMostVisited) {
UICollectionReusableView* reusableView =
[collectionView dequeueReusableSupplementaryViewOfKind:
UICollectionElementKindSectionHeader
withReuseIdentifier:@"header"
forIndexPath:indexPath];
if (!_promoHeaderView) {
_promoHeaderView = [[WhatsNewHeaderView alloc] init];
[_promoHeaderView
setSideMargin:content_suggestions::centeredTilesMarginForWidth(
[self viewWidth])
forWidth:[self viewWidth]];
[_promoHeaderView setDelegate:self];
if (self.promoCanShow) {
[_promoHeaderView setText:self.promoText];
[_promoHeaderView setIcon:self.promoIcon];
[self.dataSource promoViewed];
}
}
[reusableView addSubview:_promoHeaderView];
AddSameConstraints(reusableView, _promoHeaderView);
return reusableView;
}
NOTREACHED();
return nil;
}
- (NSInteger)numberOfSectionsInCollectionView:
(UICollectionView*)collectionView {
return NumberOfCollectionViewSections;
}
- (NSInteger)collectionView:(UICollectionView*)collectionView
numberOfItemsInSection:(NSInteger)section {
// The first section only contains a header view and no items.
if (section == SectionWithOmnibox)
return 0;
// Phone always contains the maximum number of cells. Cells in excess of the
// number of thumbnails are used solely for layout/sizing.
if (!IsIPadIdiom())
return self.maximumMostVisitedSitesShown;
return [self numberOfNonEmptyTilesShown];
}
- (UICollectionViewCell*)collectionView:(UICollectionView*)collectionView
cellForItemAtIndexPath:(NSIndexPath*)indexPath {
MostVisitedCell* cell = (MostVisitedCell*)[collectionView
dequeueReusableCellWithReuseIdentifier:@"classCell"
forIndexPath:indexPath];
BOOL isPlaceholder = indexPath.row >= (int)[self numberOfItems];
if (isPlaceholder) {
[cell showPlaceholder];
for (UIGestureRecognizer* ges in cell.gestureRecognizers) {
[cell removeGestureRecognizer:ges];
}
// When -numberOfItems is 0, always remove the placeholder.
if (indexPath.row >= [self numberOfColumns] || [self numberOfItems] == 0) {
// This cell is completely empty and only exists for layout/sizing
// purposes.
[cell removePlaceholderImage];
}
return cell;
}
const ntp_tiles::NTPTile& ntpTile =
[self.dataSource mostVisitedAtIndex:indexPath.row];
NSString* title = base::SysUTF16ToNSString(ntpTile.title);
[cell setupWithURL:ntpTile.url title:title dataSource:self.dataSource];
UILongPressGestureRecognizer* longPress =
[[UILongPressGestureRecognizer alloc]
initWithTarget:self
action:@selector(handleMostVisitedLongPress:)];
[cell addGestureRecognizer:longPress];
return cell;
}
#pragma mark - Context Menu
// Called when a user does a long press on a most visited item.
- (void)handleMostVisitedLongPress:(UILongPressGestureRecognizer*)sender {
if (sender.state == UIGestureRecognizerStateBegan) {
// Only one long press at a time.
if ([_contextMenuCoordinator isVisible]) {
return;
}
NSIndexPath* indexPath = [_mostVisitedView
indexPathForCell:static_cast<UICollectionViewCell*>(sender.view)];
const NSUInteger index = indexPath.row;
// A long press occured on one of the most visited button. Popup a context
// menu.
DCHECK(index < [self numberOfItems]);
web::ContextMenuParams params;
// Get view coordinates in local space.
params.location = [sender locationInView:self.view];
params.view.reset(self.view);
// Present sheet/popover using controller that is added to view hierarchy.
UIViewController* topController = [params.view window].rootViewController;
while (topController.presentedViewController)
topController = topController.presentedViewController;
_contextMenuCoordinator =
[[ContextMenuCoordinator alloc] initWithBaseViewController:topController
params:params];
ProceduralBlock action;
// Open In New Tab.
GURL url = [self urlForIndex:index];
__weak GoogleLandingViewController* weakSelf = self;
action = ^{
GoogleLandingViewController* strongSelf = weakSelf;
if (!strongSelf)
return;
MostVisitedCell* cell = (MostVisitedCell*)sender.view;
[[strongSelf dataSource] logMostVisitedClick:index
tileType:cell.tileType];
[[strongSelf dispatcher] webPageOrderedOpen:url
referrer:web::Referrer()
inBackground:YES
appendTo:kCurrentTab];
};
[_contextMenuCoordinator
addItemWithTitle:l10n_util::GetNSStringWithFixup(
IDS_IOS_CONTENT_CONTEXT_OPENLINKNEWTAB)
action:action];
// Open in Incognito Tab.
action = ^{
GoogleLandingViewController* strongSelf = weakSelf;
if (!strongSelf)
return;
MostVisitedCell* cell = (MostVisitedCell*)sender.view;
[[strongSelf dataSource] logMostVisitedClick:index
tileType:cell.tileType];
[[strongSelf dispatcher] webPageOrderedOpen:url
referrer:web::Referrer()
inIncognito:YES
inBackground:NO
appendTo:kCurrentTab];
};
[_contextMenuCoordinator
addItemWithTitle:l10n_util::GetNSStringWithFixup(
IDS_IOS_CONTENT_CONTEXT_OPENLINKNEWINCOGNITOTAB)
action:action];
// Remove the most visited url.
NSString* title =
l10n_util::GetNSStringWithFixup(IDS_BOOKMARK_BUBBLE_REMOVE_BOOKMARK);
action = ^{
GoogleLandingViewController* strongSelf = weakSelf;
// Early return if the controller has been deallocated.
if (!strongSelf)
return;
base::RecordAction(UserMetricsAction("MostVisited_UrlBlacklisted"));
[[strongSelf dataSource] addBlacklistedURL:url];
[strongSelf showMostVisitedUndoForURL:net::NSURLWithGURL(url)];
};
[_contextMenuCoordinator addItemWithTitle:title action:action];
[_contextMenuCoordinator start];
if (IsIPadIdiom())
[self blurOmnibox];
}
}
- (void)showMostVisitedUndoForURL:(NSURL*)url {
_deletedUrl = url;
MDCSnackbarMessageAction* action = [[MDCSnackbarMessageAction alloc] init];
__weak GoogleLandingViewController* weakSelf = self;
action.handler = ^{
GoogleLandingViewController* strongSelf = weakSelf;
if (!strongSelf)
return;
[[strongSelf dataSource]
removeBlacklistedURL:net::GURLWithNSURL(_deletedUrl)];
};
action.title = l10n_util::GetNSString(IDS_NEW_TAB_UNDO_THUMBNAIL_REMOVE);
action.accessibilityIdentifier = @"Undo";
TriggerHapticFeedbackForNotification(UINotificationFeedbackTypeSuccess);
MDCSnackbarMessage* message = [MDCSnackbarMessage
messageWithText:l10n_util::GetNSString(
IDS_IOS_NEW_TAB_MOST_VISITED_ITEM_REMOVED)];
message.action = action;
message.category = @"MostVisitedUndo";
[MDCSnackbarManager showMessage:message];
}
- (void)onPromoLabelTapped {
[self.dispatcher cancelOmniboxEdit];
[_promoHeaderView setHidden:YES];
[self.view setNeedsLayout];
[self.dataSource promoTapped];
}
// Returns the Y value to use for the scroll view's contentOffset when scrolling
// the omnibox to the top of the screen.
- (CGFloat)pinnedOffsetY {
CGFloat headerHeight = content_suggestions::heightForLogoHeader(
self.logoIsShowing, self.promoCanShow);
CGFloat offsetY =
headerHeight - ntp_header::kScrolledToTopOmniboxBottomMargin;
if (!IsIPadIdiom())
offsetY -= ntp_header::kToolbarHeight;
return offsetY;
}
#pragma mark - NewTabPagePanelProtocol
- (void)reload {
// Fetch the doodle after the view finishes laying out. Otherwise, tablet
// may fetch the wrong sized doodle.
if (_viewLoaded)
[self.logoVendor fetchDoodle];
[self updateLogoAndFakeboxDisplay];
[self hideWhatsNewIfNecessary];
}
- (void)wasShown {
_isShowing = YES;
[_headerView hideToolbarViewsForNewTabPage];
// The view is not loaded with the width it is displayed with. Reloading the
// data after being displayed ensure that we got the right number of items.
[self reloadData];
}
- (void)wasHidden {
_isShowing = NO;
}
- (void)dismissModals {
[_contextMenuCoordinator stop];
}
- (void)dismissKeyboard {
}
- (void)setScrollsToTop:(BOOL)enable {
}
- (CGFloat)alphaForBottomShadow {
// Get the frame of the bottommost cell in |self.view|'s coordinate system.
NSInteger section = SectionWithMostVisited;
// Account for the fact that the tableview may not yet contain
// |numberOfNonEmptyTilesShown| tiles because it hasn't been updated yet.
NSUInteger lastItemIndex =
std::min([_mostVisitedView numberOfItemsInSection:SectionWithMostVisited],
[self numberOfNonEmptyTilesShown]) -
1;
DCHECK(lastItemIndex >= 0);
NSIndexPath* lastCellIndexPath =
[NSIndexPath indexPathForItem:lastItemIndex inSection:section];
UICollectionViewLayoutAttributes* attributes =
[_mostVisitedView layoutAttributesForItemAtIndexPath:lastCellIndexPath];
CGRect lastCellFrame = attributes.frame;
CGRect cellFrameInSuperview =
[_mostVisitedView convertRect:lastCellFrame toView:self.view];
// Calculate when the bottom of the cell passes through the bottom of
// |self.view|.
CGFloat maxY = CGRectGetMaxY(cellFrameInSuperview);
CGFloat viewHeight = CGRectGetHeight(self.view.frame);
CGFloat pixelsBelowFrame = maxY - viewHeight;
CGFloat alpha = pixelsBelowFrame / kNewTabPageDistanceToFadeShadow;
alpha = MIN(MAX(alpha, 0), 1);
return alpha;
}
- (void)willUpdateSnapshot {
[_overscrollActionsController clear];
}
#pragma mark - LogoAnimationControllerOwnerOwner
- (id<LogoAnimationControllerOwner>)logoAnimationControllerOwner {
return [self.logoVendor logoAnimationControllerOwner];
}
#pragma mark - UIScrollViewDelegate Methods.
- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
[self.delegate updateNtpBarShadowForPanelController:self];
[_overscrollActionsController scrollViewDidScroll:scrollView];
// Blur the omnibox when the scroll view is scrolled below the pinned offset.
CGFloat pinnedOffsetY = [self pinnedOffsetY];
if (_omniboxFocused && scrollView.dragging &&
scrollView.contentOffset.y < pinnedOffsetY) {
[self.dispatcher cancelOmniboxEdit];
}
if (IsIPadIdiom()) {
return;
}
if (_animateHeader) {
[self updateSearchField];
}
}
- (void)scrollViewWillBeginDragging:(UIScrollView*)scrollView {
[_overscrollActionsController scrollViewWillBeginDragging:scrollView];
}
- (void)scrollViewDidEndDragging:(UIScrollView*)scrollView
willDecelerate:(BOOL)decelerate {
[_overscrollActionsController scrollViewDidEndDragging:scrollView
willDecelerate:decelerate];
}
- (void)scrollViewWillEndDragging:(UIScrollView*)scrollView
withVelocity:(CGPoint)velocity
targetContentOffset:(inout CGPoint*)targetContentOffset {
[_overscrollActionsController scrollViewWillEndDragging:scrollView
withVelocity:velocity
targetContentOffset:targetContentOffset];
if (IsIPadIdiom() || _omniboxFocused)
return;
CGFloat pinnedOffsetY = [self pinnedOffsetY];
CGFloat offsetY = scrollView.contentOffset.y;
CGFloat targetY = targetContentOffset->y;
if (offsetY > 0 && offsetY < pinnedOffsetY) {
// Omnibox is currently between middle and top of screen.
if (velocity.y > 0) { // scrolling upwards
if (targetY < pinnedOffsetY) {
// Scroll the omnibox up to |pinnedOffsetY| if velocity is upwards but
// scrolling will stop before reaching |pinnedOffsetY|.
targetContentOffset->y = offsetY;
[_mostVisitedView setContentOffset:CGPointMake(0, pinnedOffsetY)
animated:YES];
}
_scrolledToTop = YES;
} else { // scrolling downwards
if (targetY > 0) {
// Scroll the omnibox down to zero if velocity is downwards or 0 but
// scrolling will stop before reaching 0.
targetContentOffset->y = offsetY;
[_mostVisitedView setContentOffset:CGPointZero animated:YES];
}
_scrolledToTop = NO;
}
} else if (offsetY > pinnedOffsetY &&
targetContentOffset->y < pinnedOffsetY) {
// Most visited cells are currently scrolled up past the omnibox but will
// end the scroll below the omnibox. Stop the scroll at just below the
// omnibox.
targetContentOffset->y = offsetY;
[_mostVisitedView setContentOffset:CGPointMake(0, pinnedOffsetY)
animated:YES];
_scrolledToTop = YES;
} else if (offsetY >= pinnedOffsetY) {
_scrolledToTop = YES;
} else if (offsetY <= 0) {
_scrolledToTop = NO;
}
}
#pragma mark - Most visited / Suggestions service wrapper methods.
- (NSUInteger)numberOfItems {
NSUInteger numItems = [self.dataSource mostVisitedSize];
NSUInteger maxItems = [self numberOfColumns] * kMaxNumMostVisitedFaviconRows;
return MIN(maxItems, numItems);
}
- (NSInteger)numberOfNonEmptyTilesShown {
NSInteger numCells =
MIN([self numberOfItems], self.maximumMostVisitedSitesShown);
return MAX(numCells, [self numberOfColumns]);
}
- (GURL)urlForIndex:(NSUInteger)index {
return [self.dataSource mostVisitedAtIndex:index].url;
}
#pragma mark - GoogleLandingViewController (ExposedForTesting) methods.
- (BOOL)scrolledToTop {
return _scrolledToTop;
}
- (BOOL)animateHeader {
return _animateHeader;
}
#pragma mark - OverscrollActionsControllerDelegate
- (void)overscrollActionsController:(OverscrollActionsController*)controller
didTriggerAction:(OverscrollAction)action {
switch (action) {
case OverscrollAction::NEW_TAB: {
NewTabCommand* command = [[NewTabCommand alloc] initWithIncognito:NO];
[[self view] chromeExecuteCommand:command];
} break;
case OverscrollAction::CLOSE_TAB:
[self.dispatcher closeCurrentTab];
break;
case OverscrollAction::REFRESH:
[self reload];
break;
case OverscrollAction::NONE:
NOTREACHED();
break;
}
}
- (BOOL)shouldAllowOverscrollActions {
return YES;
}
- (UIView*)toolbarSnapshotView {
return [[_headerView toolBarView] snapshotViewAfterScreenUpdates:NO];
}
- (UIView*)headerView {
return self.view;
}
- (CGFloat)overscrollActionsControllerHeaderInset:
(OverscrollActionsController*)controller {
return 0;
}
- (CGFloat)overscrollHeaderHeight {
return [_headerView toolBarView].bounds.size.height;
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldReceiveTouch:(UITouch*)touch {
return [self nearestAncestorOfView:touch.view
withClass:[MostVisitedCell class]] == nil;
}
- (UIView*)nearestAncestorOfView:(UIView*)view withClass:(Class)aClass {
if (!view) {
return nil;
}
if ([view isKindOfClass:aClass]) {
return view;
}
return [self nearestAncestorOfView:[view superview] withClass:aClass];
}
#pragma mark - GoogleLandingConsumer
- (void)setLogoIsShowing:(BOOL)logoIsShowing {
_logoIsShowing = logoIsShowing;
[self updateLogoAndFakeboxDisplay];
}
- (void)mostVisitedDataUpdated {
[self reloadData];
}
- (void)mostVisitedIconMadeAvailableAtIndex:(NSUInteger)index {
if (index >= [self numberOfItems])
return;
NSIndexPath* indexPath =
[NSIndexPath indexPathForRow:index inSection:SectionWithMostVisited];
[_mostVisitedView reloadItemsAtIndexPaths:@[ indexPath ]];
}
- (void)setTabCount:(int)tabCount {
_tabCount = tabCount;
[_headerView setToolbarTabCount:self.tabCount];
}
- (void)setCanGoForward:(BOOL)canGoForward {
_canGoForward = canGoForward;
[_headerView setCanGoForward:self.canGoForward];
}
- (void)setCanGoBack:(BOOL)canGoBack {
_canGoBack = canGoBack;
[_headerView setCanGoBack:self.canGoBack];
}
@end