chromium/ios/chrome/browser/bubble/ui_bundled/bubble_view_controller_presenter.mm

// Copyright 2017 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_view_controller_presenter.h"

#import "base/check.h"
#import "base/ios/block_types.h"
#import "base/metrics/histogram_functions.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_util.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_view.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_view_controller.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_view_controller_presenter+Testing.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/util/layout_guide_names.h"
#import "ios/chrome/browser/shared/ui/util/named_guide.h"

namespace {

// How long, in seconds, the user should be considered engaged with the bubble
// after the bubble first becomes visible.
const NSTimeInterval kBubbleEngagementDuration = 30.0;

// Delay before posting the VoiceOver notification.
const CGFloat kVoiceOverAnnouncementDelay = 1;

}  // namespace

// Implements BubbleViewDelegate to handle BubbleView's close and snooze buttons
// tap.
@interface BubbleViewControllerPresenter () <UIGestureRecognizerDelegate,
                                             BubbleViewDelegate>

// The underlying BubbleViewController managed by this object.
// `bubbleViewController` manages the BubbleView instance.
@property(nonatomic, strong) BubbleViewController* bubbleViewController;
// The timer used to dismiss the bubble after a certain length of time. The
// bubble is dismissed automatically if the user does not dismiss it manually.
// If the user dismisses it manually, this timer is invalidated. The timer
// maintains a strong reference to the presenter, so it must be retained weakly
// to prevent a retain cycle. The run loop retains a strong reference to the
// timer so it is not deallocated until it is invalidated.
@property(nonatomic, strong) NSTimer* bubbleDismissalTimer;
// The timer used to reset the user's engagement. The user is considered
// engaged with the bubble while it is visible and for a certain duration after
// it disappears. The timer maintains a strong reference to the presenter, so it
// must be retained weakly to prevent a retain cycle. The run loop retains a
// strong reference to the timer so it is not deallocated until it is
// invalidated.
@property(nonatomic, strong) NSTimer* engagementTimer;
// The `parentView` of the underlying BubbleView which corresponds to the
// `parentViewController`'s view passed in -presentInViewController:anchorPoint.
@property(nonatomic, strong) UIView* parentView;
// The frame of the view the underlying BubbleView anchored to, can be
// CGRectZero if un-provided or inapplicable. Passed in
// -presentInViewController:anchorPoint:anchorViewFrame.
@property(nonatomic, assign) CGRect anchorViewFrame;
// Redeclared as readwrite so the value can be changed internally.
@property(nonatomic, assign, readwrite, getter=isUserEngaged) BOOL userEngaged;
// The tap gesture recognizer intercepting tap gestures occurring inside the
// bubble view. Taps inside must be differentiated from taps outside to track
// UMA metrics.
@property(nonatomic, strong) UITapGestureRecognizer* insideBubbleTapRecognizer;
// The tap gesture recognizer intercepting tap gestures occurring outside the
// bubble view. Does not prevent interactions with elements being tapped on.
// For example, tapping on a button both dismisses the bubble and triggers the
// button's action.
@property(nonatomic, strong) UITapGestureRecognizer* outsideBubbleTapRecognizer;
// The pan gesture recognizer to dismiss the bubble on scrolling events.
@property(nonatomic, strong) UIPanGestureRecognizer* outsideBubblePanRecognizer;
// The swipe gesture recognizer to dismiss the bubble on swipes.
@property(nonatomic, strong) UISwipeGestureRecognizer* swipeRecognizer;
// The direction the underlying BubbleView's arrow is pointing.
@property(nonatomic, assign) BubbleArrowDirection arrowDirection;
// The alignment of the underlying BubbleView's arrow.
@property(nonatomic, assign) BubbleAlignment alignment;
// The type of the bubble view's content.
@property(nonatomic, assign, readonly) BubbleViewType bubbleType;
// Whether the bubble view controller is presented or dismissed.
@property(nonatomic, assign, getter=isPresenting) BOOL presenting;
// The block invoked when the bubble is dismissed (both via timer and via tap).
// Is optional.
@property(nonatomic, strong)
    CallbackWithIPHDismissalReasonType dismissalCallback;

@end

@implementation BubbleViewControllerPresenter

