// Copyright 2023 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/gesture_iph/gesture_in_product_help_view.h"
#import "base/ios/block_types.h"
#import "base/metrics/histogram_functions.h"
#import "base/task/sequenced_task_runner.h"
#import "base/time/time.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/bubble/ui_bundled/bubble_constants.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/gesture_iph/gesture_in_product_help_constants.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_gesture_recognizer.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_view+subclassing.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_view_delegate.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/image_util.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util.h"
namespace {
// Blur radius of the background beneath the in-product help.
const CGFloat kBlurRadius = 6.0f;
// Initial distance between the bubble and edge of the view the bubble arrow
// points to.
const CGFloat kInitialBubbleDistanceToEdgeSpacingVertical = 16.0f;
// The distance that the bubble should move during the animation.
const CGFloat kBubbleDistanceAnimated = 40.0f;
// The radius of the gesture indicator when the animation starts fading in and
// ends fading out.
const CGFloat kFadingGestureIndicatorRadius = 46.0f;
// Initial distance between the bubble and the center of the gesture indicator
// ellipsis.
const CGFloat kInitialGestureIndicatorToBubbleSpacingDefault = 62.0f;
const CGFloat
kInitialGestureIndicatorToBubbleSpacingVerticalSwipeInCompactHeight = 52.0f;
// The distance that the gesture indicator should move during the animation.
const CGFloat kGestureIndicatorDistanceAnimatedDefault = 140.0f;
const CGFloat kGestureIndicatorDistanceAnimatedVerticalSwipeInCompactHeight =
80.0f;
// The distance between the gesture indicator and the edge for horizontal side
// swipe gestures within compact widths.
const CGFloat kSideSwipeGestureIndicatorDistance = 22.0f;
// The distance between the dismiss button and the bottom edge (or the top edge,
// when the bubble points down.)
const CGFloat kDismissButtonMargin = 28.0f;
// Animation times.
const base::TimeDelta kAnimationDuration = base::Seconds(3);
const base::TimeDelta kGestureIndicatorShrinkOrExpandTime =
base::Milliseconds(250);
const base::TimeDelta kStartSlideAnimation = base::Milliseconds(500);
const base::TimeDelta kSlideAnimationDuration = base::Milliseconds(1500);
const base::TimeDelta kStartShrinkingGestureIndicator =
base::Milliseconds(2250);
// Time to wait for other view components to fall into place after size changes
// before captureing a snapshot to create a blurred background.
const base::TimeDelta kBlurSuperviewWaitTime = base::Milliseconds(400);
// Whether bubble with arrow direction `direction` is pointing left.
BOOL IsArrowPointingLeft(BubbleArrowDirection direction) {
return direction == (UseRTLLayout() ? BubbleArrowDirectionTrailing
: BubbleArrowDirectionLeading);
}
// Whether the gesture indicator should offset from the center. The gesture
// indicator should offset on iPhone portrait mode and iPad split screen. In
// both cases, the horizontal size class is compact while the vertical size
// class is regular.
BOOL ShouldGestureIndicatorOffsetFromCenter(
UITraitCollection* trait_collection) {
return trait_collection.horizontalSizeClass ==
UIUserInterfaceSizeClassCompact &&
trait_collection.verticalSizeClass == UIUserInterfaceSizeClassRegular;
}
// Returns the opposite direction of `direction`.
UISwipeGestureRecognizerDirection GetOppositeDirection(
UISwipeGestureRecognizerDirection direction) {
switch (direction) {
case UISwipeGestureRecognizerDirectionUp:
return UISwipeGestureRecognizerDirectionDown;
case UISwipeGestureRecognizerDirectionDown:
return UISwipeGestureRecognizerDirectionUp;
case UISwipeGestureRecognizerDirectionLeft:
return UISwipeGestureRecognizerDirectionRight;
case UISwipeGestureRecognizerDirectionRight:
default:
return UISwipeGestureRecognizerDirectionLeft;
}
}
// Returns the expected bubble arrow direction for `swipe_direction`.
BubbleArrowDirection GetExpectedBubbleArrowDirectionForSwipeDirection(
UISwipeGestureRecognizerDirection swipe_direction) {
switch (swipe_direction) {
case UISwipeGestureRecognizerDirectionUp:
return BubbleArrowDirectionDown;
case UISwipeGestureRecognizerDirectionDown:
return BubbleArrowDirectionUp;
case UISwipeGestureRecognizerDirectionLeft:
return UseRTLLayout() ? BubbleArrowDirectionLeading
: BubbleArrowDirectionTrailing;
case UISwipeGestureRecognizerDirectionRight:
default:
return UseRTLLayout() ? BubbleArrowDirectionTrailing
: BubbleArrowDirectionLeading;
}
}
// The anchor point for the bubble arrow of the side swipe view.
CGPoint GetAnchorPointForBubbleArrow(CGSize bubble_bounding_size,
BubbleArrowDirection direction) {
switch (direction) {
case BubbleArrowDirectionUp:
return CGPointMake(bubble_bounding_size.width / 2,
kInitialBubbleDistanceToEdgeSpacingVertical);
case BubbleArrowDirectionDown:
return CGPointMake(bubble_bounding_size.width / 2,
bubble_bounding_size.height -
kInitialBubbleDistanceToEdgeSpacingVertical);
case BubbleArrowDirectionLeading:
case BubbleArrowDirectionTrailing:
if (IsArrowPointingLeft(direction)) {
return CGPointMake(0, bubble_bounding_size.height / 2);
}
return CGPointMake(bubble_bounding_size.width,
bubble_bounding_size.height / 2);
}
}
// The frame for the bubble in the side swipe view.
CGRect GetInitialBubbleFrameForView(CGSize bubble_bounding_size,
BubbleView* bubble_view) {
BubbleArrowDirection direction = bubble_view.direction;
// Bubble's initial placement should NOT go beyond the middle of the screen.
CGFloat shift_distance = 0;
switch (direction) {
case BubbleArrowDirectionDown:
shift_distance = bubble_bounding_size.height / 2;
[[fallthrough]];
case BubbleArrowDirectionUp:
bubble_bounding_size.height = bubble_bounding_size.height / 2 -
kInitialBubbleDistanceToEdgeSpacingVertical;
break;
case BubbleArrowDirectionLeading:
case BubbleArrowDirectionTrailing:
if (!IsArrowPointingLeft(direction)) {
shift_distance = bubble_bounding_size.width / 2;
}
bubble_bounding_size.width /= 2;
break;
}
CGPoint anchor_pt =
GetAnchorPointForBubbleArrow(bubble_bounding_size, direction);
CGSize max_bubble_size = bubble_util::BubbleMaxSize(
anchor_pt, 0, direction, BubbleAlignmentCenter, bubble_bounding_size);
CGSize bubble_size = [bubble_view sizeThatFits:max_bubble_size];
CGRect frame = bubble_util::BubbleFrame(anchor_pt, 0, bubble_size, direction,
BubbleAlignmentCenter,
bubble_bounding_size.width);
if (direction == BubbleArrowDirectionUp ||
direction == BubbleArrowDirectionDown) {
frame.origin.y += shift_distance;
} else {
frame.origin.x += shift_distance;
}
return frame;
}
// Returns the transparent gesture indicator circle at its initial size and
// position.
UIView* CreateInitialGestureIndicator() {
UIView* gesture_indicator = [[UIView alloc] initWithFrame:CGRectZero];
gesture_indicator.translatesAutoresizingMaskIntoConstraints = NO;
gesture_indicator.layer.cornerRadius = kFadingGestureIndicatorRadius;
gesture_indicator.backgroundColor = UIColor.whiteColor;
gesture_indicator.alpha = 0;
gesture_indicator.userInteractionEnabled = NO;
// Shadow.
gesture_indicator.layer.shadowColor = UIColor.blackColor.CGColor;
gesture_indicator.layer.shadowOffset = CGSizeMake(0, 4);
gesture_indicator.layer.shadowRadius = 16;
gesture_indicator.layer.shadowOpacity = 1.0f;
return gesture_indicator;
}
// Returns the dismiss button with `primaryAction`.
UIButton* CreateDismissButton(UIAction* primaryAction) {
UIButtonConfiguration* button_config =
[UIButtonConfiguration filledButtonConfiguration];
button_config.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
UIFont* font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
NSDictionary* attributes = @{NSFontAttributeName : font};
NSMutableAttributedString* attributedString =
[[NSMutableAttributedString alloc]
initWithString:l10n_util::GetNSString(
IDS_IOS_IPH_SIDE_SWIPE_DISMISS_BUTTON)
attributes:attributes];
button_config.attributedTitle = attributedString;
button_config.contentInsets =
NSDirectionalEdgeInsetsMake(14.0f, 32.0f, 14.0f, 32.0f);
button_config.baseForegroundColor = UIColor.whiteColor;
button_config.baseBackgroundColor =
[UIColor.whiteColor colorWithAlphaComponent:0.2f];
UIButton* dismiss_button = [UIButton buttonWithType:UIButtonTypeCustom
primaryAction:primaryAction];
dismiss_button.configuration = button_config;
dismiss_button.accessibilityIdentifier =
kGestureInProductHelpViewDismissButtonAXId;
dismiss_button.translatesAutoresizingMaskIntoConstraints = NO;
return dismiss_button;
}
} // namespace
@implementation GestureInProductHelpView {
// Subclass Properties.
BubbleView* _bubbleView;
UIView* _gestureIndicator;
UIButton* _dismissButton;
// Bubble text.
NSString* _text;
// Gaussian blurred super view that creates a blur-filter effect.
UIView* _blurredSuperview;
// Gesture recognizer of the view.
GestureInProductHelpGestureRecognizer* _gestureRecognizer;
// Currently displaying or animating direction of the gesture indicator.
UISwipeGestureRecognizerDirection _animatingDirection;
// Constraints for the gesture indicator defining its size, margin to the
// bubble view, and its center alignment. Saved as ivar to be updated during
// the animation.
NSArray<NSLayoutConstraint*>* _gestureIndicatorSizeConstraints;
NSLayoutConstraint* _gestureIndicatorMarginConstraint;
NSLayoutConstraint* _gestureIndicatorCenterConstraint;
// Animator object that handles the animation.
UIViewPropertyAnimator* _animator;
// Whether the bubble and the gesture indicator needs to be repositioned;
// value would usually be YES right after a size class change, and back to NO
// after redrawing completes.
BOOL _needsRepositionBubbleAndGestureIndicator;
// Set to `YES` before a Gaussian blurred snapshot of the superview is being
// created; used to avoid repetitive requests to do so while waiting for other
// views to fall into place in the event of a view size change, like device
// rotation.
BOOL _blurringSuperview;
// Number of times the animation has already repeated.
int _currentAnimationRepeatCount;
// If `YES`, a static view, instead of an animation, would be displayed and
// auto-dismissed on timeout.
BOOL _reduceMotion;
// If `YES`, the in-product help view is either currently being dismissed or
// has already been removed from superview.
BOOL _dismissed;
}
- (instancetype)initWithText:(NSString*)text
bubbleBoundingSize:(CGSize)bubbleBoundingSize
swipeDirection:(UISwipeGestureRecognizerDirection)direction
voiceOverAnnouncement:(NSString*)voiceOverAnnouncement {
if ((self = [super initWithFrame:CGRectZero])) {
_text = UIAccessibilityIsVoiceOverRunning() && voiceOverAnnouncement
? voiceOverAnnouncement
: text;
_animatingDirection = direction;
_needsRepositionBubbleAndGestureIndicator = NO;
_blurringSuperview = NO;
_currentAnimationRepeatCount = 0;
_animationRepeatCount = 3;
_reduceMotion = UIAccessibilityIsReduceMotionEnabled() ||
UIAccessibilityIsVoiceOverRunning();
_dismissed = NO;
// Background view.
UIView* backgroundView = [[UIView alloc] initWithFrame:CGRectZero];
backgroundView.accessibilityIdentifier =
kGestureInProductHelpViewBackgroundAXId;
backgroundView.translatesAutoresizingMaskIntoConstraints = NO;
backgroundView.backgroundColor = UIColor.blackColor;
backgroundView.alpha = 0.65f;
[self addSubview:backgroundView];
AddSameConstraints(backgroundView, self);
// Bubble view. This has to be positioned according to the initial view's
// size.
[self setInitialBubbleViewWithDirection:
GetExpectedBubbleArrowDirectionForSwipeDirection(
_animatingDirection)
boundingSize:bubbleBoundingSize];
// Gesture indicator ellipsis.
_gestureIndicator = CreateInitialGestureIndicator();
[self addSubview:_gestureIndicator];
_gestureIndicatorSizeConstraints = @[
[_gestureIndicator.heightAnchor
constraintEqualToConstant:kFadingGestureIndicatorRadius * 2],
[_gestureIndicator.widthAnchor
constraintEqualToConstant:kFadingGestureIndicatorRadius * 2]
];
[NSLayoutConstraint activateConstraints:_gestureIndicatorSizeConstraints];
if (!UIAccessibilityIsVoiceOverRunning()) {
// Dismiss button. It will be untappable in voice over mode, so only show
// it to non-voiceOver users. VoiceOver users are able to dismiss the
// view by swiping to the next accessibility element, and therefore don't
// need the button.
__weak GestureInProductHelpView* weakSelf = self;
UIAction* dismissButtonAction =
[UIAction actionWithHandler:^(UIAction* _) {
[weakSelf dismissWithReason:IPHDismissalReasonType::kTappedClose];
}];
_dismissButton = CreateDismissButton(dismissButtonAction);
[self addSubview:_dismissButton];
[NSLayoutConstraint activateConstraints:[self dismissButtonConstraints]];
}
_gestureRecognizer = [[GestureInProductHelpGestureRecognizer alloc]
initWithExpectedSwipeDirection:_animatingDirection
target:self
action:@selector
(handleInstructedSwipeGesture:)];
[self addGestureRecognizer:_gestureRecognizer];
self.alpha = 0;
self.isAccessibilityElement = YES;
self.accessibilityViewIsModal = YES;
}
return self;
}
- (instancetype)initWithText:(NSString*)text
bubbleBoundingSize:(CGSize)bubbleBoundingSize
swipeDirection:(UISwipeGestureRecognizerDirection)direction {
return [self initWithText:text
bubbleBoundingSize:bubbleBoundingSize
swipeDirection:direction
voiceOverAnnouncement:nil];
}
- (void)didMoveToSuperview {
if (self.superview != nil && self.alpha < 1) {
GestureInProductHelpView* weakSelf = self;
[UIView
animateWithDuration:kGestureInProductHelpViewAppearDuration.InSecondsF()
animations:^{
weakSelf.alpha = 1;
}];
}
}
- (CGSize)systemLayoutSizeFittingSize:(CGSize)targetSize {
// Computes the smallest possible size that would fit all the UI elements in
// all their animated positions.
CGFloat min_width = _bubbleView.frame.size.width;
CGFloat min_height = _bubbleView.frame.size.height +
[_dismissButton intrinsicContentSize].height +
kDismissButtonMargin;
if (_reduceMotion) {
return CGSizeMake(min_width, min_height);
}
switch (_animatingDirection) {
case BubbleArrowDirectionUp:
case BubbleArrowDirectionDown:
min_height += kInitialBubbleDistanceToEdgeSpacingVertical +
[self initialGestureIndicatorToBubbleSpacing] +
[self gestureIndicatorAnimatedDistance] +
kFadingGestureIndicatorRadius;
min_width = MAX(min_width, kFadingGestureIndicatorRadius * 2);
break;
case BubbleArrowDirectionLeading:
case BubbleArrowDirectionTrailing:
if (ShouldGestureIndicatorOffsetFromCenter(self.traitCollection)) {
min_width /= 2;
} else {
min_width += [self initialGestureIndicatorToBubbleSpacing];
}
min_width += [self gestureIndicatorAnimatedDistance] +
kFadingGestureIndicatorRadius;
min_height = MAX(min_height, kFadingGestureIndicatorRadius * 2);
break;
}
// This view can expand as large as needed.
return CGSizeMake(MAX(min_width, targetSize.width),
MAX(min_height, targetSize.height));
}
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
if (ShouldGestureIndicatorOffsetFromCenter(previousTraitCollection) !=
ShouldGestureIndicatorOffsetFromCenter(self.traitCollection)) {
[_animator pauseAnimation];
_needsRepositionBubbleAndGestureIndicator = YES;
}
}
- (void)layoutSubviews {
[super layoutSubviews];
if (_needsRepositionBubbleAndGestureIndicator) {
// Avoid loops if `reposition` methods call [superview layoutIfNeeded].
_needsRepositionBubbleAndGestureIndicator = NO;
_bubbleView.frame =
GetInitialBubbleFrameForView(self.frame.size, _bubbleView);
[self repositionBubbleViewInSafeArea];
[self repositionGestureIndicator];
[_animator startAnimation];
}
UIView* blurredBackgroundImageView = _blurredSuperview.subviews.firstObject;
if (self.superview && blurredBackgroundImageView && !_blurringSuperview &&
!CGSizeEqualToSize(self.superview.bounds.size,
blurredBackgroundImageView.bounds.size)) {
_blurringSuperview = YES;
[_blurredSuperview removeFromSuperview];
_blurredSuperview = nil;
// Wait until all views settle in place after size change.
GestureInProductHelpView* weakSelf = self;
base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
FROM_HERE, base::BindOnce(^{
[weakSelf blurrifySuperview];
}),
kBlurSuperviewWaitTime);
}
}
#pragma mark - Public Properties
- (BOOL)bidirectional {
return _gestureRecognizer.bidirectional;
}
- (void)setBidirectional:(BOOL)bidirectional {
_gestureRecognizer.bidirectional = bidirectional;
}
- (BOOL)isEdgeSwipe {
return [_gestureRecognizer isEdgeSwipe];
}
- (void)setEdgeSwipe:(BOOL)edgeSwipe {
_gestureRecognizer.edgeSwipe = edgeSwipe;
}
#pragma mark - Public
- (void)startAnimation {
[self startAnimationAfterDelay:base::TimeDelta()];
}
- (void)startAnimationAfterDelay:(base::TimeDelta)delay {
CHECK(self.superview);
CHECK_GT(self.animationRepeatCount, 0);
[self.superview layoutIfNeeded];
if (!_blurringSuperview) {
[self blurrifySuperview];
}
[self repositionBubbleViewInSafeArea];
if (UIAccessibilityIsVoiceOverRunning()) {
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification,
_text);
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector
(handleUIAccessibilityAnnouncementDidFinishNotification:)
name:UIAccessibilityAnnouncementDidFinishNotification
object:nil];
}
__weak GestureInProductHelpView* weakSelf = self;
if (UIAccessibilityIsReduceMotionEnabled()) {
// Dismiss after the same timeout as with animation enabled, or when
// voiceover stops.
base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
FROM_HERE, base::BindOnce(^{
[weakSelf dismissWithReason:IPHDismissalReasonType::kTimedOut];
}),
kAnimationDuration * self.animationRepeatCount);
return;
}
// Total and relative time for each cycle of keyframe animation.
base::TimeDelta keyframeAnimationDurationPerCycle = kAnimationDuration;
if (self.bidirectional) {
keyframeAnimationDurationPerCycle -= kDurationBetweenBidirectionalCycles;
}
double gestureIndicatorSizeChangeDuration =
kGestureIndicatorShrinkOrExpandTime / keyframeAnimationDurationPerCycle;
double startSlidingTime =
kStartSlideAnimation / keyframeAnimationDurationPerCycle;
double slidingDuration =
kSlideAnimationDuration / keyframeAnimationDurationPerCycle;
double startShrinkingTime =
kStartShrinkingGestureIndicator / keyframeAnimationDurationPerCycle;
ProceduralBlock gestureIndicatorKeyframes = ^{
[UIView
addKeyframeWithRelativeStartTime:0
relativeDuration:gestureIndicatorSizeChangeDuration
animations:^{
[weakSelf
animateGestureIndicatorForVisibility:YES];
}];
[UIView addKeyframeWithRelativeStartTime:startSlidingTime
relativeDuration:slidingDuration
animations:^{
[weakSelf animateGestureIndicatorSwipe];
}];
[UIView
addKeyframeWithRelativeStartTime:startShrinkingTime
relativeDuration:gestureIndicatorSizeChangeDuration
animations:^{
[weakSelf
animateGestureIndicatorForVisibility:NO];
}];
};
ProceduralBlock bubbleKeyframes = ^{
[UIView addKeyframeWithRelativeStartTime:startSlidingTime
relativeDuration:slidingDuration
animations:^{
[weakSelf
animateBubbleSwipeInReverseDrection:NO];
}];
[UIView
addKeyframeWithRelativeStartTime:startShrinkingTime
relativeDuration:gestureIndicatorSizeChangeDuration
animations:^{
[weakSelf
animateBubbleSwipeInReverseDrection:YES];
}];
};
ProceduralBlock animation = ^{
[UIView
animateKeyframesWithDuration:keyframeAnimationDurationPerCycle
.InSecondsF()
delay:0
options:
UIViewKeyframeAnimationOptionCalculationModeLinear
animations:gestureIndicatorKeyframes
completion:nil];
[UIView
animateKeyframesWithDuration:keyframeAnimationDurationPerCycle
.InSecondsF()
delay:0
options:
UIViewKeyframeAnimationOptionCalculationModeCubic
animations:bubbleKeyframes
completion:nil];
};
// Position gesture indicator at the start of each animation cycle, as it
// might have been shifted away from its original position at the end of the
// last cycle.
[self repositionGestureIndicator];
_animator = [UIViewPropertyAnimator
runningPropertyAnimatorWithDuration:kAnimationDuration.InSecondsF()
delay:delay.InSecondsF()
options:UIViewAnimationOptionTransitionNone
animations:animation
completion:^(UIViewAnimatingPosition position) {
if (position == UIViewAnimatingPositionEnd) {
[weakSelf onAnimationCycleComplete];
}
}];
}
- (void)dismissWithReason:(IPHDismissalReasonType)reason {
[self dismissWithReason:reason completionHandler:nil];
}
#pragma mark - Private
// Action handler that executes when voiceover announcement ends.
- (void)handleUIAccessibilityAnnouncementDidFinishNotification:
(NSNotification*)notification {
[self dismissWithReason:IPHDismissalReasonType::kVoiceOverAnnouncementEnded];
}
// If the bubble view is fully visible in safe area, do nothing; otherwise, move
// it into the safe area.
- (void)repositionBubbleViewInSafeArea {
CHECK(self.superview);
UIEdgeInsets safeAreaInsets = self.safeAreaInsets;
if (UIEdgeInsetsEqualToEdgeInsets(safeAreaInsets, UIEdgeInsetsZero)) {
return;
}
CGRect bubbleFrame = _bubbleView.frame;
CGSize viewSize = self.bounds.size;
if (bubbleFrame.origin.x < safeAreaInsets.left) {
bubbleFrame.origin.x = safeAreaInsets.left;
}
if (bubbleFrame.origin.y < safeAreaInsets.top) {
bubbleFrame.origin.y = safeAreaInsets.top;
}
if (bubbleFrame.origin.x + bubbleFrame.size.width >
viewSize.width - safeAreaInsets.right) {
bubbleFrame.origin.x =
viewSize.width - safeAreaInsets.right - bubbleFrame.size.width;
}
if (bubbleFrame.origin.y + bubbleFrame.size.height >
viewSize.height - safeAreaInsets.bottom) {
bubbleFrame.origin.y =
viewSize.height - safeAreaInsets.bottom - bubbleFrame.size.height;
}
_bubbleView.frame = bubbleFrame;
[self.superview layoutIfNeeded];
}
// Puts the gesture indicator at its initial position.
- (void)repositionGestureIndicator {
CHECK(self.superview);
if (_gestureIndicatorMarginConstraint.active &&
_gestureIndicatorCenterConstraint.active) {
[NSLayoutConstraint deactivateConstraints:@[
_gestureIndicatorMarginConstraint,
_gestureIndicatorCenterConstraint,
]];
}
_gestureIndicatorMarginConstraint =
[self initialGestureIndicatorMarginConstraint];
_gestureIndicatorCenterConstraint =
[self initialGestureIndicatorCenterConstraint];
[NSLayoutConstraint activateConstraints:@[
_gestureIndicatorMarginConstraint,
_gestureIndicatorCenterConstraint,
]];
[self.superview layoutIfNeeded];
}
// Update the bottom-most subview to be a Gaussian blurred version of the
// superview to make the in-product help act as a blur-filter as well. If the
// superview is already blurred, this method does nothing.
- (void)blurrifySuperview {
if (!self.superview || _blurredSuperview) {
_blurringSuperview = NO;
return;
}
// Using frame based layout so we can compare its frame with the superview's
// frame to detect whether a redraw is needed.
UIView* superview = self.superview;
// Hide view to capture snapshot without IPH view elements.
self.hidden = YES;
UIImage* backgroundImage = CaptureViewWithOption(
superview, 1.0f, CaptureViewOption::kClientSideRendering);
self.hidden = NO;
UIImage* blurredBackgroundImage =
BlurredImageWithImage(backgroundImage, kBlurRadius);
UIImageView* blurredBackgroundImageView =
[[UIImageView alloc] initWithImage:blurredBackgroundImage];
blurredBackgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
// Create wrapper view to clip the blurred image to the edge of the superview.
_blurredSuperview = [[UIView alloc] initWithFrame:CGRectZero];
_blurredSuperview.translatesAutoresizingMaskIntoConstraints = NO;
_blurredSuperview.clipsToBounds = YES;
[_blurredSuperview addSubview:blurredBackgroundImageView];
blurredBackgroundImageView.frame = [self convertRect:superview.bounds
fromView:superview];
[self insertSubview:_blurredSuperview atIndex:0];
AddSameConstraints(_blurredSuperview, self);
_blurringSuperview = NO;
}
// Handles the completion of each round of animation.
- (void)onAnimationCycleComplete {
if (!self.superview) {
return;
}
_currentAnimationRepeatCount++;
if (_currentAnimationRepeatCount == self.animationRepeatCount) {
[self dismissWithReason:IPHDismissalReasonType::kTimedOut];
return;
}
if (!self.bidirectional) {
[self startAnimation];
return;
}
// Handle direction change.
_animatingDirection = GetOppositeDirection(_animatingDirection);
[self handleDirectionChangeToOppositeDirection];
}
// Helper of "dismissWithReason:" that comes with an optional completion
// handler.
- (void)dismissWithReason:(IPHDismissalReasonType)reason
completionHandler:(ProceduralBlock)completionHandler {
if (!self.superview || _dismissed) {
return;
}
_dismissed = YES;
GestureInProductHelpView* weakSelf = self;
[UIView
animateWithDuration:kGestureInProductHelpViewAppearDuration.InSecondsF()
animations:^{
weakSelf.alpha = 0;
}
completion:^(BOOL finished) {
GestureInProductHelpView* strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf removeFromSuperview];
base::UmaHistogramEnumeration(kUMAGesturalIPHDismissalReason, reason);
[strongSelf.delegate gestureInProductHelpView:strongSelf
didDismissWithReason:reason];
if (completionHandler) {
completionHandler();
}
}];
}
@end
@implementation GestureInProductHelpView (Subclassing)
#pragma mark - Subclass Properties
- (BubbleView*)bubbleView {
return _bubbleView;
}
- (UIView*)gestureIndicator {
return _gestureIndicator;
}
- (UIButton*)dismissButton {
return _dismissButton;
}
- (UISwipeGestureRecognizerDirection)animatingDirection {
return _animatingDirection;
}
#pragma mark - Positioning
- (void)setInitialBubbleViewWithDirection:(BubbleArrowDirection)direction
boundingSize:(CGSize)boundingSize {
_bubbleView = [[BubbleView alloc] initWithText:_text
arrowDirection:direction
alignment:BubbleAlignmentCenter];
_bubbleView.frame = GetInitialBubbleFrameForView(boundingSize, _bubbleView);
_bubbleView.accessibilityIdentifier = kGestureInProductHelpViewBubbleAXId;
[self addSubview:_bubbleView];
[_bubbleView setArrowHidden:!_reduceMotion animated:NO];
}
- (CGFloat)initialGestureIndicatorToBubbleSpacing {
BOOL verticalSwipeInCompactHeight =
self.traitCollection.verticalSizeClass ==
UIUserInterfaceSizeClassCompact &&
(_animatingDirection == UISwipeGestureRecognizerDirectionUp ||
_animatingDirection == UISwipeGestureRecognizerDirectionDown);
return verticalSwipeInCompactHeight
? kInitialGestureIndicatorToBubbleSpacingVerticalSwipeInCompactHeight
: kInitialGestureIndicatorToBubbleSpacingDefault;
}
- (CGFloat)gestureIndicatorAnimatedDistance {
BOOL verticalSwipeInCompactHeight =
self.traitCollection.verticalSizeClass ==
UIUserInterfaceSizeClassCompact &&
(_animatingDirection == UISwipeGestureRecognizerDirectionUp ||
_animatingDirection == UISwipeGestureRecognizerDirectionDown);
if (verticalSwipeInCompactHeight) {
CGFloat swipeDistance =
kGestureIndicatorDistanceAnimatedVerticalSwipeInCompactHeight;
if ([self isEdgeSwipe]) {
CGFloat bubbleWidth = CGRectGetWidth(_bubbleView.bounds);
swipeDistance += bubbleWidth / 2 - kSideSwipeGestureIndicatorDistance;
}
return swipeDistance;
}
return kGestureIndicatorDistanceAnimatedDefault;
}
- (NSLayoutConstraint*)initialGestureIndicatorMarginConstraint {
// NOTE: Despite that the returning object defines the distance between the
// gesture indicator to the bubble, it anchors on the view's nearest edge
// instead of the bubble's, so that the gesture indicator's movement during
// the animation would NOT be influenced by the bubble's movement.
CGSize bubbleSize = _bubbleView.bounds.size;
CGFloat gestureIndicatorToBubbleSpacing =
[self initialGestureIndicatorToBubbleSpacing];
switch (_animatingDirection) {
case UISwipeGestureRecognizerDirectionUp: {
// Gesture indicator should be `kInitialGestureIndicatorToBubbleSpacing`
// away from the bubble's top edge.
CGFloat margin = kInitialBubbleDistanceToEdgeSpacingVertical +
bubbleSize.height + gestureIndicatorToBubbleSpacing;
return [_gestureIndicator.centerYAnchor
constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor
constant:-margin];
}
case UISwipeGestureRecognizerDirectionDown: {
// Gesture indicator should be `kInitialGestureIndicatorToBubbleSpacing`
// away from the bubble's bottom edge.
CGFloat margin = kInitialBubbleDistanceToEdgeSpacingVertical +
bubbleSize.height + gestureIndicatorToBubbleSpacing;
return [_gestureIndicator.centerYAnchor
constraintEqualToAnchor:self.safeAreaLayoutGuide.topAnchor
constant:margin];
}
case UISwipeGestureRecognizerDirectionLeft:
case UISwipeGestureRecognizerDirectionRight:
default: {
CGFloat margin;
NSLayoutAnchor* anchorForMargin;
if (ShouldGestureIndicatorOffsetFromCenter(self.traitCollection)) {
// If the user should swipe from the edge, the gesture indicator should
// start from the edge of the view; otherwise, it should be
// center-aligned with the bubble.
margin = [self isEdgeSwipe] ? kSideSwipeGestureIndicatorDistance
: bubbleSize.width / 2;
} else {
// Gesture indicator should be `gestureIndicatorToBubbleSpacing`
// away from the bubble's leading/trailing edge.
margin = bubbleSize.width + gestureIndicatorToBubbleSpacing;
}
BOOL isSwipingLeadingDirection =
_animatingDirection == (UseRTLLayout()
? UISwipeGestureRecognizerDirectionRight
: UISwipeGestureRecognizerDirectionLeft);
if (isSwipingLeadingDirection) {
margin = -margin;
anchorForMargin = self.safeAreaLayoutGuide.trailingAnchor;
} else {
anchorForMargin = self.safeAreaLayoutGuide.leadingAnchor;
}
return [_gestureIndicator.centerXAnchor
constraintEqualToAnchor:anchorForMargin
constant:margin];
}
}
}
- (NSLayoutConstraint*)initialGestureIndicatorCenterConstraint {
NSLayoutConstraint* gestureIndicatorCenterConstraint;
switch (_animatingDirection) {
case UISwipeGestureRecognizerDirectionUp:
case UISwipeGestureRecognizerDirectionDown:
gestureIndicatorCenterConstraint = [_gestureIndicator.centerXAnchor
constraintEqualToAnchor:self.centerXAnchor];
break;
case UISwipeGestureRecognizerDirectionLeft:
case UISwipeGestureRecognizerDirectionRight:
CGFloat offset = [self initialGestureIndicatorToBubbleSpacing] +
_bubbleView.frame.size.height / 2;
gestureIndicatorCenterConstraint = [_gestureIndicator.centerYAnchor
constraintEqualToAnchor:self.centerYAnchor
constant:ShouldGestureIndicatorOffsetFromCenter(
self.traitCollection)
? offset
: 0];
break;
}
return gestureIndicatorCenterConstraint;
}
- (NSArray<NSLayoutConstraint*>*)dismissButtonConstraints {
NSLayoutConstraint* centerConstraint =
[_dismissButton.centerXAnchor constraintEqualToAnchor:self.centerXAnchor];
NSLayoutConstraint* marginConstraint =
_animatingDirection == UISwipeGestureRecognizerDirectionUp
? [_dismissButton.topAnchor
constraintEqualToAnchor:self.topAnchor
constant:kDismissButtonMargin]
: [_dismissButton.bottomAnchor
constraintEqualToAnchor:self.bottomAnchor
constant:-kDismissButtonMargin];
return @[ centerConstraint, marginConstraint ];
}
#pragma mark - Animation Keyframe
- (void)animateGestureIndicatorForVisibility:(BOOL)visible {
const CGFloat radius =
visible ? kGestureIndicatorRadius : kFadingGestureIndicatorRadius;
for (NSLayoutConstraint* constraint in _gestureIndicatorSizeConstraints) {
constraint.constant = radius * 2;
}
_gestureIndicator.layer.cornerRadius = radius;
if (visible) {
_gestureIndicator.alpha =
UIAccessibilityIsReduceTransparencyEnabled() ? 1.0f : 0.7f;
} else {
_gestureIndicator.alpha = 0;
}
[self layoutIfNeeded];
}
- (void)animateGestureIndicatorSwipe {
BOOL animatingAwayFromOrigin =
_animatingDirection == UISwipeGestureRecognizerDirectionDown ||
(_animatingDirection == UISwipeGestureRecognizerDirectionLeft &&
UseRTLLayout()) ||
(_animatingDirection == UISwipeGestureRecognizerDirectionRight &&
!UseRTLLayout());
CGFloat gestureIndicatorAnimatedDistance =
[self gestureIndicatorAnimatedDistance];
_gestureIndicatorMarginConstraint.constant +=
animatingAwayFromOrigin ? gestureIndicatorAnimatedDistance
: -gestureIndicatorAnimatedDistance;
[self layoutIfNeeded];
}
- (void)animateBubbleSwipeInReverseDrection:(BOOL)reverse {
UISwipeGestureRecognizerDirection direction = _animatingDirection;
if (reverse) {
direction = GetOppositeDirection(direction);
}
CGRect newFrame = _bubbleView.frame;
switch (direction) {
case UISwipeGestureRecognizerDirectionUp:
newFrame.origin.y -= kBubbleDistanceAnimated;
break;
case UISwipeGestureRecognizerDirectionDown:
newFrame.origin.y += kBubbleDistanceAnimated;
break;
case UISwipeGestureRecognizerDirectionLeft:
newFrame.origin.x -= kBubbleDistanceAnimated;
break;
case UISwipeGestureRecognizerDirectionRight:
newFrame.origin.x += kBubbleDistanceAnimated;
break;
}
_bubbleView.frame = newFrame;
[_bubbleView setArrowHidden:reverse animated:YES];
[self layoutIfNeeded];
}
#pragma mark - Event handler
- (void)handleInstructedSwipeGesture:
(GestureInProductHelpGestureRecognizer*)gesture {
__weak GestureInProductHelpView* weakSelf = self;
// Triggers an animation that resembles a user-initiated swipe on the views
// beneath the IPH. For one directional IPH, the swipe direction should be
// opposite to the arrow direction. Also dismisses the IPH with the reason
// `kSwipedAsInstructedByGestureIPH`.
[self
dismissWithReason:IPHDismissalReasonType::kSwipedAsInstructedByGestureIPH
completionHandler:^{
[weakSelf.delegate
gestureInProductHelpView:weakSelf
shouldHandleSwipeInDirection:gesture.actualSwipeDirection];
}];
}
- (void)handleDirectionChangeToOppositeDirection {
BubbleView* previousBubbleView = _bubbleView;
__weak GestureInProductHelpView* weakSelf = self;
[UIView animateWithDuration:kDurationBetweenBidirectionalCycles.InSecondsF()
animations:^{
previousBubbleView.alpha = 0;
}
completion:^(BOOL completed) {
[previousBubbleView removeFromSuperview];
if (completed) {
[weakSelf setInitialBubbleViewWithDirection:
GetExpectedBubbleArrowDirectionForSwipeDirection(
weakSelf.animatingDirection)
boundingSize:weakSelf.frame.size];
[weakSelf startAnimation];
} else {
// This will be most likely caused by that the view has been
// dismissed during animation, but in case it's not, dismiss the
// view. If the view has already been dismissed, this call does
// nothing.
[weakSelf dismissWithReason:IPHDismissalReasonType::kUnknown];
}
}];
}
@end