blob: 6067f4e28b15b4f12c1dc9881424fe2947ba2063 [file] [log] [blame]
// Copyright 2017 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/content_suggestions/content_suggestions_header_view_controller.h"
#include "base/feature_list.h"
#include "base/ios/ios_util.h"
#include "base/logging.h"
#include "base/mac/foundation_util.h"
#include "base/metrics/user_metrics.h"
#include "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/ntp/new_tab_page_tab_helper.h"
#import "ios/chrome/browser/signin/feature_flags.h"
#import "ios/chrome/browser/ui/commands/application_commands.h"
#import "ios/chrome/browser/ui/commands/browser_commands.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_collection_synchronizing.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_collection_utils.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_commands.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_header_view.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_header_view_controller_delegate.h"
#import "ios/chrome/browser/ui/content_suggestions/ntp_home_constant.h"
#import "ios/chrome/browser/ui/content_suggestions/user_account_image_update_delegate.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_controller_delegate.h"
#import "ios/chrome/browser/ui/ntp/new_tab_page_header_constants.h"
#import "ios/chrome/browser/ui/toolbar/public/fakebox_focuser.h"
#import "ios/chrome/browser/ui/toolbar/public/omnibox_focuser.h"
#import "ios/chrome/browser/ui/toolbar/public/toolbar_utils.h"
#import "ios/chrome/browser/ui/util/named_guide.h"
#include "ios/chrome/browser/ui/util/ui_util.h"
#import "ios/chrome/browser/ui/util/uikit_ui_util.h"
#import "ios/chrome/common/ui_util/constraints_ui_util.h"
#include "ios/chrome/grit/ios_strings.h"
#include "ios/public/provider/chrome/browser/ui/logo_vendor.h"
#include "ui/base/l10n/l10n_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
using base::UserMetricsAction;
@interface ContentSuggestionsHeaderViewController () <
UserAccountImageUpdateDelegate>
// 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| when notifications indicate the omnibox is focused.
@property(nonatomic, assign, getter=isOmniboxFocused) BOOL omniboxFocused;
// |YES| if this consumer is has voice search enabled.
@property(nonatomic, assign) BOOL voiceSearchIsEnabled;
// Exposes view and methods to drive the doodle.
@property(nonatomic, weak) id<LogoVendor> logoVendor;
// |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;
// The number of tabs to show in the google landing fake toolbar.
@property(nonatomic, assign) int tabCount;
@property(nonatomic, strong) ContentSuggestionsHeaderView* headerView;
@property(nonatomic, strong) UIButton* fakeOmnibox;
@property(nonatomic, strong) UIButton* accessibilityButton;
@property(nonatomic, strong) UIButton* identityDiscButton;
@property(nonatomic, strong) NSLayoutConstraint* doodleHeightConstraint;
@property(nonatomic, strong) NSLayoutConstraint* doodleTopMarginConstraint;
@property(nonatomic, strong) NSLayoutConstraint* fakeOmniboxWidthConstraint;
@property(nonatomic, strong) NSLayoutConstraint* fakeOmniboxHeightConstraint;
@property(nonatomic, strong) NSLayoutConstraint* fakeOmniboxTopMarginConstraint;
@property(nonatomic, assign) BOOL logoFetched;
@end
@implementation ContentSuggestionsHeaderViewController
@synthesize dispatcher = _dispatcher;
@synthesize delegate = _delegate;
@synthesize commandHandler = _commandHandler;
@synthesize collectionSynchronizer = _collectionSynchronizer;
@synthesize readingListModel = _readingListModel;
@synthesize toolbarDelegate = _toolbarDelegate;
@synthesize logoVendor = _logoVendor;
@synthesize promoCanShow = _promoCanShow;
@synthesize canGoForward = _canGoForward;
@synthesize canGoBack = _canGoBack;
@synthesize showing = _showing;
@synthesize omniboxFocused = _omniboxFocused;
@synthesize tabCount = _tabCount;
@synthesize headerView = _headerView;
@synthesize fakeOmnibox = _fakeOmnibox;
@synthesize accessibilityButton = _accessibilityButton;
@synthesize doodleHeightConstraint = _doodleHeightConstraint;
@synthesize doodleTopMarginConstraint = _doodleTopMarginConstraint;
@synthesize fakeOmniboxWidthConstraint = _fakeOmniboxWidthConstraint;
@synthesize fakeOmniboxHeightConstraint = _fakeOmniboxHeightConstraint;
@synthesize fakeOmniboxTopMarginConstraint = _fakeOmniboxTopMarginConstraint;
@synthesize voiceSearchIsEnabled = _voiceSearchIsEnabled;
@synthesize logoIsShowing = _logoIsShowing;
@synthesize logoFetched = _logoFetched;
#pragma mark - Public
- (instancetype)initWithVoiceSearchEnabled:(BOOL)voiceSearchIsEnabled {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_voiceSearchIsEnabled = voiceSearchIsEnabled;
}
return self;
}
- (UIView*)toolBarView {
return self.headerView.toolBarView;
}
- (void)willTransitionToTraitCollection:(UITraitCollection*)newCollection
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
[super willTransitionToTraitCollection:newCollection
withTransitionCoordinator:coordinator];
void (^transition)(id<UIViewControllerTransitionCoordinatorContext>) =
^(id<UIViewControllerTransitionCoordinatorContext> context) {
// Ensure omnibox is reset when not a regular tablet.
if (IsSplitToolbarMode()) {
[self.toolbarDelegate setScrollProgressForTabletOmnibox:1];
}
};
[coordinator animateAlongsideTransition:transition completion:nil];
}
- (void)dealloc {
[self.accessibilityButton removeObserver:self forKeyPath:@"highlighted"];
}
#pragma mark - ContentSuggestionsHeaderControlling
- (void)updateFakeOmniboxForOffset:(CGFloat)offset
screenWidth:(CGFloat)screenWidth
safeAreaInsets:(UIEdgeInsets)safeAreaInsets {
if (self.isShowing) {
CGFloat progress =
self.logoIsShowing || !IsRegularXRegularSizeClass()
? [self.headerView searchFieldProgressForOffset:offset
safeAreaInsets:safeAreaInsets]
// RxR with no logo hides the fakebox, so always show the omnibox.
: 1;
if (!IsSplitToolbarMode()) {
[self.toolbarDelegate setScrollProgressForTabletOmnibox:progress];
} else {
// Ensure omnibox is reset when not a regular tablet.
[self.toolbarDelegate setScrollProgressForTabletOmnibox:1];
}
}
if (self.disableScrollAnimation)
return;
[self.headerView updateSearchFieldWidth:self.fakeOmniboxWidthConstraint
height:self.fakeOmniboxHeightConstraint
topMargin:self.fakeOmniboxTopMarginConstraint
forOffset:offset
screenWidth:screenWidth
safeAreaInsets:safeAreaInsets];
}
- (void)updateFakeOmniboxForWidth:(CGFloat)width {
self.fakeOmniboxWidthConstraint.constant =
content_suggestions::searchFieldWidth(width);
}
- (void)unfocusOmnibox {
if (self.omniboxFocused) {
[self.dispatcher cancelOmniboxEdit];
} else {
[self locationBarResignsFirstResponder];
}
}
- (void)layoutHeader {
[self.headerView layoutIfNeeded];
}
// Update the doodle top margin to the new -doodleTopMargin value.
- (void)updateConstraints {
self.doodleTopMarginConstraint.constant =
content_suggestions::doodleTopMargin(YES, [self topInset]);
}
- (CGFloat)pinnedOffsetY {
CGFloat headerHeight = content_suggestions::heightForLogoHeader(
self.logoIsShowing, self.promoCanShow, YES, [self topInset]);
CGFloat offsetY =
headerHeight - ntp_header::kScrolledToTopOmniboxBottomMargin;
if (!IsRegularXRegularSizeClass(self)) {
offsetY -= ToolbarExpandedHeight(
self.traitCollection.preferredContentSizeCategory) +
[self topInset];
}
return AlignValueToPixel(offsetY);
}
- (void)loadView {
self.view = [[ContentSuggestionsHeaderView alloc] init];
}
- (CGFloat)headerHeight {
return content_suggestions::heightForLogoHeader(
self.logoIsShowing, self.promoCanShow, YES, [self topInset]);
}
#pragma mark - ContentSuggestionsHeaderProvider
- (UIView*)headerForWidth:(CGFloat)width {
if (!self.headerView) {
self.headerView =
base::mac::ObjCCastStrict<ContentSuggestionsHeaderView>(self.view);
[self addFakeTapView];
if (base::FeatureList::IsEnabled(kIdentityDisc))
[self addIdentityDisc];
[self addFakeOmnibox];
[self.headerView addSubview:self.logoVendor.view];
[self.headerView addSubview:self.fakeOmnibox];
self.logoVendor.view.translatesAutoresizingMaskIntoConstraints = NO;
self.fakeOmnibox.translatesAutoresizingMaskIntoConstraints = NO;
[self.headerView addSeparatorToSearchField:self.fakeOmnibox];
// -headerForView is regularly called before self.headerView has been added
// to the view hierarchy, so there's no simple way to get the correct
// safeAreaInsets. Since this situation is universally called for the full
// screen new tab animation, it's safe to check the rootViewController's
// view instead.
// TODO(crbug.com/791784) : Remove use of rootViewController.
UIView* insetsView = self.headerView;
if (!self.headerView.window) {
insetsView =
[[UIApplication sharedApplication] keyWindow].rootViewController.view;
}
UIEdgeInsets safeAreaInsets = insetsView.safeAreaInsets;
width = std::max<CGFloat>(
0, width - safeAreaInsets.left - safeAreaInsets.right);
self.fakeOmniboxWidthConstraint = [self.fakeOmnibox.widthAnchor
constraintEqualToConstant:content_suggestions::searchFieldWidth(width)];
[self addConstraintsForLogoView:self.logoVendor.view
fakeOmnibox:self.fakeOmnibox
andHeaderView:self.headerView];
[self.logoVendor fetchDoodle];
}
return self.headerView;
}
#pragma mark - UIViewController
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
[coordinator
animateAlongsideTransition:^(
id<UIViewControllerTransitionCoordinatorContext> context) {
[self.headerView adjustIdentityDiscConstraints];
}
completion:nil];
}
#pragma mark - Private
// Initialize and add a search field tap target and a voice search button.
- (void)addFakeOmnibox {
self.fakeOmnibox = [[UIButton alloc] init];
[self.fakeOmnibox setAdjustsImageWhenHighlighted:NO];
// Set isAccessibilityElement to NO so that Voice Search button is accessible.
[self.fakeOmnibox setIsAccessibilityElement:NO];
self.fakeOmnibox.accessibilityIdentifier =
ntp_home::FakeOmniboxAccessibilityID();
// Set a button the same size as the fake omnibox as the accessibility
// element. If the hint is the only accessible element, when the fake omnibox
// is taking the full width, there are few points that are not accessible and
// allow to select the content below it.
self.accessibilityButton = [[UIButton alloc] init];
[self.accessibilityButton addTarget:self
action:@selector(fakeboxTapped)
forControlEvents:UIControlEventTouchUpInside];
// Because the visual fakebox background is implemented within
// ContentSuggestionsHeaderView, KVO the highlight events of
// |accessibilityButton| and pass them along.
[self.accessibilityButton addObserver:self
forKeyPath:@"highlighted"
options:NSKeyValueObservingOptionNew
context:NULL];
self.accessibilityButton.isAccessibilityElement = YES;
self.accessibilityButton.accessibilityLabel =
l10n_util::GetNSString(IDS_OMNIBOX_EMPTY_HINT);
[self.fakeOmnibox addSubview:self.accessibilityButton];
self.accessibilityButton.translatesAutoresizingMaskIntoConstraints = NO;
AddSameConstraints(self.fakeOmnibox, self.accessibilityButton);
[self.headerView addViewsToSearchField:self.fakeOmnibox];
if (self.voiceSearchIsEnabled) {
[self.headerView.voiceSearchButton addTarget:self
action:@selector(loadVoiceSearch:)
forControlEvents:UIControlEventTouchUpInside];
[self.headerView.voiceSearchButton addTarget:self
action:@selector(preloadVoiceSearch:)
forControlEvents:UIControlEventTouchDown];
} else {
[self.headerView.voiceSearchButton setEnabled:NO];
}
}
- (void)addFakeTapView {
UIButton* fakeTapButton = [[UIButton alloc] init];
fakeTapButton.translatesAutoresizingMaskIntoConstraints = NO;
fakeTapButton.accessibilityLabel =
l10n_util::GetNSString(IDS_ACCNAME_LOCATION);
[self.headerView addToolbarView:fakeTapButton];
[fakeTapButton addTarget:self
action:@selector(fakeboxTapped)
forControlEvents:UIControlEventTouchUpInside];
}
- (void)addIdentityDisc {
// Set up a button. Details for the button will be set through delegate
// implementation of UserAccountImageUpdateDelegate.
self.identityDiscButton = [UIButton buttonWithType:UIButtonTypeCustom];
self.identityDiscButton.accessibilityLabel =
l10n_util::GetNSString(IDS_ACCNAME_PARTICLE_DISC);
self.identityDiscButton.imageEdgeInsets = UIEdgeInsetsMake(
ntp_home::kIdentityAvatarMargin, ntp_home::kIdentityAvatarMargin,
ntp_home::kIdentityAvatarMargin, ntp_home::kIdentityAvatarMargin);
// TODO(crbug.com/961120): Set action on button to launch into Settings.
[self.headerView setIdentityDiscView:self.identityDiscButton];
// Register to receive the avatar of the currently signed in user.
[self.delegate registerImageUpdater:self];
}
- (void)loadVoiceSearch:(id)sender {
[self.commandHandler dismissModals];
if ([self.delegate ignoreLoadRequests])
return;
DCHECK(self.voiceSearchIsEnabled);
base::RecordAction(UserMetricsAction("MobileNTPMostVisitedVoiceSearch"));
UIView* voiceSearchButton = base::mac::ObjCCastStrict<UIView>(sender);
if (base::ios::IsRunningOnIOS12OrLater()) {
[NamedGuide guideWithName:kVoiceSearchButtonGuide view:voiceSearchButton]
.constrainedView = voiceSearchButton;
} else {
// On iOS 11 and below, constraining the layout guide to a view instead of
// using frame freeze the app. The root cause wasn't found. See
// https://crbug.com/874017.
NamedGuide* voiceSearchGuide =
[NamedGuide guideWithName:kVoiceSearchButtonGuide
view:voiceSearchButton];
voiceSearchGuide.constrainedFrame =
[voiceSearchGuide.owningView convertRect:voiceSearchButton.bounds
fromView:voiceSearchButton];
}
[self.dispatcher startVoiceSearch];
}
- (void)preloadVoiceSearch:(id)sender {
DCHECK(self.voiceSearchIsEnabled);
[sender removeTarget:self
action:@selector(preloadVoiceSearch:)
forControlEvents:UIControlEventTouchDown];
[self.dispatcher preloadVoiceSearch];
}
- (void)fakeboxTapped {
if ([self.delegate ignoreLoadRequests])
return;
base::RecordAction(base::UserMetricsAction("MobileFakeboxNTPTapped"));
[self focusFakebox];
}
- (void)focusFakebox {
if ([self.delegate ignoreLoadRequests])
return;
[self shiftTilesUp];
}
// TODO(crbug.com/807330) The fakebox is currently a collection of views spread
// between ContentSuggestionsHeaderViewController and inside
// ContentSuggestionsHeaderView. Post refresh this can be coalesced into one
// control, and the KVO highlight logic below can be removed.
- (void)observeValueForKeyPath:(NSString*)keyPath
ofObject:(id)object
change:(NSDictionary*)change
context:(void*)context {
if ([@"highlighted" isEqualToString:keyPath])
[self.headerView setFakeboxHighlighted:[object isHighlighted]];
}
// If Google is not the default search engine, hide the logo, doodle and
// fakebox. Make them appear if Google is set as default.
- (void)updateLogoAndFakeboxDisplay {
if (self.logoVendor.showingLogo != self.logoIsShowing) {
self.logoVendor.showingLogo = self.logoIsShowing;
[self.doodleHeightConstraint
setConstant:content_suggestions::doodleHeight(self.logoIsShowing)];
if (IsRegularXRegularSizeClass(self))
[self.fakeOmnibox setHidden:!self.logoIsShowing];
[self.collectionSynchronizer invalidateLayout];
}
}
// Adds the constraints for the |logoView|, the |fakeomnibox| related to the
// |headerView|. It also sets the properties constraints related to those views.
- (void)addConstraintsForLogoView:(UIView*)logoView
fakeOmnibox:(UIView*)fakeOmnibox
andHeaderView:(UIView*)headerView {
self.doodleTopMarginConstraint = [logoView.topAnchor
constraintEqualToAnchor:headerView.topAnchor
constant:content_suggestions::doodleTopMargin(
YES, [self topInset])];
self.doodleHeightConstraint = [logoView.heightAnchor
constraintEqualToConstant:content_suggestions::doodleHeight(
self.logoIsShowing)];
self.fakeOmniboxHeightConstraint = [fakeOmnibox.heightAnchor
constraintEqualToConstant:ToolbarExpandedHeight(
self.traitCollection
.preferredContentSizeCategory)];
self.fakeOmniboxTopMarginConstraint = [logoView.bottomAnchor
constraintEqualToAnchor:fakeOmnibox.topAnchor
constant:-content_suggestions::searchFieldTopMargin()];
self.doodleTopMarginConstraint.active = YES;
self.doodleHeightConstraint.active = YES;
self.fakeOmniboxWidthConstraint.active = YES;
self.fakeOmniboxHeightConstraint.active = YES;
self.fakeOmniboxTopMarginConstraint.active = YES;
[logoView.widthAnchor constraintEqualToAnchor:headerView.widthAnchor].active =
YES;
[logoView.leadingAnchor constraintEqualToAnchor:headerView.leadingAnchor]
.active = YES;
[fakeOmnibox.centerXAnchor constraintEqualToAnchor:headerView.centerXAnchor]
.active = YES;
}
- (void)shiftTilesDown {
if (IsSplitToolbarMode()) {
[self.dispatcher onFakeboxBlur];
}
[self.collectionSynchronizer shiftTilesDown];
[self.commandHandler dismissModals];
}
- (void)shiftTilesUp {
if (self.disableScrollAnimation)
return;
void (^animations)() = nil;
if (![self.delegate isScrolledToTop]) {
// Only trigger the fake omnibox animation if the header isn't scrolled to
// the top. Otherwise just rely on the normal animation.
self.disableScrollAnimation = YES;
[self.dispatcher focusOmniboxNoAnimation];
NamedGuide* omniboxGuide = [NamedGuide guideWithName:kOmniboxGuide
view:self.headerView];
// Layout the owning view to make sure that the constrains are applied.
[omniboxGuide.owningView layoutIfNeeded];
self.headerView.omnibox.hidden = NO;
self.headerView.cancelButton.hidden = NO;
self.headerView.omnibox.alpha = 0;
self.headerView.cancelButton.alpha = 0;
animations = ^{
// Make sure that the offset is after the pinned offset to have the fake
// omnibox taking the full width.
CGFloat offset = 9000;
[self.headerView
updateSearchFieldWidth:self.fakeOmniboxWidthConstraint
height:self.fakeOmniboxHeightConstraint
topMargin:self.fakeOmniboxTopMarginConstraint
forOffset:offset
screenWidth:self.headerView.bounds.size.width
safeAreaInsets:self.view.safeAreaInsets];
self.fakeOmniboxWidthConstraint.constant =
self.headerView.bounds.size.width;
[self.headerView layoutIfNeeded];
CGRect omniboxFrameInFakebox =
[[omniboxGuide owningView] convertRect:[omniboxGuide layoutFrame]
toView:self.fakeOmnibox];
self.headerView.fakeLocationBarLeadingConstraint.constant =
omniboxFrameInFakebox.origin.x;
self.headerView.fakeLocationBarTrailingConstraint.constant = -(
self.fakeOmnibox.bounds.size.width -
(omniboxFrameInFakebox.origin.x + omniboxFrameInFakebox.size.width));
self.headerView.voiceSearchButton.alpha = 0;
self.headerView.cancelButton.alpha = 0.7;
self.headerView.omnibox.alpha = 1;
self.headerView.searchHintLabel.alpha = 0;
[self.headerView layoutIfNeeded];
};
}
void (^completionBlock)() = ^{
self.headerView.omnibox.hidden = YES;
self.headerView.cancelButton.hidden = YES;
self.headerView.searchHintLabel.alpha = 1;
self.headerView.voiceSearchButton.alpha = 1;
self.disableScrollAnimation = NO;
[self.dispatcher fakeboxFocused];
if (IsSplitToolbarMode()) {
[self.dispatcher onFakeboxAnimationComplete];
}
};
[self.collectionSynchronizer shiftTilesUpWithAnimations:animations
completion:completionBlock];
}
- (CGFloat)topInset {
return self.parentViewController.view.safeAreaInsets.top;
}
#pragma mark - LogoAnimationControllerOwnerOwner
- (id<LogoAnimationControllerOwner>)logoAnimationControllerOwner {
// Only return the logo vendor's animation controller owner if the logo view
// is fully visible. This prevents the logo from being used in transition
// animations if the logo has been scrolled off screen.
UIView* logoView = self.logoVendor.view;
UIView* parentView = self.parentViewController.view;
CGRect logoFrame = [parentView convertRect:logoView.bounds fromView:logoView];
BOOL isLogoFullyVisible = CGRectEqualToRect(
CGRectIntersection(logoFrame, parentView.bounds), logoFrame);
return isLogoFullyVisible ? [self.logoVendor logoAnimationControllerOwner]
: nil;
}
#pragma mark - NTPHomeConsumer
- (void)setLogoIsShowing:(BOOL)logoIsShowing {
_logoIsShowing = logoIsShowing;
[self updateLogoAndFakeboxDisplay];
}
- (void)locationBarBecomesFirstResponder {
if (!self.isShowing)
return;
self.omniboxFocused = YES;
[self shiftTilesUp];
}
- (void)locationBarResignsFirstResponder {
if (!self.isShowing && ![self.delegate isScrolledToTop])
return;
self.omniboxFocused = NO;
if ([self.delegate isContextMenuVisible]) {
return;
}
[self shiftTilesDown];
}
#pragma mark - UserAccountImageUpdateDelegate
- (void)updateAccountImage:(UIImage*)image {
[self.identityDiscButton setImage:image forState:UIControlStateNormal];
self.identityDiscButton.imageView.layer.cornerRadius = image.size.width / 2;
self.identityDiscButton.imageView.layer.masksToBounds = YES;
}
@end