@synthesize bubbleViewController = _bubbleViewController;
@synthesize insideBubbleTapRecognizer = _insideBubbleTapRecognizer;
@synthesize outsideBubbleTapRecognizer = _outsideBubbleTapRecognizer;
@synthesize swipeRecognizer = _swipeRecognizer;
@synthesize bubbleDismissalTimer = _bubbleDismissalTimer;
@synthesize engagementTimer = _engagementTimer;
@synthesize userEngaged = _userEngaged;
@synthesize triggerFollowUpAction = _triggerFollowUpAction;
@synthesize arrowDirection = _arrowDirection;
@synthesize alignment = _alignment;
@synthesize dismissalCallback = _dismissalCallback;
@synthesize voiceOverAnnouncement = _voiceOverAnnouncement;

- (instancetype)initWithText:(NSString*)text
                       title:(NSString*)titleString
                       image:(UIImage*)image
              arrowDirection:(BubbleArrowDirection)arrowDirection
                   alignment:(BubbleAlignment)alignment
                  bubbleType:(BubbleViewType)type
           dismissalCallback:
               (CallbackWithIPHDismissalReasonType)dismissalCallback {
  self = [super init];
  if (self) {
    _bubbleViewController =
        [[BubbleViewController alloc] initWithText:text
                                             title:titleString
                                             image:image
                                    arrowDirection:arrowDirection
                                         alignment:alignment
                                    bubbleViewType:type
                                          delegate:self];
    _userEngaged = NO;
    _triggerFollowUpAction = NO;
    _ignoreWebContentAreaInteractions = NO;
    _arrowDirection = arrowDirection;
    _alignment = alignment;
    _bubbleType = type;
    _dismissalCallback = dismissalCallback;
    // The timers are initialized when the bubble is presented, not during
    // initialization. Because the user might not present the bubble immediately
    // after initialization, the timers cannot be started until the bubble
    // appears on screen.
  }
  return self;
}

- (instancetype)initDefaultBubbleWithText:(NSString*)text
                           arrowDirection:(BubbleArrowDirection)arrowDirection
                                alignment:(BubbleAlignment)alignment
                        dismissalCallback:(CallbackWithIPHDismissalReasonType)
                                              dismissalCallback {
  return [self initWithText:text
                      title:nil
                      image:nil
             arrowDirection:arrowDirection
                  alignment:alignment
                 bubbleType:BubbleViewTypeDefault
          dismissalCallback:dismissalCallback];
}

- (BOOL)canPresentInView:(UIView*)parentView anchorPoint:(CGPoint)anchorPoint {
  CGPoint anchorPointInParent = [parentView.window convertPoint:anchorPoint
                                                         toView:parentView];
  return !CGRectIsEmpty([self frameForBubbleInRect:parentView.bounds
                                     atAnchorPoint:anchorPointInParent]);
}

- (void)presentInViewController:(UIViewController*)parentViewController
                    anchorPoint:(CGPoint)anchorPoint {
  [self presentInViewController:parentViewController
                    anchorPoint:anchorPoint
                anchorViewFrame:CGRectZero];
}

- (void)presentInViewController:(UIViewController*)parentViewController
                    anchorPoint:(CGPoint)anchorPoint
                anchorViewFrame:(CGRect)anchorViewFrame {
  self.parentView = parentViewController.view;
  _anchorViewFrame = anchorViewFrame;
  CGPoint anchorPointInParent =
      [self.parentView.window convertPoint:anchorPoint toView:self.parentView];
  self.bubbleViewController.view.frame =
      [self frameForBubbleInRect:self.parentView.bounds
                   atAnchorPoint:anchorPointInParent];
  // The bubble's frame must be set. Call `canPresentInView` to make sure that
  // the frame can be set before calling `presentInViewController`.
  DCHECK(!CGRectIsEmpty(self.bubbleViewController.view.frame));

  [self addGestureRecognizersToParentView:self.parentView];

  self.presenting = YES;
  [parentViewController addChildViewController:self.bubbleViewController];
  [self.parentView addSubview:self.bubbleViewController.view];
  [self.bubbleViewController
      didMoveToParentViewController:parentViewController];
  [self.bubbleViewController animateContentIn];

  self.bubbleDismissalTimer = [NSTimer
      scheduledTimerWithTimeInterval:[self bubbleVisibilityDuration]
                              target:self
                            selector:@selector(bubbleDismissalTimerFired:)
                            userInfo:nil
                             repeats:NO];

  self.userEngaged = YES;
  self.triggerFollowUpAction = YES;
  self.engagementTimer =
      [NSTimer scheduledTimerWithTimeInterval:kBubbleEngagementDuration
                                       target:self
                                     selector:@selector(engagementTimerFired:)
                                     userInfo:nil
                                      repeats:NO];

  [[NSNotificationCenter defaultCenter]
      addObserver:self
         selector:@selector(onKeyboardHide:)
             name:UIKeyboardWillHideNotification
           object:nil];

  if (self.voiceOverAnnouncement) {
    if (self.bubbleShouldAutoDismissUnderAccessibility) {
      // The VoiceOverAnnouncement should be dispatched after a delay to account
      // for the fact that it can be presented right after a screen change (for
      // example when the application or a new tab is opened). This screen
      // change is changing the VoiceOver focus to focus a newly visible
      // element. If this announcement is currently being read, it is cancelled.
      // The added delay allows the announcement to be posted after the element
      // is focused, so it is not cancelled.
      dispatch_after(
          dispatch_time(DISPATCH_TIME_NOW,
                        (int64_t)(kVoiceOverAnnouncementDelay * NSEC_PER_SEC)),
          dispatch_get_main_queue(), ^{
            UIAccessibilityPostNotification(
                UIAccessibilityAnnouncementNotification,
                self.voiceOverAnnouncement);
          });
    } else {
      UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification,
                                      self.bubbleViewController.view);
    }
  }
}

