910e62b5创建于 1月15日历史提交
// Copyright 2018 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/bubble/ui_bundled/bubble_presenter.h"

#import "base/apple/foundation_util.h"
#import "base/functional/bind.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "components/content_settings/core/browser/host_content_settings_map.h"
#import "components/feature_engagement/public/event_constants.h"
#import "components/feature_engagement/public/feature_constants.h"
#import "components/feature_engagement/public/tracker.h"
#import "components/omnibox/browser/omnibox_event_global_tracker.h"
#import "components/omnibox/browser/omnibox_pref_names.h"
#import "components/prefs/pref_service.h"
#import "components/segmentation_platform/embedder/default_model/device_switcher_result_dispatcher.h"
#import "ios/chrome/browser/bubble/model/utils.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_constants.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_presenter_delegate.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_util.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_view_controller_presenter.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_view.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_view_delegate.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/toolbar_swipe_gesture_in_product_help_view.h"
#import "ios/chrome/browser/feature_engagement/model/tracker_factory.h"
#import "ios/chrome/browser/fullscreen/ui_bundled/animated_scoped_fullscreen_disabler.h"
#import "ios/chrome/browser/fullscreen/ui_bundled/fullscreen_controller.h"
#import "ios/chrome/browser/ntp/shared/metrics/feed_metrics_recorder.h"
#import "ios/chrome/browser/overlays/model/public/overlay_presenter.h"
#import "ios/chrome/browser/overlays/model/public/overlay_presenter_observer_bridge.h"
#import "ios/chrome/browser/segmentation_platform/model/segmentation_platform_service_factory.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/url/chrome_url_constants.h"
#import "ios/chrome/browser/shared/model/url/url_util.h"
#import "ios/chrome/browser/shared/model/utils/first_run_util.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/commands/page_action_menu_entry_point_commands.h"
#import "ios/chrome/browser/shared/public/commands/popup_menu_commands.h"
#import "ios/chrome/browser/shared/public/commands/tab_strip_commands.h"
#import "ios/chrome/browser/shared/public/commands/toolbar_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/public/features/system_flags.h"
#import "ios/chrome/browser/shared/ui/util/layout_guide_names.h"
#import "ios/chrome/browser/shared/ui/util/named_guide.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/shared/ui/util/util_swift.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"
#import "ios/chrome/grit/ios_branded_strings.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/web/public/navigation/navigation_manager.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"
#import "ui/base/device_form_factor.h"
#import "ui/base/l10n/l10n_util.h"

