blob: 651fc01009abc3b8ac2db68ffd06b1863a45658f [file] [log] [blame]
// Copyright 2018 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/bubble/bubble_presenter.h"
#include "base/bind.h"
#include "base/metrics/user_metrics.h"
#include "base/metrics/user_metrics_action.h"
#include "components/feature_engagement/public/event_constants.h"
#include "components/feature_engagement/public/feature_constants.h"
#include "components/feature_engagement/public/tracker.h"
#include "ios/chrome/browser/browser_state/chrome_browser_state.h"
#include "ios/chrome/browser/chrome_url_constants.h"
#include "ios/chrome/browser/feature_engagement/tracker_factory.h"
#include "ios/chrome/browser/feature_engagement/tracker_util.h"
#import "ios/chrome/browser/ui/bubble/bubble_presenter_delegate.h"
#import "ios/chrome/browser/ui/bubble/bubble_util.h"
#import "ios/chrome/browser/ui/bubble/bubble_view_controller_presenter.h"
#import "ios/chrome/browser/ui/commands/toolbar_commands.h"
#import "ios/chrome/browser/ui/util/named_guide.h"
#import "ios/chrome/browser/ui/util/named_guide_util.h"
#include "ios/chrome/browser/ui/util/ui_util.h"
#import "ios/chrome/browser/ui/util/uikit_ui_util.h"
#include "ios/chrome/grit/ios_chromium_strings.h"
#include "ios/chrome/grit/ios_strings.h"
#import "ios/web/public/ui/crw_web_view_proxy.h"
#import "ios/web/public/ui/crw_web_view_scroll_view_proxy.h"
#import "ios/web/public/web_state.h"
#include "ui/base/l10n/l10n_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
const CGFloat kBubblePresentationDelay = 1;
} // namespace
@interface BubblePresenter ()
// Used to display the bottom toolbar tip in-product help promotion bubble.
// |nil| if the tip bubble has not yet been presented. Once the bubble is
// dismissed, it remains allocated so that |userEngaged| remains accessible.
@property(nonatomic, strong)
BubbleViewControllerPresenter* bottomToolbarTipBubblePresenter;
// Used to display the long press on toolbar buttons tip in-product help
// promotion bubble. |nil| if the tip bubble has not yet been presented. Once
// the bubble is dismissed, it remains allocated so that |userEngaged| remains
// accessible.
@property(nonatomic, strong)
BubbleViewControllerPresenter* longPressToolbarTipBubblePresenter;
// Used to display the new tab tip in-product help promotion bubble. |nil| if
// the new tab tip bubble has not yet been presented. Once the bubble is
// dismissed, it remains allocated so that |userEngaged| remains accessible.
@property(nonatomic, strong)
BubbleViewControllerPresenter* tabTipBubblePresenter;
@property(nonatomic, strong, readwrite)
BubbleViewControllerPresenter* incognitoTabTipBubblePresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* discoverFeedHeaderMenuTipBubblePresenter;
@property(nonatomic, assign) ChromeBrowserState* browserState;
@property(nonatomic, weak) id<BubblePresenterDelegate> delegate;
@property(nonatomic, weak) UIViewController* rootViewController;
@end
@implementation BubblePresenter
#pragma mark - Public
- (instancetype)initWithBrowserState:(ChromeBrowserState*)browserState
delegate:(id<BubblePresenterDelegate>)delegate
rootViewController:(UIViewController*)rootViewController {
self = [super init];
if (self) {
_browserState = browserState;
_delegate = delegate;
_rootViewController = rootViewController;
}
return self;
}
- (void)showHelpBubbleIfEligible {
DCHECK(self.browserState);
// Waits to present the bubbles until the feature engagement tracker database
// is fully initialized. This method requires that |self.browserState| is not
// NULL.
__weak BubblePresenter* weakSelf = self;
void (^onInitializedBlock)(bool) = ^(bool successfullyLoaded) {
if (!successfullyLoaded)
return;
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW,
(int64_t)(kBubblePresentationDelay * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[weakSelf presentBubbles];
});
};
// Because the new tab tip occurs on startup, the feature engagement
// tracker's database is not guaranteed to be loaded by this time. For the
// bubble to appear properly, a callback is used to guarantee the event data
// is loaded before the check to see if the promotion should be displayed.
feature_engagement::TrackerFactory::GetForBrowserState(self.browserState)
->AddOnInitializedCallback(base::BindRepeating(onInitializedBlock));
}
- (void)showLongPressHelpBubbleIfEligible {
DCHECK(self.browserState);
// Waits to present the bubble until the feature engagement tracker database
// is fully initialized. This method requires that |self.browserState| is not
// NULL.
__weak BubblePresenter* weakSelf = self;
void (^onInitializedBlock)(bool) = ^(bool successfullyLoaded) {
if (!successfullyLoaded)
return;
[weakSelf presentLongPressBubble];
};
// Because the new tab tip occurs on startup, the feature engagement
// tracker's database is not guaranteed to be loaded by this time. For the
// bubble to appear properly, a callback is used to guarantee the event data
// is loaded before the check to see if the promotion should be displayed.
feature_engagement::TrackerFactory::GetForBrowserState(self.browserState)
->AddOnInitializedCallback(base::BindRepeating(onInitializedBlock));
}
- (void)hideAllHelpBubbles {
[self.tabTipBubblePresenter dismissAnimated:NO];
[self.incognitoTabTipBubblePresenter dismissAnimated:NO];
[self.bottomToolbarTipBubblePresenter dismissAnimated:NO];
[self.longPressToolbarTipBubblePresenter dismissAnimated:NO];
[self.discoverFeedHeaderMenuTipBubblePresenter dismissAnimated:NO];
}
- (void)userEnteredTabSwitcher {
if ([self.tabTipBubblePresenter isUserEngaged]) {
base::RecordAction(base::UserMetricsAction("NewTabTipTargetSelected"));
}
}
- (void)toolsMenuDisplayed {
if (self.incognitoTabTipBubblePresenter.isUserEngaged) {
base::RecordAction(
base::UserMetricsAction("NewIncognitoTabTipTargetSelected"));
}
}
- (void)presentDiscoverFeedHeaderTipBubble {
BubbleArrowDirection arrowDirection = BubbleArrowDirectionDown;
NSString* text =
l10n_util::GetNSStringWithFixup(IDS_IOS_DISCOVER_FEED_HEADER_IPH);
NamedGuide* guide = [NamedGuide guideWithName:kDiscoverFeedHeaderMenuGuide
view:self.rootViewController.view];
DCHECK(guide);
UIView* menuButton = guide.constrainedView;
// Checks "canPresentBubble" after checking that the NTP with feed is visible.
// This ensures that the feature tracker doesn't trigger the IPH event if the
// bubble isn't shown, which would prevent it from ever being shown again.
if (!menuButton || ![self canPresentBubble]) {
return;
}
CGPoint discoverFeedHeaderAnchor =
[menuButton.superview convertPoint:menuButton.frame.origin toView:nil];
discoverFeedHeaderAnchor.x += menuButton.frame.size.width / 2;
// If the feature engagement tracker does not consider it valid to display
// the new tab tip, then end early to prevent the potential reassignment
// of the existing |tabTipBubblePresenter| to nil.
BubbleViewControllerPresenter* presenter = [self
presentBubbleForFeature:feature_engagement::kIPHDiscoverFeedHeaderFeature
direction:arrowDirection
alignment:BubbleAlignmentTrailing
text:text
voiceOverAnnouncement:nil
anchorPoint:discoverFeedHeaderAnchor];
if (!presenter)
return;
self.discoverFeedHeaderMenuTipBubblePresenter = presenter;
}
#pragma mark - Private
- (void)presentBubbles {
// If the tip bubble has already been presented and the user is still
// considered engaged, it can't be overwritten or set to |nil| or else it will
// reset the |userEngaged| property. Once the user is not engaged, the bubble
// can be safely overwritten or set to |nil|.
if (!self.tabTipBubblePresenter.isUserEngaged)
[self presentNewTabTipBubble];
if (!self.incognitoTabTipBubblePresenter.isUserEngaged)
[self presentNewIncognitoTabTipBubble];
// The bottom toolbar and Discover feed header menu don't use the
// isUserEngaged, so don't check if the user is engaged here.
[self presentBottomToolbarTipBubble];
}
- (void)presentLongPressBubble {
if (self.longPressToolbarTipBubblePresenter.isUserEngaged)
return;
if (![self canPresentBubble])
return;
BubbleArrowDirection arrowDirection =
IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
: BubbleArrowDirectionUp;
NSString* text =
l10n_util::GetNSString(IDS_IOS_LONG_PRESS_TOOLBAR_IPH_PROMOTION_TEXT);
CGPoint tabGridButtonAnchor = [self anchorPointToGuide:kTabSwitcherGuide
direction:arrowDirection];
// If the feature engagement tracker does not consider it valid to display
// the tip, then end early to prevent the potential reassignment of the
// existing |longPressToolbarTipBubblePresenter| to nil.
BubbleViewControllerPresenter* presenter = [self
presentBubbleForFeature:feature_engagement::kIPHLongPressToolbarTipFeature
direction:arrowDirection
alignment:BubbleAlignmentTrailing
text:text
voiceOverAnnouncement:
l10n_util::GetNSString(
IDS_IOS_LONG_PRESS_TOOLBAR_IPH_PROMOTION_VOICE_OVER)
anchorPoint:tabGridButtonAnchor];
if (!presenter)
return;
self.longPressToolbarTipBubblePresenter = presenter;
}
// Presents and returns a bubble view controller for the |feature| with an arrow
// |direction|, an arrow |alignment| and a |text| on an |anchorPoint|.
- (BubbleViewControllerPresenter*)
presentBubbleForFeature:(const base::Feature&)feature
direction:(BubbleArrowDirection)direction
alignment:(BubbleAlignment)alignment
text:(NSString*)text
voiceOverAnnouncement:(NSString*)voiceOverAnnouncement
anchorPoint:(CGPoint)anchorPoint {
BubbleViewControllerPresenter* presenter =
[self bubblePresenterForFeature:feature
direction:direction
alignment:alignment
text:text];
presenter.voiceOverAnnouncement = voiceOverAnnouncement;
[presenter presentInViewController:self.rootViewController
view:self.rootViewController.view
anchorPoint:anchorPoint];
return presenter;
}
// Presents a bubble associated with the bottom toolbar tip in-product help
// promotion. This method requires that |self.browserState| is not NULL.
- (void)presentBottomToolbarTipBubble {
if (!IsSplitToolbarMode(self.rootViewController))
return;
if (![self canPresentBubble])
return;
BubbleArrowDirection arrowDirection = BubbleArrowDirectionDown;
NSString* text = l10n_util::GetNSStringWithFixup(
IDS_IOS_BOTTOM_TOOLBAR_IPH_PROMOTION_TEXT);
CGPoint newTabButtonAnchor = [self anchorPointToGuide:kNewTabButtonGuide
direction:arrowDirection];
// If the feature engagement tracker does not consider it valid to display
// the tip, then end early to prevent the potential reassignment of the
// existing |bottomToolbarTipBubblePresenter| to nil.
BubbleViewControllerPresenter* presenter = [self
presentBubbleForFeature:feature_engagement::kIPHBottomToolbarTipFeature
direction:arrowDirection
alignment:BubbleAlignmentCenter
text:text
voiceOverAnnouncement:
l10n_util::GetNSString(
IDS_IOS_BOTTOM_TOOLBAR_IPH_PROMOTION_VOICE_OVER)
anchorPoint:newTabButtonAnchor];
if (!presenter)
return;
self.bottomToolbarTipBubblePresenter = presenter;
feature_engagement::TrackerFactory::GetForBrowserState(self.browserState)
->NotifyEvent(feature_engagement::events::kBottomToolbarOpened);
}
// Optionally presents a bubble associated with the new tab tip in-product help
// promotion. If the feature engagement tracker determines it is valid to show
// the new tab tip, then it initializes |tabTipBubblePresenter| and presents
// the bubble. If it is not valid to show the new tab tip,
// |tabTipBubblePresenter| is set to |nil| and no bubble is shown. This method
// requires that |self.browserState| is not NULL.
- (void)presentNewTabTipBubble {
if (![self canPresentBubble])
return;
// Do not present the new tab tips on NTP.
if (![self.delegate currentWebStateForBubblePresenter:self] ||
[self.delegate currentWebStateForBubblePresenter:self]->GetVisibleURL() ==
kChromeUINewTabURL)
return;
BubbleArrowDirection arrowDirection =
IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
: BubbleArrowDirectionUp;
NSString* text =
l10n_util::GetNSStringWithFixup(IDS_IOS_NEW_TAB_IPH_PROMOTION_TEXT);
CGPoint tabSwitcherAnchor = [self anchorPointToGuide:kTabSwitcherGuide
direction:arrowDirection];
// If the feature engagement tracker does not consider it valid to display
// the new tab tip, then end early to prevent the potential reassignment
// of the existing |tabTipBubblePresenter| to nil.
BubbleViewControllerPresenter* presenter =
[self presentBubbleForFeature:feature_engagement::kIPHNewTabTipFeature
direction:arrowDirection
alignment:BubbleAlignmentTrailing
text:text
voiceOverAnnouncement:nil
anchorPoint:tabSwitcherAnchor];
if (!presenter)
return;
self.tabTipBubblePresenter = presenter;
}
// Presents a bubble associated with the new incognito tab tip in-product help
// promotion. This method requires that |self.browserState| is not NULL.
- (void)presentNewIncognitoTabTipBubble {
if (![self canPresentBubble])
return;
BubbleArrowDirection arrowDirection =
IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
: BubbleArrowDirectionUp;
NSString* text = l10n_util::GetNSStringWithFixup(
IDS_IOS_NEW_INCOGNITO_TAB_IPH_PROMOTION_TEXT);
CGPoint toolsButtonAnchor =
[self anchorPointToGuide:kToolsMenuGuide direction:arrowDirection];
// If the feature engagement tracker does not consider it valid to display
// the incognito tab tip, then end early to prevent the potential reassignment
// of the existing |incognitoTabTipBubblePresenter| to nil.
BubbleViewControllerPresenter* presenter = [self
presentBubbleForFeature:feature_engagement::kIPHNewIncognitoTabTipFeature
direction:arrowDirection
alignment:BubbleAlignmentTrailing
text:text
voiceOverAnnouncement:nil
anchorPoint:toolsButtonAnchor];
if (!presenter)
return;
self.incognitoTabTipBubblePresenter = presenter;
[self.toolbarHandler triggerToolsMenuButtonAnimation];
}
#pragma mark - Private Utils
// Returns the anchor point for a bubble with an |arrowDirection| pointing to a
// |guideName|. The point is in the window coordinates.
- (CGPoint)anchorPointToGuide:(GuideName*)guideName
direction:(BubbleArrowDirection)arrowDirection {
UILayoutGuide* guide =
[NamedGuide guideWithName:guideName view:self.rootViewController.view];
DCHECK(guide);
CGPoint anchorPoint =
bubble_util::AnchorPoint(guide.layoutFrame, arrowDirection);
return [guide.owningView convertPoint:anchorPoint
toView:guide.owningView.window];
}
// Returns whether the tab can present a bubble tip.
- (BOOL)canPresentBubble {
DCHECK(self.browserState);
// If the BVC is not visible, do not present the bubble.
if (![self.delegate rootViewVisibleForBubblePresenter:self])
return NO;
// Do not present the bubble if there is no current tab.
web::WebState* currentWebState =
[self.delegate currentWebStateForBubblePresenter:self];
if (!currentWebState)
return NO;
// Do not present the bubble if the tab is not scrolled to the top.
if (![self.delegate isTabScrolledToTopForBubblePresenter:self])
return NO;
return YES;
}
// Returns a bubble associated with an in-product help promotion if
// it is valid to show the promotion and |nil| otherwise. |feature| is the
// base::Feature object associated with the given promotion. |direction| is the
// direction the bubble's arrow is pointing. |alignment| is the alignment of the
// arrow on the button. |text| is the text displayed by the bubble. This method
// requires that |self.browserState| is not NULL.
- (BubbleViewControllerPresenter*)
bubblePresenterForFeature:(const base::Feature&)feature
direction:(BubbleArrowDirection)direction
alignment:(BubbleAlignment)alignment
text:(NSString*)text {
DCHECK(self.browserState);
if (!feature_engagement::TrackerFactory::GetForBrowserState(self.browserState)
->ShouldTriggerHelpUI(feature)) {
return nil;
}
// Capture |weakSelf| instead of the feature engagement tracker object
// because |weakSelf| will safely become |nil| if it is deallocated, whereas
// the feature engagement tracker will remain pointing to invalid memory if
// its owner (the ChromeBrowserState) is deallocated.
__weak BubblePresenter* weakSelf = self;
void (^dismissalCallback)(void) = ^{
BubblePresenter* strongSelf = weakSelf;
if (strongSelf) {
feature_engagement::TrackerFactory::GetForBrowserState(
strongSelf.browserState)
->Dismissed(feature);
}
};
BubbleViewControllerPresenter* bubbleViewControllerPresenter =
[[BubbleViewControllerPresenter alloc] initWithText:text
arrowDirection:direction
alignment:alignment
dismissalCallback:dismissalCallback];
return bubbleViewControllerPresenter;
}
@end