- (void)setArrowHidden:(BOOL)hidden animated:(BOOL)animated {
  if (!self.presenting) {
    return;
  }

  [self.bubbleViewController setArrowHidden:hidden animated:animated];
}

- (void)dismissAnimated:(BOOL)animated
                 reason:(IPHDismissalReasonType)reason
           snoozeAction:(feature_engagement::Tracker::SnoozeAction)action {
  // Because this object must stay in memory to handle the `userEngaged`
  // property correctly, it is possible for `dismissAnimated` to be called
  // multiple times. However, only the first call should have any effect.
  if (!self.presenting) {
    return;
  }

  base::UmaHistogramEnumeration(kUMAIPHDismissalReason, reason);

  [self.bubbleDismissalTimer invalidate];
  self.bubbleDismissalTimer = nil;

  [self removeGestureRecognizers];

  [self.bubbleViewController dismissAnimated:animated];
  self.presenting = NO;

  if (self.dismissalCallback) {
    self.dismissalCallback(reason, action);
  }
}

- (void)dismissAnimated:(BOOL)animated {
  [self dismissAnimated:animated reason:IPHDismissalReasonType::kUnknown];
}

- (void)dismissAnimated:(BOOL)animated reason:(IPHDismissalReasonType)reason {
  [self dismissAnimated:animated
                 reason:reason
           snoozeAction:feature_engagement::Tracker::SnoozeAction::DISMISSED];
}

- (void)dealloc {
  [self.bubbleDismissalTimer invalidate];
  self.bubbleDismissalTimer = nil;
  [self.engagementTimer invalidate];
  self.engagementTimer = nil;

  [self removeGestureRecognizers];
}

#pragma mark - UIGestureRecognizerDelegate

- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
    shouldRecognizeSimultaneouslyWithGestureRecognizer:
        (UIGestureRecognizer*)otherGestureRecognizer {
  // Allow certain types of `gestureRecognizer` to be triggered at the
  // same time as other gesture recognizers.
  return gestureRecognizer == self.swipeRecognizer ||
         gestureRecognizer == self.outsideBubbleTapRecognizer ||
         gestureRecognizer == self.outsideBubblePanRecognizer;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
       shouldReceiveTouch:(UITouch*)touch {
  // Prevents outside gesture recognizers from triggering when tapping inside
  // the bubble.
  if (gestureRecognizer == self.outsideBubbleTapRecognizer &&
      [touch.view isDescendantOfView:self.bubbleViewController.view]) {
    return NO;
  }
  // If the swipe originated from a button inside the bubble, cancel the touch
  // instead of dismissing the bubble.
  if (gestureRecognizer == self.swipeRecognizer &&
      [touch.view isDescendantOfView:self.bubbleViewController.view] &&
      [touch.view isKindOfClass:[UIButton class]]) {
    return NO;
  }
  // Prevents inside gesture recognizers from triggering when tapping on a
  // button inside of the bubble.
  if (gestureRecognizer == self.insideBubbleTapRecognizer &&
      [touch.view isKindOfClass:[UIButton class]]) {
    return NO;
  }

  // If the interaction originated from outside the bubble but inside the web
  // content area, and web content area interactions should be ignored, cancel
  // the touch instead of dismissing the bubble.
  BOOL isOutsideBubbleRecognizer =
      gestureRecognizer == self.outsideBubbleTapRecognizer ||
      gestureRecognizer == self.outsideBubblePanRecognizer ||
      gestureRecognizer == self.swipeRecognizer;

  NamedGuide* contentAreaGuide = [NamedGuide guideWithName:kContentAreaGuide
                                                      view:self.parentView];

  if (self.ignoreWebContentAreaInteractions && contentAreaGuide &&
      isOutsideBubbleRecognizer &&
      ![touch.view isDescendantOfView:self.bubbleViewController.view]) {
    CGPoint touchPoint = [touch locationInView:self.parentView];
    CGPoint touchPointInOwningViewCoordinates =
        [self.parentView convertPoint:touchPoint
                               toView:contentAreaGuide.owningView];

    if (CGRectContainsPoint(contentAreaGuide.layoutFrame,
                            touchPointInOwningViewCoordinates)) {
      return NO;
    }
  }

  return YES;
}

#pragma mark - BubbleViewDelegate

- (void)didTapCloseButton {
  [self dismissAnimated:YES reason:IPHDismissalReasonType::kTappedClose];
}

- (void)didTapSnoozeButton {
  [self dismissAnimated:YES
                 reason:IPHDismissalReasonType::kTappedSnooze
           snoozeAction:feature_engagement::Tracker::SnoozeAction::SNOOZED];
}

#pragma mark - Private

- (NSTimeInterval)bubbleVisibilityDuration {
  return _customBubbleVisibilityDuration > 0 ? _customBubbleVisibilityDuration
                                             : kBubbleVisibilityDuration;
}

- (void)removeGestureRecognizers {
  [self.outsideBubbleTapRecognizer.view
      removeGestureRecognizer:self.outsideBubbleTapRecognizer];
  [self.outsideBubblePanRecognizer.view
      removeGestureRecognizer:self.outsideBubblePanRecognizer];
  [self.insideBubbleTapRecognizer.view
      removeGestureRecognizer:self.insideBubbleTapRecognizer];
  [self.swipeRecognizer.view removeGestureRecognizer:self.swipeRecognizer];
}

// Adds gesture recognizers to parent view.
- (void)addGestureRecognizersToParentView:(UIView*)parentView {
  self.outsideBubbleTapRecognizer = [[UITapGestureRecognizer alloc]
      initWithTarget:self
              action:@selector(tapOutsideBubbleRecognized:)];
  self.outsideBubbleTapRecognizer.delegate = self;
  self.outsideBubbleTapRecognizer.cancelsTouchesInView = NO;

  self.outsideBubblePanRecognizer = [[UIPanGestureRecognizer alloc]
      initWithTarget:self
              action:@selector(tapOutsideBubbleRecognized:)];
  self.outsideBubblePanRecognizer.delegate = self;
  self.outsideBubblePanRecognizer.cancelsTouchesInView = NO;

  self.insideBubbleTapRecognizer = [[UITapGestureRecognizer alloc]
      initWithTarget:self
              action:@selector(tapInsideBubbleRecognized:)];
  self.insideBubbleTapRecognizer.delegate = self;
  self.insideBubbleTapRecognizer.cancelsTouchesInView = NO;

  self.swipeRecognizer = [[UISwipeGestureRecognizer alloc]
      initWithTarget:self
              action:@selector(tapOutsideBubbleRecognized:)];
  self.swipeRecognizer.direction = UISwipeGestureRecognizerDirectionUp;
  self.swipeRecognizer.delegate = self;

  [self.bubbleViewController.view
      addGestureRecognizer:self.insideBubbleTapRecognizer];
  [parentView addGestureRecognizer:self.outsideBubbleTapRecognizer];
  [parentView addGestureRecognizer:self.outsideBubblePanRecognizer];
  [parentView addGestureRecognizer:self.swipeRecognizer];
}

// Invoked by tapping inside the bubble. Dismisses the bubble.
- (void)tapInsideBubbleRecognized:(id)sender {
  [self dismissAnimated:YES reason:IPHDismissalReasonType::kTappedIPH];
}

// Invoked by tapping outside the bubble. Dismisses the bubble.
- (void)tapOutsideBubbleRecognized:(UIGestureRecognizer*)sender {
  if (sender.numberOfTouches <= 0) {
    return;
  }
  CGPoint touchLocation = [sender locationOfTouch:0 inView:self.parentView];
  IPHDismissalReasonType reasonType = IPHDismissalReasonType::kUnknown;
  if (CGRectContainsPoint(_anchorViewFrame, touchLocation)) {
    reasonType = IPHDismissalReasonType::kTappedAnchorView;
  } else {
    reasonType = IPHDismissalReasonType::kTappedOutsideIPHAndAnchorView;
  }
  [self dismissAnimated:YES reason:reasonType];
}

// Automatically dismisses the bubble view when `bubbleDismissalTimer` fires.
- (void)bubbleDismissalTimerFired:(id)sender {
  BOOL usesScreenReader = UIAccessibilityIsVoiceOverRunning() ||
                          UIAccessibilityIsSwitchControlRunning();
  if (usesScreenReader && !self.bubbleShouldAutoDismissUnderAccessibility) {
    // No-op. Keep the IPH available for screen reader users.
  } else {
    [self dismissAnimated:YES reason:IPHDismissalReasonType::kTimedOut];
  }
}

// Marks the user as not engaged when `engagementTimer` fires.
- (void)engagementTimerFired:(id)sender {
  self.userEngaged = NO;
  self.triggerFollowUpAction = NO;
  self.engagementTimer = nil;
}

// Invoked when the keyboard is dismissed.
- (void)onKeyboardHide:(NSNotification*)notification {
  BOOL usesScreenReader = UIAccessibilityIsVoiceOverRunning() ||
                          UIAccessibilityIsSwitchControlRunning();
  if (usesScreenReader && !self.bubbleShouldAutoDismissUnderAccessibility) {
    [self dismissAnimated:YES reason:IPHDismissalReasonType::kOnKeyboardHide];
  }
}

// Calculates the frame of the BubbleView. `rect` is the frame of the bubble's
// superview. `anchorPoint` is the anchor point of the bubble. `anchorPoint`
// and `rect` must be in the same coordinates.
- (CGRect)frameForBubbleInRect:(CGRect)rect atAnchorPoint:(CGPoint)anchorPoint {
  CGFloat bubbleAlignmentOffset = bubble_util::BubbleDefaultAlignmentOffset();
  if ([self arrowIsFloating]) {
    bubbleAlignmentOffset = bubble_util::FloatingArrowAlignmentOffset(
        rect.size.width, anchorPoint, self.alignment);
  }
  // Set bubble alignment offset, must be set before the call to `sizeThatFits`.
  [self.bubbleViewController setBubbleAlignmentOffset:bubbleAlignmentOffset];
  CGSize maxBubbleSize = bubble_util::BubbleMaxSize(
      anchorPoint, bubbleAlignmentOffset, self.arrowDirection, self.alignment,
      rect.size);
  CGSize bubbleSize =
      [self.bubbleViewController.view sizeThatFits:maxBubbleSize];

  if ([self bubbleIsFullWidth]) {
    bubbleSize.width = maxBubbleSize.width;
  }
  // If `bubbleSize` does not fit in `maxBubbleSize`, the bubble will be
  // partially off screen and not look good. This is most likely a result of
  // an incorrect value for `alignment` (such as a trailing aligned bubble
  // anchored to an element on the leading edge of the screen).
  if (bubbleSize.width > maxBubbleSize.width ||
      bubbleSize.height > maxBubbleSize.height) {
    return CGRectNull;
  }
  CGRect bubbleFrame = bubble_util::BubbleFrame(
      anchorPoint, bubbleAlignmentOffset, bubbleSize, self.arrowDirection,
      self.alignment, CGRectGetWidth(rect));
  // If anchorPoint is too close to the edge of the screen, the bubble will be
  // partially off screen and not look good.
  if (!CGRectContainsRect(rect, bubbleFrame)) {
    return CGRectNull;
  }
  return bubbleFrame;
}

// Whether the bubble's arrow is floating.
- (BOOL)arrowIsFloating {
  return self.bubbleType == BubbleViewTypeWithClose ||
         ((self.bubbleType == BubbleViewTypeRichWithSnooze ||
           self.bubbleType == BubbleViewTypeRich) &&
          !IsRichBubbleWithoutImageEnabled());
}

// Whether the bubble should be full width.
- (BOOL)bubbleIsFullWidth {
  return (self.bubbleType == BubbleViewTypeRichWithSnooze ||
          self.bubbleType == BubbleViewTypeRich) &&
         !IsRichBubbleWithoutImageEnabled();
}

// Whether the bubble should stick or auto-dismiss when the user uses a screen
// reader. This is for bubble types that don't have interaction buttons.
- (BOOL)bubbleShouldAutoDismissUnderAccessibility {
  return self.bubbleType == BubbleViewTypeDefault ||
         self.bubbleType == BubbleViewTypeRich;
}

@end