namespace {

// Returns whether `view` could display and animate correctly within `guide`. If
// NO, elements in `view` may be hidden or overlap with each other during the
// animation.
BOOL CanGestureInProductHelpViewFitInGuide(GestureInProductHelpView* view,
                                           UILayoutGuide* guide) {
  CGSize guide_size = guide.layoutFrame.size;
  CGSize view_fitting_size =
      [view systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
  return view_fitting_size.width <= guide_size.width &&
         view_fitting_size.height <= guide_size.height;
}

}  // namespace

@interface BubblePresenter () <GestureInProductHelpViewDelegate,
                               OverlayPresenterObserving>

@end

@implementation BubblePresenter {
  // Required dependencies.
  LayoutGuideCenter* _layoutGuideCenter;
  raw_ptr<WebStateList> _webStateList;
  raw_ptr<feature_engagement::Tracker> _engagementTracker;

  // Overlay observing.
  raw_ptr<OverlayPresenter> _webContentOverlayPresenter;
  raw_ptr<OverlayPresenter> _infobarBannerPresenter;
  raw_ptr<OverlayPresenter> _infobarModalPresenter;
  std::unique_ptr<OverlayPresenterObserver> _overlayPresenterObserver;

  // Whether the presenter is started.
  BOOL _started;

  // The fullscreen controller and disabler to block fullscreen momentarily for
  // some bubbles while they present.
  raw_ptr<FullscreenController> _fullscreenController;
  std::unique_ptr<AnimatedScopedFullscreenDisabler> _animatedFullscreenDisabler;

  // List of existing bubble view presenters.
  BubbleViewControllerPresenter* _bottomToolbarTipBubblePresenter;
  BubbleViewControllerPresenter* _discoverFeedHeaderMenuTipBubblePresenter;
  BubbleViewControllerPresenter* _homeCustomizationMenuTipBubblePresenter;
  BubbleViewControllerPresenter* _readingListTipBubblePresenter;
  BubbleViewControllerPresenter* _defaultPageModeTipBubblePresenter;
  BubbleViewControllerPresenter* _whatsNewBubblePresenter;
  BubbleViewControllerPresenter*
      _priceNotificationsWhileBrowsingBubbleTipPresenter;
  BubbleViewControllerPresenter* _lensKeyboardPresenter;
  BubbleViewControllerPresenter* _lensOverlayEntrypointBubblePresenter;
  BubbleViewControllerPresenter* _settingsInOverflowMenuBubblePresenter;
  BubbleViewControllerPresenter*
      _switchAccountWithNTPIdentityDiscBubblePresenter;
  BubbleViewControllerPresenter* _feedSwipeBubblePresenter;
  BubbleViewControllerPresenter* _pageActionMenuBubblePresenter;
  BubbleViewControllerPresenter* _readerModeOptionsBubblePresenter;

  // List of existing gestural IPH views.
  GestureInProductHelpView* _pullToRefreshGestureIPH;
  GestureInProductHelpView* _swipeBackForwardGestureIPH;
  ToolbarSwipeGestureInProductHelpView* _toolbarSwipeGestureIPH;
}

- (instancetype)
        initWithLayoutGuideCenter:(LayoutGuideCenter*)layoutGuideCenter
                engagementTracker:
                    (raw_ptr<feature_engagement::Tracker>)engagementTracker
                     webStateList:(raw_ptr<WebStateList>)webStateList
             fullscreenController:
                 (raw_ptr<FullscreenController>)fullscreenController
    overlayPresenterForWebContent:
        (raw_ptr<OverlayPresenter>)webContentOverlayPresenter
                    infobarBanner:(raw_ptr<OverlayPresenter>)bannerPresenter
                     infobarModal:(raw_ptr<OverlayPresenter>)modalPresenter {
  self = [super init];
  if (self) {
    CHECK(webStateList);

    _layoutGuideCenter = layoutGuideCenter;
    _engagementTracker = engagementTracker;
    _webStateList = webStateList;
    _fullscreenController = fullscreenController;

    _overlayPresenterObserver =
        std::make_unique<OverlayPresenterObserverBridge>(self);

    // Set and observe overlay presenters.
    if (webContentOverlayPresenter) {
      CHECK(webContentOverlayPresenter->GetModality() ==
            OverlayModality::kWebContentArea);
      _webContentOverlayPresenter = webContentOverlayPresenter;
      _webContentOverlayPresenter->AddObserver(_overlayPresenterObserver.get());
    }
    if (bannerPresenter) {
      CHECK(bannerPresenter->GetModality() == OverlayModality::kInfobarBanner);
      _infobarBannerPresenter = bannerPresenter;
      _infobarBannerPresenter->AddObserver(_overlayPresenterObserver.get());
    }
    if (modalPresenter) {
      CHECK(modalPresenter->GetModality() == OverlayModality::kInfobarModal);
      _infobarModalPresenter = modalPresenter;
      _infobarModalPresenter->AddObserver(_overlayPresenterObserver.get());
    }

    _started = YES;
  }
  return self;
}

- (void)disconnect {
  _started = NO;
  [self disconnectOverlayPresenters];
  _webStateList = nullptr;
  _engagementTracker = nullptr;
}

- (void)hideAllHelpBubbles {
  [_bottomToolbarTipBubblePresenter dismissAnimated:NO];
  [_discoverFeedHeaderMenuTipBubblePresenter dismissAnimated:NO];
  [_homeCustomizationMenuTipBubblePresenter dismissAnimated:NO];
  [_readingListTipBubblePresenter dismissAnimated:NO];
  [_priceNotificationsWhileBrowsingBubbleTipPresenter dismissAnimated:NO];
  [_whatsNewBubblePresenter dismissAnimated:NO];
  [_lensKeyboardPresenter dismissAnimated:NO];
  [_defaultPageModeTipBubblePresenter dismissAnimated:NO];
  [_lensOverlayEntrypointBubblePresenter dismissAnimated:NO];
  [_pageActionMenuBubblePresenter dismissAnimated:NO];
  [_readerModeOptionsBubblePresenter dismissAnimated:NO];
  [self hideAllGestureInProductHelpViewsForReason:IPHDismissalReasonType::
                                                      kUnknown];
}

- (void)hideBubblesPointingToOmnibox {
  [_lensOverlayEntrypointBubblePresenter dismissAnimated:NO];
}

- (void)handleTapOutsideOfVisibleGestureInProductHelp {
  [self hideAllGestureInProductHelpViewsForReason:
            IPHDismissalReasonType::kTappedOutsideIPHAndAnchorView];
}

- (void)handleToolbarSwipeGesture {
  [_toolbarSwipeGestureIPH
      dismissWithReason:IPHDismissalReasonType::
                            kSwipedAsInstructedByGestureIPH];
}

#pragma mark - Bubble presenter methods

- (void)presentDiscoverFeedMenuTipBubble {
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_DISCOVER_FEED_HEADER_IPH);

  UIView* menuButton =
      [_layoutGuideCenter referencedViewUnderName:kFeedIPHNamedGuide];
  // 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 discoverFeedMenuAnchor =
      [menuButton.superview convertPoint:menuButton.frame.origin toView:nil];

  // Slightly move IPH to ensure that the bubble doesn't bleed out the screen.
  discoverFeedMenuAnchor.x += menuButton.frame.size.width / 2;
  discoverFeedMenuAnchor.y += menuButton.frame.size.height;

  // 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 `discoverFeedHeaderMenuTipBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHDiscoverFeedHeaderFeature
                    direction:BubbleArrowDirectionUp
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:discoverFeedMenuAnchor];
  if (!presenter) {
    return;
  }

  _discoverFeedHeaderMenuTipBubblePresenter = presenter;
}

- (void)presentHomeCustomizationTipBubble {
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_HOME_CUSTOMIZATION_IPH);

  UIView* menuButton =
      [_layoutGuideCenter referencedViewUnderName:kFeedIPHNamedGuide];
  // 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 customizationMenuAnchor =
      [menuButton.superview convertPoint:menuButton.frame.origin toView:nil];

  // Slightly move IPH to ensure that the bubble doesn't bleed out the screen.
  customizationMenuAnchor.x += menuButton.frame.size.width / 2;
  customizationMenuAnchor.y += menuButton.frame.size.height;

  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:feature_engagement::
                                        kIPHHomeCustomizationMenuFeature
                          direction:BubbleArrowDirectionUp
                          alignment:BubbleAlignmentTopOrLeading
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:customizationMenuAnchor];
  if (!presenter) {
    return;
  }

  _homeCustomizationMenuTipBubblePresenter = presenter;
}

- (void)presentDefaultSiteViewTipBubbleWithSettingsMap:
            (raw_ptr<HostContentSettingsMap>)settingsMap
                                      popupMenuHandler:(id<PopupMenuCommands>)
                                                           popupMenuHandler {
  if (![self canPresentBubble]) {
    return;
  }
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!currentWebState || ShouldLoadUrlInDesktopMode(
                              currentWebState->GetVisibleURL(), settingsMap)) {
    return;
  }

  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(IDS_IOS_DEFAULT_PAGE_MODE_TIP);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           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 presenter to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHDefaultSiteViewFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentBottomOrTrailing
                         text:text
        voiceOverAnnouncement:l10n_util::GetNSString(
                                  IDS_IOS_DEFAULT_PAGE_MODE_TIP_VOICE_OVER)
                  anchorPoint:toolsMenuAnchor];
  if (!presenter) {
    return;
  }
  [popupMenuHandler notifyIPHBubblePresenting];
  _defaultPageModeTipBubblePresenter = presenter;
}

- (void)presentWhatsNewBottomToolbarBubbleWithPopupMenuHandler:
    (id<PopupMenuCommands>)popupMenuHandler {
  if (![self canPresentBubble]) {
    return;
  }
  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(IDS_IOS_WHATS_NEW_IPH_TEXT);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           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 `whatsNewBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHWhatsNewFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentBottomOrTrailing
                         text:text
        voiceOverAnnouncement:l10n_util::GetNSString(IDS_IOS_WHATS_NEW_IPH_TEXT)
                  anchorPoint:toolsMenuAnchor];
  if (presenter) {
    [popupMenuHandler notifyIPHBubblePresenting];
    _whatsNewBubblePresenter = presenter;
  }
}

- (void)presentPriceNotificationsWhileBrowsingTipBubbleWithPopupMenuHandler:
    (id<PopupMenuCommands>)popupMenuHandler {
  if (![self canPresentBubble]) {
    return;
  }
  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(
      IDS_IOS_PRICE_NOTIFICATIONS_PRICE_TRACK_TOAST_IPH_TEXT);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           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 `whatsNewBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:
                feature_engagement::kIPHPriceNotificationsWhileBrowsingFeature
                          direction:arrowDirection
                          alignment:BubbleAlignmentBottomOrTrailing
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:toolsMenuAnchor];
  if (presenter) {
    [popupMenuHandler notifyIPHBubblePresenting];
    _priceNotificationsWhileBrowsingBubbleTipPresenter = presenter;
  }
}

- (void)presentLensKeyboardTipBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  BubbleArrowDirection arrowDirection = BubbleArrowDirectionDown;
  NSString* text = l10n_util::GetNSString(IDS_IOS_LENS_KEYBOARD_IPH_TEXT);
  CGPoint lensButtonAnchor = [self anchorPointToGuide:kLensKeyboardButtonGuide
                                            direction:arrowDirection];

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHiOSLensKeyboardFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:lensButtonAnchor];
  if (presenter) {
    _lensKeyboardPresenter = presenter;
  }
}

- (void)presentLensOverlayTipBubble {
  if (![self canPresentBubble]) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  BOOL isBottomOmnibox = IsBottomOmniboxAvailable() &&
                         GetApplicationContext()->GetLocalState()->GetBoolean(
                             omnibox::kIsOmniboxInBottomPosition);
  BubbleArrowDirection arrowDirection =
      isBottomOmnibox ? BubbleArrowDirectionDown : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(IDS_IOS_LENS_OVERLAY_TOOLTIP_TEXT);

  CGPoint lensOverlayEntrypointAnchor =
      [self anchorPointToGuide:kLensOverlayEntrypointGuide
                     direction:arrowDirection];
  // To prevent the bubble from extending beyond the screen's edge, an offset is
  // added, with the anchor point positioned at the top left corner.
  // TODO(crbug.com/365049480): Remove this offset once the bubble view margins
  // are fixed.
  CGFloat anchorXOffset = UseRTLLayout() ? -2 : 2;

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::
                                  kIPHiOSLensOverlayEntrypointTipFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:CGPoint(
                                  lensOverlayEntrypointAnchor.x + anchorXOffset,
                                  lensOverlayEntrypointAnchor.y)];

  if (presenter) {
    _lensOverlayEntrypointBubblePresenter = presenter;
  }
}

- (void)presentOverflowMenuSettingsBubble {
  if (![self canPresentBubble]) {
    return;
  }

  // Only show on top of the NTP:
  // If the user had opened the account menu from the NTP, chances are they were
  // looking for settings (because that's where the account particle disc used
  // to link), so this IPH will help them.
  // If they had opened the account menu from anywhere else, there's no
  // connection to settings, and so this IPH wouldn't make sense.
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text =
      l10n_util::GetNSString(IDS_IOS_SETTINGS_IN_OVERFLOW_MENU_IPH_TEXT);

  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the IPH, then end early to prevent the potential reassignment of the
  // existing presenter to nil.
  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:
                feature_engagement::kIPHiOSSettingsInOverflowMenuBubbleFeature
                          direction:arrowDirection
                          alignment:BubbleAlignmentBottomOrTrailing
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:toolsMenuAnchor];
  if (presenter) {
    _settingsInOverflowMenuBubblePresenter = presenter;
  }
}

- (void)presentSwitchAccountsWithNTPAccountParticleDiscBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:YES]) {
    return;
  }

  // Only show if the user has previously used a web-triggered flow to switch
  // accounts in Chrome.
  // Note: This condition can't be handled by the `feature_engagement::Tracker`
  // internally, because it's per-device while the tracker is per-profile.
  if (!GetApplicationContext()->GetLocalState()->GetBoolean(
          prefs::kHasSwitchedAccountsViaWebFlow)) {
    return;
  }

  BubbleArrowDirection arrowDirection = BubbleArrowDirectionUp;

  CGPoint identityDiscAnchor =
      [self anchorPointToGuide:kNTPIdentityDiscButtonGuide
                     direction:arrowDirection];

  // The identity disc button is slightly larger than it visually appears, so
  // move the bubble a bit closer.
  CGFloat anchorYOffset = -8;

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:
          feature_engagement::
              kIPHiOSSwitchAccountsWithNTPAccountParticleDiscFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentBottomOrTrailing
                         text:l10n_util::GetNSString(
                                  IDS_IOS_SWITCH_ACCOUNTS_IPH_MESSAGE)
        voiceOverAnnouncement:
            l10n_util::GetNSString(
                IDS_IOS_SWITCH_ACCOUNTS_IPH_ACCESSIBILITY_LABEL)
                  anchorPoint:CGPoint(identityDiscAnchor.x,
                                      identityDiscAnchor.y + anchorYOffset)];
  if (presenter) {
    _switchAccountWithNTPIdentityDiscBubblePresenter = presenter;
  }
}

- (void)
    presentPullToRefreshGestureInProductHelpWithDeviceSwitcherResultDispatcher:
        (raw_ptr<segmentation_platform::DeviceSwitcherResultDispatcher>)
            deviceSwitcherResultDispatcher {
  if (UIAccessibilityIsVoiceOverRunning() ||
      (![self.delegate isOverscrollActionsSupportedForBubblePresenter:self]) ||
      (![self canPresentBubble])) {
    // TODO(crbug.com/41494458): Add voice over announcement once fixed.
    return;
  }
  const base::Feature& pullToRefreshFeature =
      feature_engagement::kIPHiOSPullToRefreshFeature;
  BOOL userEligibleForPullToRefreshIPH =
      deviceSwitcherResultDispatcher &&
      IsUserNewSafariSwitcher(deviceSwitcherResultDispatcher) &&
      _engagementTracker->WouldTriggerHelpUI(pullToRefreshFeature);
  if (!userEligibleForPullToRefreshIPH) {
    return;
  }
  NSString* text = l10n_util::GetNSString(IDS_IOS_PULL_TO_REFRESH_IPH);
  _pullToRefreshGestureIPH =
      [self presentGestureInProductHelpForFeature:pullToRefreshFeature
                                   swipeDirection:
                                       UISwipeGestureRecognizerDirectionDown
                                             text:text];
  [_pullToRefreshGestureIPH startAnimation];
}

- (void)presentBackForwardSwipeGestureInProductHelp {
  if (UIAccessibilityIsVoiceOverRunning() ||
      (![self canPresentBubbleWithCheckTabScrolledToTop:NO])) {
    return;
  }
  const base::Feature& backForwardSwipeFeature =
      feature_engagement::kIPHiOSSwipeBackForwardFeature;

  if (!_engagementTracker->WouldTriggerHelpUI(backForwardSwipeFeature)) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  // Retrieve swipe-able directions.
  const web::NavigationManager* navigationManager =
      currentWebState->GetNavigationManager();
  BOOL back = navigationManager->CanGoBack();
  BOOL forward = navigationManager->CanGoForward();
  if (!back && !forward) {
    return;
  }
  int textId = IDS_IOS_BACK_FORWARD_SWIPE_IPH_BACK_ONLY;
  if (forward) {
    textId = back ? IDS_IOS_BACK_FORWARD_SWIPE_IPH
                  : IDS_IOS_BACK_FORWARD_SWIPE_IPH_FORWARD_ONLY;
  }

  UISwipeGestureRecognizerDirection direction =
      back ^ UseRTLLayout() ? UISwipeGestureRecognizerDirectionRight
                            : UISwipeGestureRecognizerDirectionLeft;
  _swipeBackForwardGestureIPH = [self
      presentGestureInProductHelpForFeature:backForwardSwipeFeature
                             swipeDirection:direction
                                       text:l10n_util::GetNSString(textId)];
  _swipeBackForwardGestureIPH.edgeSwipe = YES;
  if (back && forward) {
    _swipeBackForwardGestureIPH.animationRepeatCount = 4;
    _swipeBackForwardGestureIPH.bidirectional = YES;
  }
  [_swipeBackForwardGestureIPH startAnimation];
}

- (void)presentToolbarSwipeGestureInProductHelp {
  // Inapplicable on iPad.
  if (ui::GetDeviceFormFactor() !=
          ui::DeviceFormFactor::DEVICE_FORM_FACTOR_PHONE ||
      UIAccessibilityIsVoiceOverRunning() ||
      (![self canPresentBubbleWithCheckTabScrolledToTop:NO])) {
    return;
  }
  const base::Feature& feature =
      feature_engagement::kIPHiOSSwipeToolbarToChangeTabFeature;
  if (!_engagementTracker->WouldTriggerHelpUI(feature)) {
    return;
  }
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  // Check index to determine which directions are supported.
  int activeIndex = _webStateList->active_index();
  BOOL canGoBack = activeIndex > 0;
  BOOL canGoForward = activeIndex < _webStateList->count() - 1;
  if (!canGoBack && !canGoForward) {
    return;
  }
  // Setup view constraints.
  NamedGuide* contentAreaGuide =
      [NamedGuide guideWithName:kContentAreaGuide
                           view:self.rootViewController.view];
  if (!contentAreaGuide) {
    return;
  }
  UILayoutGuide* guide = [[UILayoutGuide alloc] init];
  [self.rootViewController.view addLayoutGuide:guide];
  AddSameConstraintsToSides(
      guide, contentAreaGuide,
      LayoutSides::kLeading | LayoutSides::kTrailing | LayoutSides::kBottom);
  NSLayoutConstraint* topConstraintForBottomEdgeSwipe = [guide.topAnchor
      constraintEqualToAnchor:self.rootViewController.view.topAnchor];
  NSLayoutConstraint* topConstraintForTopEdgeSwipe =
      [guide.topAnchor constraintEqualToAnchor:contentAreaGuide.topAnchor];
  NSLayoutConstraint* initialTopConstraint =
      self.rootViewController.traitCollection.verticalSizeClass ==
              UIUserInterfaceSizeClassRegular
          ? topConstraintForBottomEdgeSwipe
          : topConstraintForTopEdgeSwipe;
  initialTopConstraint.active = YES;

  // Configure IPH view.
  ToolbarSwipeGestureInProductHelpView* toolbarSwipeGestureIPH =
      [[ToolbarSwipeGestureInProductHelpView alloc]
          initWithBubbleBoundingSize:guide.layoutFrame.size
                           canGoBack:canGoBack
                             forward:canGoForward];
  [toolbarSwipeGestureIPH setTranslatesAutoresizingMaskIntoConstraints:NO];
  if (!CanGestureInProductHelpViewFitInGuide(toolbarSwipeGestureIPH, guide) ||
      !_engagementTracker->ShouldTriggerHelpUI(feature)) {
    return;
  }
  toolbarSwipeGestureIPH.topConstraintForBottomEdgeSwipe =
      topConstraintForBottomEdgeSwipe;
  toolbarSwipeGestureIPH.topConstraintForTopEdgeSwipe =
      topConstraintForTopEdgeSwipe;
  toolbarSwipeGestureIPH.delegate = self;
  [self.rootViewController.view addSubview:toolbarSwipeGestureIPH];
  AddSameConstraints(toolbarSwipeGestureIPH, guide);

  [toolbarSwipeGestureIPH startAnimation];
  _toolbarSwipeGestureIPH = toolbarSwipeGestureIPH;
}

- (void)presentFeedSwipeGestureInProductHelp {
  // TODO(crbug.com/402803175): Present animated IPH.
}

- (void)presentFeedSwipeBubble {
  if (![self canPresentBubble]) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  NSString* text = l10n_util::GetNSString(IDS_IOS_FEED_SWIPE_IPH);

  CGPoint toolbarAnchor = [self anchorPointToGuide:kSecondaryToolbarGuide
                                         direction:BubbleArrowDirectionDown];

  // Tip bubble should present slightly above the toolbar/bottom of screen and
  // not on the toolbar itself.
  CGFloat anchorYOffset = -25;

  // If the feature engagement tracker does not consider it valid to display
  // the IPH, then end early to prevent the potential reassignment of the
  // existing presenter to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHiOSFeedSwipeStaticFeature
                    direction:BubbleArrowDirectionDown
                    alignment:BubbleAlignmentCenter
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:CGPoint(toolbarAnchor.x,
                                      toolbarAnchor.y + anchorYOffset)];
  if (presenter) {
    _feedSwipeBubblePresenter = presenter;
  }
}

- (void)presentPageActionMenuBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (currentWebState && IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  BOOL isBottomOmnibox = IsBottomOmniboxAvailable() &&
                         GetApplicationContext()->GetLocalState()->GetBoolean(
                             omnibox::kIsOmniboxInBottomPosition);
  BubbleArrowDirection arrowDirection =
      isBottomOmnibox ? BubbleArrowDirectionDown : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(IDS_IOS_BWG_IPH_TEXT);

  CGPoint pageActionMenuEntrypointAnchor =
      [self anchorPointToGuide:kPageActionMenuEntrypointGuide
                     direction:arrowDirection];

  // To prevent the bubble from extending beyond the screen's edge, an offset is
  // added, with the anchor point positioned at the top left corner.
  // TODO(crbug.com/365049480): Remove this offset once the bubble view margins
  // are fixed.
  CGFloat anchorXOffset = UseRTLLayout() ? -2 : 2;

  __weak __typeof(self) weakSelf = self;
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHIOSPageActionMenu
      direction:arrowDirection
      alignment:BubbleAlignmentTopOrLeading
      text:text
      voiceOverAnnouncement:text
      anchorPoint:CGPoint(pageActionMenuEntrypointAnchor.x + anchorXOffset,
                          pageActionMenuEntrypointAnchor.y)
      presentAction:^{
        [weakSelf.pageActionMenuEntryPointHandler
            toggleEntryPointHighlight:YES];
      }
      dismissAction:^{
        [weakSelf.pageActionMenuEntryPointHandler toggleEntryPointHighlight:NO];
      }];

  if (presenter) {
    _pageActionMenuBubblePresenter = presenter;
  }
}

- (void)presentReaderModeOptionsBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!currentWebState || IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  BOOL isBottomOmnibox = IsBottomOmniboxAvailable() &&
                         GetApplicationContext()->GetLocalState()->GetBoolean(
                             omnibox::kIsOmniboxInBottomPosition);
  BubbleArrowDirection arrowDirection =
      isBottomOmnibox ? BubbleArrowDirectionDown : BubbleArrowDirectionUp;
  NSString* text =
      l10n_util::GetNSString(IDS_IOS_READER_MODE_OPTIONS_IPH_DESCRIPTION);

  CGPoint readerModeOptionsAnchor =
      [self anchorPointToGuide:kReaderModeOptionsEntrypointGuide
                     direction:arrowDirection];

  // An adjusted x offset to ensure that the bubble frame is on-screen.
  CGFloat anchorXOffset = UseRTLLayout() ? -38 : 38;

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::
                                  kIPHiOSReaderModeOptionsFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:CGPoint(readerModeOptionsAnchor.x + anchorXOffset,
                                      readerModeOptionsAnchor.y)];

  if (presenter) {
    _readerModeOptionsBubblePresenter = presenter;
  }
}

#pragma mark - GestureInProductHelpViewDelegate

- (void)gestureInProductHelpView:(GestureInProductHelpView*)view
            didDismissWithReason:(IPHDismissalReasonType)reason {
  std::string dismissButtonTappedEvent;
  if (view == _pullToRefreshGestureIPH) {
    dismissButtonTappedEvent =
        feature_engagement::events::kIOSPullToRefreshIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::kIPHiOSPullToRefreshFeature];
  } else if (view == _swipeBackForwardGestureIPH) {
    dismissButtonTappedEvent =
        feature_engagement::events::kIOSSwipeBackForwardIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::kIPHiOSSwipeBackForwardFeature];
  } else if (view == _toolbarSwipeGestureIPH) {
    dismissButtonTappedEvent = feature_engagement::events::
        kIOSSwipeToolbarToChangeTabIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::
                               kIPHiOSSwipeToolbarToChangeTabFeature];
  } else {
    NOTREACHED();
  }
  if (reason == IPHDismissalReasonType::kTappedClose && _engagementTracker &&
      !dismissButtonTappedEvent.empty()) {
    _engagementTracker->NotifyEvent(dismissButtonTappedEvent);
  }
}

- (void)gestureInProductHelpView:(GestureInProductHelpView*)view
    shouldHandleSwipeInDirection:(UISwipeGestureRecognizerDirection)direction {
  if (view == _pullToRefreshGestureIPH) {
    [self.delegate bubblePresenterDidPerformPullToRefreshGesture:self];
  } else if (view == _swipeBackForwardGestureIPH) {
    [self.delegate bubblePresenter:self
        didPerformSwipeToNavigateInDirection:direction];
  } else if (view == _toolbarSwipeGestureIPH) {
    // Do nothing. Swipe happens outside of the view.
  } else {
    NOTREACHED();
  }
}

#pragma mark - OverlayPresenterObserving

- (void)overlayPresenter:(OverlayPresenter*)presenter
    willShowOverlayForRequest:(OverlayRequest*)request
          initialPresentation:(BOOL)initialPresentation {
  [self hideAllHelpBubbles];
}

- (void)overlayPresenterDestroyed:(OverlayPresenter*)presenter {
  switch (presenter->GetModality()) {
    case OverlayModality::kWebContentArea:
      CHECK_EQ(presenter, _webContentOverlayPresenter);
      _webContentOverlayPresenter = nullptr;
      break;
    case OverlayModality::kInfobarBanner:
      CHECK_EQ(presenter, _infobarBannerPresenter);
      _infobarBannerPresenter = nullptr;
      break;
    case OverlayModality::kInfobarModal:
      CHECK_EQ(presenter, _infobarModalPresenter);
      _infobarModalPresenter = nullptr;
      break;
    case OverlayModality::kTesting:
      NOTREACHED();
  }
}

#pragma mark - Private

// Convenience method that calls -presentBubbleForFeature with default param
// values for `alignment`, `presentAction`, and `dismissAction`.
- (BubbleViewControllerPresenter*)
    presentBubbleForFeature:(const base::Feature&)feature
                  direction:(BubbleArrowDirection)direction
                  alignment:(BubbleAlignment)alignment
                       text:(NSString*)text
      voiceOverAnnouncement:(NSString*)voiceOverAnnouncement
                anchorPoint:(CGPoint)anchorPoint {
  return [self presentBubbleForFeature:feature
                             direction:direction
                             alignment:alignment
                                  text:text
                 voiceOverAnnouncement:voiceOverAnnouncement
                           anchorPoint:anchorPoint
                         presentAction:nil
                         dismissAction:nil];
}

// 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
              presentAction:(ProceduralBlock)presentAction
              dismissAction:(ProceduralBlock)dismissAction {
  DCHECK(_engagementTracker);
  BubbleViewControllerPresenter* presenter =
      [self bubblePresenterForFeature:feature
                            direction:direction
                            alignment:alignment
                                 text:text
                        dismissAction:dismissAction];
  if (!presenter) {
    return nil;
  }
  presenter.voiceOverAnnouncement = voiceOverAnnouncement;
  if ([presenter canPresentInView:self.rootViewController.view
                      anchorPoint:anchorPoint] &&
      ([self shouldForcePresentBubbleForFeature:feature] ||
       _engagementTracker->ShouldTriggerHelpUI(feature))) {
    if ([self shouldDisableFullscreenForFeature:feature]) {
      [self startAnimatedFullscreenDisabler];
    }
    [presenter presentInViewController:self.rootViewController
                           anchorPoint:anchorPoint];
    if (presentAction) {
      presentAction();
    }
  }
  return presenter;
}

// If any gesture IPH visible, remove it and log the `reason` why it should be
// removed on UMA. Otherwise, do nothing. The presenter of any gesture IPH
// should make sure it's called when the user leaves the refreshed website,
// especially while the IPH is still visible.
- (void)hideAllGestureInProductHelpViewsForReason:
    (IPHDismissalReasonType)reason {
  [_pullToRefreshGestureIPH dismissWithReason:reason];
  [_swipeBackForwardGestureIPH dismissWithReason:reason];
  [_toolbarSwipeGestureIPH dismissWithReason:reason];
}

#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 = [_layoutGuideCenter makeLayoutGuideNamed:guideName];
  DCHECK(guide);
  [self.rootViewController.view addLayoutGuide:guide];
  CGPoint anchorPoint =
      bubble_util::AnchorPoint(guide.layoutFrame, arrowDirection);
  CGPoint anchorPointInWindow =
      [guide.owningView convertPoint:anchorPoint
                              toView:guide.owningView.window];
  [self.rootViewController.view removeLayoutGuide:guide];
  return anchorPointInWindow;
}

// Returns whether the tab can present a bubble tip.
// TODO(crbug.com/40914423): make most callsites pass NO for
// `CheckTabScrolledToTop` as it's error-prone.
- (BOOL)canPresentBubble {
  return [self canPresentBubbleWithCheckTabScrolledToTop:YES];
}

// Returns whether the tab can present a bubble tip. Whether tab being scrolled
// to top is required for presenting the bubble tip is determined by
// `checkTabScrolledToTop`.
- (BOOL)canPresentBubbleWithCheckTabScrolledToTop:(BOOL)checkTabScrolledToTop {
  // If BubblePresenter has been stopped, do not present the bubble.
  if (!_started) {
    return NO;
  }
  // 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.
  if (!_webStateList->GetActiveWebState()) {
    return NO;
  }
  // Do not present bubble if an overlay is showing.
  if ((_webContentOverlayPresenter &&
       _webContentOverlayPresenter->IsShowingOverlayUI()) ||
      (_infobarBannerPresenter &&
       _infobarBannerPresenter->IsShowingOverlayUI()) ||
      (_infobarModalPresenter &&
       _infobarModalPresenter->IsShowingOverlayUI())) {
    return NO;
  }
  // Do not present the bubble if the tab is not scrolled to the top.
  if (checkTabScrolledToTop && ![self isTabScrolledToTop]) {
    return NO;
  }
  return YES;
}

- (BOOL)isTabScrolledToTop {
  // If NTP exists, check if it is scrolled to top.
  if ([self.delegate isNTPActiveForBubblePresenter:self]) {
    return [self.delegate isNTPScrolledToTopForBubblePresenter:self];
  }
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  CRWWebViewScrollViewProxy* scrollProxy =
      currentWebState->GetWebViewProxy().scrollViewProxy;
  CGPoint scrollOffset = scrollProxy.contentOffset;
  UIEdgeInsets contentInset = scrollProxy.contentInset;
  return AreCGFloatsEqual(scrollOffset.y, -contentInset.top);
}

// 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.
- (BubbleViewControllerPresenter*)
    bubblePresenterForFeature:(const base::Feature&)feature
                    direction:(BubbleArrowDirection)direction
                    alignment:(BubbleAlignment)alignment
                         text:(NSString*)text
                dismissAction:(ProceduralBlock)dismissAction {
  DCHECK(_engagementTracker);
  // 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 ProfileIOS) is deallocated.
  __weak BubblePresenter* weakSelf = self;
  CallbackWithIPHDismissalReasonType dismissalCallback =
      ^(IPHDismissalReasonType IPHDismissalReasonType) {
        if (dismissAction) {
          dismissAction();
        }
        [weakSelf stopAnimatedFullscreenDisabler];
        [weakSelf featureDismissed:feature];
      };

  BubbleViewControllerPresenter* bubbleViewControllerPresenter =
      [[BubbleViewControllerPresenter alloc]
          initDefaultBubbleWithText:text
                     arrowDirection:direction
                          alignment:alignment
                  dismissalCallback:dismissalCallback];

  bubbleViewControllerPresenter.customBubbleVisibilityDuration =
      [self bubbleVisibilityDurationForFeature:feature];
  bubbleViewControllerPresenter.ignoreWebContentAreaInteractions =
      [self shouldIgnoreWebContentAreaInteractionsForFeature:feature];

  BOOL shouldDisablePanRecognizer =
      base::FeatureList::IsEnabled(kLensOverlayDisableIPHPanGesture);
  BOOL isLensOverlayIPH =
      (feature.name ==
           feature_engagement::kIPHiOSLensOverlayEscapeHatchTipFeature.name ||
       feature.name ==
           feature_engagement::kIPHiOSLensOverlayEntrypointTipFeature.name);
  BOOL isPageActionMenuIPH =
      feature.name == feature_engagement::kIPHIOSPageActionMenu.name;
  bubbleViewControllerPresenter.forceDisablePanGestureRecognizer =
      (shouldDisablePanRecognizer && isLensOverlayIPH) || isPageActionMenuIPH;

  return bubbleViewControllerPresenter;
}

// If an in-product help message should be shown for `feature`, presents an IPH
// view covering the content area and return the view, otherwise return `nil`
// and do nothing. `direction` is the direction the bubble's arrow is pointing.
// `text` is the text displayed by the bubble.
//
// Note that this method does NOT start the animation. The caller should start
// the animation of the returned `GestureInProductHelpView` accordingly. This
// allows the caller to make modifications to the view before animating.
- (GestureInProductHelpView*)
    presentGestureInProductHelpForFeature:(const base::Feature&)feature
                           swipeDirection:
                               (UISwipeGestureRecognizerDirection)direction
                                     text:(NSString*)text {
  DCHECK(_engagementTracker);
  NamedGuide* contentAreaGuide =
      [NamedGuide guideWithName:kContentAreaGuide
                           view:self.rootViewController.view];
  if (!contentAreaGuide) {
    return nil;
  }
  UILayoutGuide* boundingSizeGuide = [[UILayoutGuide alloc] init];
  UILayoutGuide* safeAreaGuide =
      self.rootViewController.view.safeAreaLayoutGuide;
  [self.rootViewController.view addLayoutGuide:boundingSizeGuide];

  BOOL isDirectionLeading = direction == UseRTLLayout()
                                ? UISwipeGestureRecognizerDirectionRight
                                : UISwipeGestureRecognizerDirectionLeft;
  switch (direction) {
    case UISwipeGestureRecognizerDirectionUp:
      AddSameConstraintsToSides(
          boundingSizeGuide, contentAreaGuide,
          LayoutSides::kLeading | LayoutSides::kTrailing | LayoutSides::kTop);
      AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                LayoutSides::kBottom);
      break;
    case UISwipeGestureRecognizerDirectionDown:
      AddSameConstraintsToSides(boundingSizeGuide, contentAreaGuide,
                                LayoutSides::kLeading | LayoutSides::kTrailing |
                                    LayoutSides::kBottom);
      AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                LayoutSides::kTop);
      break;
    case UISwipeGestureRecognizerDirectionLeft:
    case UISwipeGestureRecognizerDirectionRight:
      if (isDirectionLeading) {
        AddSameConstraintsToSides(
            boundingSizeGuide, contentAreaGuide,
            LayoutSides::kTop | LayoutSides::kBottom | LayoutSides::kLeading);
        AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                  LayoutSides::kTrailing);
      } else {
        AddSameConstraintsToSides(
            boundingSizeGuide, contentAreaGuide,
            LayoutSides::kTop | LayoutSides::kBottom | LayoutSides::kTrailing);
        AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                  LayoutSides::kLeading);
      }
      break;
  }
  GestureInProductHelpView* gestureIPHView = [[GestureInProductHelpView alloc]
            initWithText:text
      bubbleBoundingSize:boundingSizeGuide.layoutFrame.size
          swipeDirection:direction];
  [gestureIPHView setTranslatesAutoresizingMaskIntoConstraints:NO];
  if (CanGestureInProductHelpViewFitInGuide(gestureIPHView,
                                            boundingSizeGuide) &&
      _engagementTracker->ShouldTriggerHelpUI(feature)) {
    [self.rootViewController.view addSubview:gestureIPHView];
    gestureIPHView.delegate = self;
    AddSameConstraints(gestureIPHView, contentAreaGuide);
    return gestureIPHView;
  }
  return nil;
}

// Stops the animated fullscreen disabler.
- (void)stopAnimatedFullscreenDisabler {
  _animatedFullscreenDisabler = nullptr;
}

// Creates and starts the animated fullscreen disabler.
- (void)startAnimatedFullscreenDisabler {
  _animatedFullscreenDisabler =
      std::make_unique<AnimatedScopedFullscreenDisabler>(_fullscreenController);
  _animatedFullscreenDisabler->StartAnimation();
}

- (void)featureDismissed:(const base::Feature&)feature {
  if (!_engagementTracker) {
    return;
  }
  _engagementTracker->Dismissed(feature);
}

// Returns the custom duration of the bubble for `feature`, or 0 if there is
// none.
- (NSTimeInterval)bubbleVisibilityDurationForFeature:
    (const base::Feature&)feature {
  // Display FollowWhileBrowsing in-product help bubble with custom duration.
  if (feature.name == feature_engagement::kIPHFollowWhileBrowsingFeature.name ||
      feature.name == feature_engagement::kIPHIOSPageActionMenu.name) {
    return kDefaultLongDurationBubbleVisibility;
  }

  return 0;
}

// Returns whether the web content area interactions should be ignored for the
// given feature.
- (BOOL)shouldIgnoreWebContentAreaInteractionsForFeature:
    (const base::Feature&)feature {
  if (feature.name == feature_engagement::kIPHIOSPageActionMenu.name) {
    return YES;
  }

  return NO;
}

// Returns whether fullscreen should be disabled before presenting the bubble
// for a given feature.
- (BOOL)shouldDisableFullscreenForFeature:(const base::Feature&)feature {
  if (feature.name == feature_engagement::kIPHIOSPageActionMenu.name) {
    return YES;
  }

  return NO;
}

// Return YES if the bubble should always be presented. Ex. if force present
// bubble set by system experimental settings.
- (BOOL)shouldForcePresentBubbleForFeature:(const base::Feature&)feature {
  return NO;
}

// Stop observing overlay events and disconnect related properties.
- (void)disconnectOverlayPresenters {
  if (_webContentOverlayPresenter) {
    _webContentOverlayPresenter->RemoveObserver(
        _overlayPresenterObserver.get());
    _webContentOverlayPresenter = nullptr;
  }
  if (_infobarBannerPresenter) {
    _infobarBannerPresenter->RemoveObserver(_overlayPresenterObserver.get());
    _infobarBannerPresenter = nullptr;
  }
  if (_infobarModalPresenter) {
    _infobarModalPresenter->RemoveObserver(_overlayPresenterObserver.get());
    _infobarModalPresenter = nullptr;
  }
  _overlayPresenterObserver = nullptr;
}

@end