// Copyright 2021 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/common/ui/promo_style/promo_style_view_controller.h"
#import "base/check.h"
#import "base/check_op.h"
#import "base/i18n/rtl.h"
#import "base/notreached.h"
#import "base/time/time.h"
#import "ios/chrome/common/constants.h"
#import "ios/chrome/common/string_util.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/elements/highlight_button.h"
#import "ios/chrome/common/ui/promo_style/constants.h"
#import "ios/chrome/common/ui/promo_style/promo_style_background_view.h"
#import "ios/chrome/common/ui/promo_style/utils.h"
#import "ios/chrome/common/ui/util/button_util.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/device_util.h"
#import "ios/chrome/common/ui/util/dynamic_type_util.h"
#import "ios/chrome/common/ui/util/image_util.h"
#import "ios/chrome/common/ui/util/pointer_interaction_util.h"
#import "ios/chrome/common/ui/util/text_view_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"
namespace {
// Default margin between the subtitle and the content view.
constexpr CGFloat kDefaultSubtitleBottomMargin = 22;
// Top margin for no background header image in percentage of the dialog size.
constexpr CGFloat kNoBackgroundHeaderImageTopMarginPercentage = 0.04;
constexpr CGFloat kNoBackgroundHeaderImageBottomMargin = 5;
// Top margin for header image with background in percentage of the dialog size.
constexpr CGFloat kHeaderImageBackgroundTopMarginPercentage = 0.1;
constexpr CGFloat kHeaderImageBackgroundBottomMargin = 34;
constexpr CGFloat kTitleHorizontalMargin = 18;
constexpr CGFloat kTitleNoHeaderTopMargin = 56;
constexpr CGFloat kTallBannerMultiplier = 0.35;
constexpr CGFloat kExtraTallBannerMultiplier = 0.5;
constexpr CGFloat kDefaultBannerMultiplier = 0.25;
constexpr CGFloat kShortBannerMultiplier = 0.2;
constexpr CGFloat kMoreArrowMargin = 4;
constexpr CGFloat kPreviousContentVisibleOnScroll = 0.15;
constexpr CGFloat kSeparatorHeight = 1;
constexpr CGFloat kLearnMoreButtonSide = 40;
constexpr CGFloat kheaderImageSize = 48;
constexpr CGFloat kFullheaderImageSize = 100;
constexpr CGFloat kStackViewEquallyWeightedButtonSpacing = 12;
constexpr CGFloat kStackViewDefaultButtonSpacing = 0;
// Corner radius for the whole view.
constexpr CGFloat kCornerRadius = 20;
// Duration for the buttons' fade-in animation.
constexpr base::TimeDelta kAnimationDuration = base::Milliseconds(200);
// Properties of the header image kImageWithShadow type shadow.
const CGFloat kHeaderImageCornerRadius = 13;
const CGFloat kHeaderImageShadowOffsetX = 0;
const CGFloat kHeaderImageShadowOffsetY = 0;
const CGFloat kHeaderImageShadowRadius = 6;
const CGFloat kHeaderImageShadowOpacity = 0.1;
const CGFloat kHeaderImageShadowShadowInset = 20;
} // namespace
@interface PromoStyleViewController () <UIScrollViewDelegate>
@property(nonatomic, strong) UIImageView* bannerImageView;
// This view contains only the header image.
@property(nonatomic, strong) UIImageView* headerImageView;
@property(nonatomic, strong) UITextView* disclaimerView;
// Primary action button for the view controller.
@property(nonatomic, strong) HighlightButton* primaryActionButton;
// Activity indicator on top of `primaryActionButton`.
@property(nonatomic, strong)
UIActivityIndicatorView* primaryButtonActivityIndicatorView;
// Read/Write override.
@property(nonatomic, assign, readwrite) BOOL didReachBottom;
@end
@implementation PromoStyleViewController {
// Whether banner is light or dark mode
UIUserInterfaceStyle _bannerStyle;
UIScrollView* _scrollView;
// UIView that wraps the scrollable content.
UIView* _scrollContentView;
// This view contains the header image with a shadow background image behind.
UIView* _fullHeaderImageView;
// This view contains the background image for the header image. The header
// view will be placed at the center of it.
UIImageView* _headerBackgroundImageView;
// Stack view containing the action buttons.
UIStackView* _actionButtonsStackView;
UIButton* _secondaryActionButton;
UIButton* _tertiaryActionButton;
UIView* _separator;
CGFloat _scrollViewBottomOffsetY;
// Layout constraint for `headerBackgroundImageView` top margin.
NSLayoutConstraint* _headerBackgroundImageViewTopMargin;
// Layout constraint for `titleLabel` top margin when there is no banner or
// header.
NSLayoutConstraint* _titleLabelNoHeaderTopMargin;
// YES if the views can be updated on scroll updates (e.g., change the text
// label string of the primary button) which corresponds to the moment where
// the layout reflects the latest updates.
BOOL _canUpdateViewsOnScroll;
// Whether the image is currently being calculated; used to prevent infinite
// recursions caused by `viewDidLayoutSubviews`.
BOOL _calculatingImageSize;
// Vertical constraints for buttons; used to reset top anchors when the number
// of buttons changes on scroll.
NSArray<NSLayoutConstraint*>* _buttonsVerticalAnchorConstraints;
// Vertical constraints for banner; used to deactivate these constraints when
// the banner is hidden.
NSArray<NSLayoutConstraint*>* _bannerConstraints;
// Indicate that the view should scroll to the bottom at the end of the next
// layout.
BOOL _shouldScrollToBottom;
// Whether the buttons have been updated from "More" to the action buttons.
BOOL _buttonUpdated;
}
@synthesize actionButtonsVisibility = _actionButtonsVisibility;
@synthesize learnMoreButton = _learnMoreButton;
@synthesize primaryButtonSpinnerEnabled = _primaryButtonSpinnerEnabled;
#pragma mark - Public
- (instancetype)initWithNibName:(NSString*)nibNameOrNil
bundle:(NSBundle*)nibBundleOrNil {
self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
if (self) {
_titleHorizontalMargin = kTitleHorizontalMargin;
_subtitleBottomMargin = kDefaultSubtitleBottomMargin;
_headerImageShadowInset = kHeaderImageShadowShadowInset;
_headerImageBottomMargin = kPromoStyleDefaultMargin;
_noBackgroundHeaderImageTopMarginPercentage =
kNoBackgroundHeaderImageTopMarginPercentage;
_primaryButtonEnabled = YES;
}
return self;
}
#pragma mark - UIViewController
- (void)viewDidLoad {
[super viewDidLoad];
UIView* view = self.view;
view.backgroundColor = [UIColor colorNamed:kPrimaryBackgroundColor];
if (self.usePromoStyleBackground) {
CHECK(self.shouldHideBanner);
UIView* backgroundView = [[PromoStyleBackgroundView alloc] init];
backgroundView.translatesAutoresizingMaskIntoConstraints = NO;
backgroundView.layer.zPosition = -1;
[view addSubview:backgroundView];
AddSameConstraints(view, backgroundView);
}
// Create a layout guide for the margin between the subtitle and the screen-
// specific content. A layout guide is needed because the margin scales with
// the view height.
UILayoutGuide* subtitleMarginLayoutGuide = [[UILayoutGuide alloc] init];
_separator = [[UIView alloc] init];
_bannerStyle = UIUserInterfaceStyleUnspecified;
_separator.translatesAutoresizingMaskIntoConstraints = NO;
_separator.backgroundColor = [UIColor colorNamed:kSeparatorColor];
_separator.hidden = YES;
[view addSubview:_separator];
_scrollContentView = [[UIView alloc] init];
_scrollContentView.translatesAutoresizingMaskIntoConstraints = NO;
[_scrollContentView addSubview:self.bannerImageView];
if (self.headerImageType != PromoStyleImageType::kNone) {
_fullHeaderImageView = [self createFullHeaderImageView];
_headerBackgroundImageView = [self createheaderBackgroundImageView];
[_scrollContentView addSubview:_headerBackgroundImageView];
[_headerBackgroundImageView addSubview:_fullHeaderImageView];
[_fullHeaderImageView addSubview:self.headerImageView];
}
UILabel* titleLabel = self.titleLabel;
[_scrollContentView addSubview:titleLabel];
_subtitleLabel = [self createSubtitleLabel];
[_scrollContentView addSubview:_subtitleLabel];
[view addLayoutGuide:subtitleMarginLayoutGuide];
UIView* specificContentView = self.specificContentView;
[_scrollContentView addSubview:specificContentView];
UITextView* disclaimerView = self.disclaimerView;
if (disclaimerView) {
[_scrollContentView addSubview:disclaimerView];
}
// Wrap everything except the action buttons in a scroll view, to support
// dynamic types.
_scrollView = [self createScrollView];
[_scrollView addSubview:_scrollContentView];
[view addSubview:_scrollView];
// Add learn more button to top left of the view, if requested
if (self.shouldShowLearnMoreButton) {
[view insertSubview:self.learnMoreButton aboveSubview:_scrollView];
}
_actionButtonsStackView = [[UIStackView alloc] init];
_actionButtonsStackView.alignment = UIStackViewAlignmentFill;
_actionButtonsStackView.axis = UILayoutConstraintAxisVertical;
_actionButtonsStackView.translatesAutoresizingMaskIntoConstraints = NO;
[_actionButtonsStackView addArrangedSubview:self.primaryActionButton];
_actionButtonsStackView.hidden =
(self.actionButtonsVisibility == ActionButtonsVisibility::kHidden);
[view addSubview:_actionButtonsStackView];
// Create a layout guide to constrain the width of the content, while still
// allowing the scroll view to take the full screen width.
UILayoutGuide* widthLayoutGuide = AddPromoStyleWidthLayoutGuide(view);
if (disclaimerView) {
[NSLayoutConstraint activateConstraints:@[
[disclaimerView.topAnchor
constraintEqualToAnchor:specificContentView.bottomAnchor
constant:kPromoStyleDefaultMargin],
[disclaimerView.leadingAnchor
constraintEqualToAnchor:_scrollContentView.leadingAnchor],
[disclaimerView.trailingAnchor
constraintEqualToAnchor:_scrollContentView.trailingAnchor],
]];
if (self.topAlignedLayout) {
[NSLayoutConstraint activateConstraints:@[
[disclaimerView.bottomAnchor
constraintLessThanOrEqualToAnchor:_scrollContentView.bottomAnchor]
]];
} else {
[NSLayoutConstraint activateConstraints:@[
[disclaimerView.bottomAnchor
constraintEqualToAnchor:_scrollContentView.bottomAnchor]
]];
}
} else {
[_scrollContentView.bottomAnchor
constraintEqualToAnchor:specificContentView.bottomAnchor]
.active = YES;
}
NSLayoutConstraint* scrollViewTopConstraint =
self.layoutBehindNavigationBar
? [_scrollView.topAnchor constraintEqualToAnchor:view.topAnchor]
: [_scrollView.topAnchor
constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor];
[NSLayoutConstraint activateConstraints:@[
// Scroll view constraints.
scrollViewTopConstraint,
[_scrollView.leadingAnchor constraintEqualToAnchor:view.leadingAnchor],
[_scrollView.trailingAnchor constraintEqualToAnchor:view.trailingAnchor],
// Separator constraints.
[_separator.heightAnchor constraintEqualToConstant:kSeparatorHeight],
[_separator.leadingAnchor constraintEqualToAnchor:view.leadingAnchor],
[_separator.trailingAnchor constraintEqualToAnchor:view.trailingAnchor],
[_separator.topAnchor constraintEqualToAnchor:_scrollView.bottomAnchor],
// Scroll content view constraints. Constrain its height to at least the
// scroll view height, so that derived VCs can pin UI elements just above
// the buttons.
[_scrollContentView.topAnchor
constraintEqualToAnchor:_scrollView.topAnchor],
[_scrollContentView.leadingAnchor
constraintEqualToAnchor:widthLayoutGuide.leadingAnchor],
[_scrollContentView.trailingAnchor
constraintEqualToAnchor:widthLayoutGuide.trailingAnchor],
[_scrollContentView.bottomAnchor
constraintEqualToAnchor:_scrollView.bottomAnchor],
[_scrollContentView.heightAnchor
constraintGreaterThanOrEqualToAnchor:_scrollView.heightAnchor],
// Labels contraints. Attach them to the top of the scroll content view, and
// center them horizontally.
[titleLabel.centerXAnchor
constraintEqualToAnchor:_scrollContentView.centerXAnchor],
[titleLabel.widthAnchor
constraintLessThanOrEqualToAnchor:_scrollContentView.widthAnchor
constant:-2 * self.titleHorizontalMargin],
[_subtitleLabel.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor
constant:kPromoStyleDefaultMargin],
[_subtitleLabel.centerXAnchor
constraintEqualToAnchor:_scrollContentView.centerXAnchor],
[_subtitleLabel.widthAnchor
constraintLessThanOrEqualToAnchor:_scrollContentView.widthAnchor],
// Constraints for the screen-specific content view. It should take the
// remaining scroll view area, with some margins on the top and sides.
[subtitleMarginLayoutGuide.topAnchor
constraintEqualToAnchor:_subtitleLabel.bottomAnchor],
[subtitleMarginLayoutGuide.heightAnchor
constraintEqualToConstant:_subtitleBottomMargin],
[specificContentView.leadingAnchor
constraintEqualToAnchor:_scrollContentView.leadingAnchor],
[specificContentView.trailingAnchor
constraintEqualToAnchor:_scrollContentView.trailingAnchor],
// Action stack view constraints. Constrain the bottom of the action stack
// view to both the bottom of the screen and the bottom of the safe area, to
// give a nice result whether the device has a physical home button or not.
[_actionButtonsStackView.leadingAnchor
constraintEqualToAnchor:widthLayoutGuide.leadingAnchor],
[_actionButtonsStackView.trailingAnchor
constraintEqualToAnchor:widthLayoutGuide.trailingAnchor],
]];
if (self.hideSpecificContentView) {
// Hide the specificContentView and do not add the margin.
[NSLayoutConstraint activateConstraints:@[
[specificContentView.topAnchor
constraintEqualToAnchor:_subtitleLabel.bottomAnchor],
[specificContentView.heightAnchor constraintEqualToConstant:0]
]];
} else {
[NSLayoutConstraint activateConstraints:@[
[specificContentView.topAnchor
constraintEqualToAnchor:subtitleMarginLayoutGuide.bottomAnchor]
]];
}
if (self.bannerLimitWithRoundedCorner) {
// Add a subview with the same background of the view and put it over the
// banner image view.
UIView* limitView = [[UIView alloc] init];
limitView.clipsToBounds = YES;
limitView.translatesAutoresizingMaskIntoConstraints = NO;
limitView.backgroundColor = [UIColor colorNamed:kPrimaryBackgroundColor];
[_scrollContentView insertSubview:limitView
aboveSubview:self.bannerImageView];
// Corner radius cannot reach over half of the view, so set the height to 2*
// kCornerRadius.
[NSLayoutConstraint activateConstraints:@[
[limitView.centerYAnchor
constraintEqualToAnchor:_bannerImageView.bottomAnchor],
[limitView.leftAnchor constraintEqualToAnchor:self.view.leftAnchor],
[limitView.rightAnchor constraintEqualToAnchor:self.view.rightAnchor],
[limitView.heightAnchor constraintEqualToConstant:2 * kCornerRadius],
]];
limitView.layer.cornerRadius = kCornerRadius;
limitView.layer.maskedCorners =
kCALayerMaxXMinYCorner | kCALayerMinXMinYCorner;
limitView.layer.masksToBounds = true;
}
if (self.headerImageType != PromoStyleImageType::kNone) {
_headerBackgroundImageViewTopMargin = [_headerBackgroundImageView.topAnchor
constraintEqualToAnchor:self.bannerImageView.bottomAnchor];
CGFloat headerImageBottomMargin = _headerImageBottomMargin;
if (self.headerImageType == PromoStyleImageType::kAvatar) {
headerImageBottomMargin = self.headerBackgroundImage == nil
? kNoBackgroundHeaderImageBottomMargin
: kHeaderImageBackgroundBottomMargin;
}
UIImageView* headerImageView = self.headerImageView;
[NSLayoutConstraint activateConstraints:@[
_headerBackgroundImageViewTopMargin,
[titleLabel.topAnchor
constraintEqualToAnchor:_headerBackgroundImageView.bottomAnchor
constant:headerImageBottomMargin],
[_headerBackgroundImageView.centerXAnchor
constraintEqualToAnchor:_scrollContentView.centerXAnchor],
[_headerBackgroundImageView.centerXAnchor
constraintEqualToAnchor:_fullHeaderImageView.centerXAnchor],
[_headerBackgroundImageView.centerYAnchor
constraintEqualToAnchor:_fullHeaderImageView.centerYAnchor],
[_fullHeaderImageView.centerXAnchor
constraintEqualToAnchor:headerImageView.centerXAnchor],
[_fullHeaderImageView.centerYAnchor
constraintEqualToAnchor:headerImageView.centerYAnchor],
]];
if (self.headerImageType == PromoStyleImageType::kAvatar) {
[NSLayoutConstraint activateConstraints:@[
[_fullHeaderImageView.widthAnchor
constraintEqualToConstant:kFullheaderImageSize],
[_fullHeaderImageView.heightAnchor
constraintEqualToConstant:kFullheaderImageSize],
[_headerBackgroundImageView.widthAnchor
constraintGreaterThanOrEqualToConstant:kFullheaderImageSize],
[_headerBackgroundImageView.heightAnchor
constraintGreaterThanOrEqualToConstant:kFullheaderImageSize],
[headerImageView.widthAnchor
constraintEqualToConstant:kheaderImageSize],
[headerImageView.heightAnchor
constraintEqualToConstant:kheaderImageSize],
]];
}
if (self.headerImageType == PromoStyleImageType::kImage ||
self.headerImageType == PromoStyleImageType::kImageWithShadow) {
[NSLayoutConstraint activateConstraints:@[
[_fullHeaderImageView.widthAnchor
constraintEqualToAnchor:_fullHeaderImageView.heightAnchor],
[_fullHeaderImageView.widthAnchor
constraintGreaterThanOrEqualToAnchor:headerImageView.widthAnchor
constant:_headerImageShadowInset],
[_fullHeaderImageView.heightAnchor
constraintGreaterThanOrEqualToAnchor:headerImageView.heightAnchor
constant:_headerImageShadowInset],
[_headerBackgroundImageView.widthAnchor
constraintGreaterThanOrEqualToAnchor:_fullHeaderImageView
.widthAnchor],
[_headerBackgroundImageView.heightAnchor
constraintGreaterThanOrEqualToAnchor:_fullHeaderImageView
.heightAnchor],
]];
// Set low priority constraint to set the width/height according to image
// size. If image ratio is not 1:1, this will conflict with the
// height = width constraint abve and one of the 2 will be dropped.
NSLayoutConstraint* widthConstraint = [_fullHeaderImageView.widthAnchor
constraintEqualToAnchor:headerImageView.widthAnchor
constant:_headerImageShadowInset];
widthConstraint.priority = UILayoutPriorityDefaultLow;
widthConstraint.active = YES;
NSLayoutConstraint* heightConstraint = [_fullHeaderImageView.heightAnchor
constraintEqualToAnchor:headerImageView.heightAnchor
constant:_headerImageShadowInset];
heightConstraint.priority = UILayoutPriorityDefaultLow;
heightConstraint.active = YES;
}
[_headerBackgroundImageView
setContentHuggingPriority:UILayoutPriorityDefaultHigh
forAxis:UILayoutConstraintAxisHorizontal];
[_headerBackgroundImageView
setContentHuggingPriority:UILayoutPriorityDefaultHigh
forAxis:UILayoutConstraintAxisVertical];
} else {
[NSLayoutConstraint activateConstraints:@[
[titleLabel.topAnchor
constraintEqualToAnchor:self.bannerImageView.bottomAnchor
constant:_titleTopMarginWhenNoHeaderImage],
]];
}
[self setupBannerConstraints];
_buttonsVerticalAnchorConstraints = @[
[_scrollView.bottomAnchor
constraintEqualToAnchor:_actionButtonsStackView.topAnchor
constant:-kPromoStyleDefaultMargin],
[_actionButtonsStackView.bottomAnchor
constraintLessThanOrEqualToAnchor:view.bottomAnchor
constant:-kActionsBottomMarginWithoutSafeArea],
[_actionButtonsStackView.bottomAnchor
constraintLessThanOrEqualToAnchor:view.safeAreaLayoutGuide.bottomAnchor
constant:-kActionsBottomMarginWithSafeArea],
];
[NSLayoutConstraint activateConstraints:_buttonsVerticalAnchorConstraints];
// Also constrain the bottom of the action stack view to the bottom of the
// safe area, but with a lower priority, so that the action stack view is put
// as close to the bottom as possible.
NSLayoutConstraint* actionBottomConstraint =
[_actionButtonsStackView.bottomAnchor
constraintEqualToAnchor:view.safeAreaLayoutGuide.bottomAnchor];
actionBottomConstraint.priority = UILayoutPriorityDefaultLow;
actionBottomConstraint.active = YES;
if (self.shouldShowLearnMoreButton) {
UIButton* learnMoreButton = self.learnMoreButton;
[NSLayoutConstraint activateConstraints:@[
[learnMoreButton.topAnchor
constraintEqualToAnchor:_scrollContentView.topAnchor],
[learnMoreButton.leadingAnchor
constraintEqualToAnchor:view.safeAreaLayoutGuide.leadingAnchor],
[learnMoreButton.widthAnchor
constraintEqualToConstant:kLearnMoreButtonSide],
[learnMoreButton.heightAnchor
constraintEqualToConstant:kLearnMoreButtonSide],
]];
}
if (self.hideHeaderOnTallContent) {
[self updateActionButtonsAndPushUpScrollViewIfMandatory];
}
}
- (void)viewWillDisappear:(BOOL)animated {
_canUpdateViewsOnScroll = NO;
}
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
if (self.isBeingDismissed &&
[self.delegate respondsToSelector:@selector(didDismissViewController)]) {
[self.delegate didDismissViewController];
}
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
// Set `didReachBottom` to YES when `scrollToEndMandatory` is NO, since the
// screen can already be considered as fully scrolled when scrolling to the
// end isn't mandatory.
if (!self.scrollToEndMandatory) {
self.didReachBottom = YES;
}
// Only add the scroll view delegate after all the view layouts are fully
// done.
dispatch_async(dispatch_get_main_queue(), ^{
[self setupScrollView];
});
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
// Prevents potential recursive calls to `viewDidLayoutSubviews`.
if (_calculatingImageSize) {
return;
}
// Rescale image here as on iPad the view height isn't correctly set before
// subviews are laid out.
_calculatingImageSize = YES;
self.bannerImageView.image =
[self scaleBannerWithCurrentImage:self.bannerImageView.image
toSize:[self computeBannerImageSize]];
_calculatingImageSize = NO;
if (_shouldScrollToBottom) {
_shouldScrollToBottom = NO;
dispatch_async(dispatch_get_main_queue(), ^{
[self scrollToBottom];
});
}
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
// Update the buttons once the layout changes take effect to have the right
// measurements to evaluate the scroll position.
void (^transition)(id<UIViewControllerTransitionCoordinatorContext>) =
^(id<UIViewControllerTransitionCoordinatorContext> context) {
[self updateViewsOnScrollViewUpdate];
[self hideHeaderOnTallContentIfNeeded];
};
[coordinator animateAlongsideTransition:transition completion:nil];
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
if (!self.topAlignedLayout) {
CGFloat headerImageTopMarginPercentage =
self.headerBackgroundImage == nil
? _noBackgroundHeaderImageTopMarginPercentage
: kHeaderImageBackgroundTopMarginPercentage;
_headerBackgroundImageViewTopMargin.constant = AlignValueToPixel(
self.view.bounds.size.height * headerImageTopMarginPercentage);
}
}
- (void)setActionButtonsVisibility:(ActionButtonsVisibility)visibility {
if (_actionButtonsVisibility == visibility) {
return;
}
// Visibility should not be reverted to kDefault.
DCHECK(visibility != ActionButtonsVisibility::kDefault);
_actionButtonsVisibility = visibility;
// On hidden visibility, hide the entire button stack view and the disclaimer
// view above it.
if (visibility == ActionButtonsVisibility::kHidden) {
if (_actionButtonsStackView) {
_actionButtonsStackView.hidden = YES;
}
self.disclaimerView.hidden = YES;
return;
}
// On unhiding, the primary action button will have updated style based
// on actionButtonsVisibility.
if (self.primaryActionString) {
[self setPrimaryActionButtonColor:self.primaryActionButton];
}
// The secondary action button has button type based on
// actionButtonsVisibility and should be recreated.
if (_secondaryActionButton) {
// Remove the current secondary button from view.
[_secondaryActionButton removeFromSuperview];
_secondaryActionButton = [self createSecondaryActionButton];
[_actionButtonsStackView insertArrangedSubview:_secondaryActionButton
atIndex:1];
[self updateActionButtonsSpacing];
}
// Fade the buttons and disclaimer text in if they are hidden.
if (_actionButtonsStackView.hidden) {
_actionButtonsStackView.alpha = 0;
_actionButtonsStackView.hidden = NO;
self.disclaimerView.alpha = 0;
self.disclaimerView.hidden = NO;
__weak __typeof(self) weakSelf = self;
[UIView animateWithDuration:kAnimationDuration.InSecondsF()
animations:^{
PromoStyleViewController* strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf updateActionButtonsStackAlpha:1.0];
strongSelf.disclaimerView.alpha = 1.0;
}
completion:nil];
}
}
- (void)setPrimaryButtonSpinnerEnabled:(BOOL)enabled {
if (_primaryButtonSpinnerEnabled == enabled) {
return;
}
_primaryButtonSpinnerEnabled = enabled;
if (enabled) {
CHECK(!self.primaryButtonActivityIndicatorView);
CHECK(self.primaryActionString);
// Disable the button.
self.primaryActionButton.enabled = NO;
// Set blank button text and set accessibility label.
SetConfigurationTitle(self.primaryActionButton, @" ");
[self.primaryActionButton setAccessibilityLabel:self.primaryActionString];
// Create the spinner overlay.
self.primaryButtonActivityIndicatorView =
[[UIActivityIndicatorView alloc] init];
self.primaryButtonActivityIndicatorView
.translatesAutoresizingMaskIntoConstraints = NO;
self.primaryButtonActivityIndicatorView.color =
[UIColor colorNamed:kPrimaryBackgroundColor];
// Add the spinner to the primary button.
[self.primaryActionButton
addSubview:self.primaryButtonActivityIndicatorView];
AddSameCenterConstraints(self.primaryButtonActivityIndicatorView,
self.primaryActionButton);
[self.primaryButtonActivityIndicatorView startAnimating];
} else {
CHECK(self.primaryButtonActivityIndicatorView);
// Remove the spinner.
[self.primaryButtonActivityIndicatorView removeFromSuperview];
self.primaryButtonActivityIndicatorView = nil;
self.primaryActionButton.enabled = YES;
// Reset the button text and accessibility label.
SetConfigurationTitle(self.primaryActionButton, self.primaryActionString);
self.primaryActionButton.accessibilityLabel = nil;
}
}
#pragma mark - UITraitEnvironment
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
// Reset the title font and the learn more text to make sure that they are
// properly scaled. Nothing will be done for the Read More text if the
// bottom is reached.
self.titleLabel.font = GetFRETitleFont(self.titleLabelFontTextStyle);
[self setReadMoreText];
// Update the primary button once the layout changes take effect to have the
// right measurements to evaluate the scroll position.
dispatch_async(dispatch_get_main_queue(), ^{
[self updateViewsOnScrollViewUpdate];
[self hideHeaderOnTallContentIfNeeded];
});
}
#pragma mark - Accessors
- (void)setShouldBannerFillTopSpace:(BOOL)shouldBannerFillTopSpace {
_shouldBannerFillTopSpace = shouldBannerFillTopSpace;
[self setupBannerConstraints];
self.bannerImageView.image =
[self scaleBannerWithCurrentImage:self.bannerImageView.image
toSize:[self computeBannerImageSize]];
}
- (void)setShouldHideBanner:(BOOL)shouldHideBanner {
_shouldHideBanner = shouldHideBanner;
[self setupBannerConstraints];
self.bannerImageView.image =
[self scaleBannerWithCurrentImage:self.bannerImageView.image
toSize:[self computeBannerImageSize]];
}
- (void)setPrimaryActionString:(NSString*)text {
_primaryActionString = text;
// Change the button's label, unless scrolling to the end is mandatory and the
// scroll view hasn't been scrolled to the end at least once yet.
if (_primaryActionButton &&
(!self.scrollToEndMandatory || self.didReachBottom)) {
UIButtonConfiguration* buttonConfiguration =
_primaryActionButton.configuration;
buttonConfiguration.attributedTitle = nil;
buttonConfiguration.title = _primaryActionString;
_primaryActionButton.configuration = buttonConfiguration;
[self setPrimaryActionButtonFont:_primaryActionButton];
}
}
- (UIImageView*)bannerImageView {
if (!_bannerImageView) {
_bannerImageView = [[UIImageView alloc]
initWithImage:
[self scaleBannerWithCurrentImage:nil
toSize:[self computeBannerImageSize]]];
_bannerImageView.clipsToBounds = YES;
_bannerImageView.translatesAutoresizingMaskIntoConstraints = NO;
}
return _bannerImageView;
}
- (UIImageView*)headerImageView {
if (!_headerImageView) {
DCHECK(self.headerImageType != PromoStyleImageType::kNone);
_headerImageView = [[UIImageView alloc] initWithImage:self.headerImage];
_headerImageView.clipsToBounds = YES;
_headerImageView.translatesAutoresizingMaskIntoConstraints = NO;
if (self.headerImageType == PromoStyleImageType::kAvatar) {
_headerImageView.layer.cornerRadius = kheaderImageSize / 2.;
}
_headerImageView.image = _headerImage;
_headerImageView.accessibilityLabel = _headerAccessibilityLabel;
_headerImageView.isAccessibilityElement = _headerAccessibilityLabel != nil;
if (self.headerViewForceStyleLight) {
_headerImageView.overrideUserInterfaceStyle = UIUserInterfaceStyleLight;
}
}
return _headerImageView;
}
- (void)setHeaderImage:(UIImage*)headerImage {
_headerImage = headerImage;
if (self.headerImageType == PromoStyleImageType::kAvatar) {
DCHECK_EQ(headerImage.size.width, kheaderImageSize);
DCHECK_EQ(headerImage.size.height, kheaderImageSize);
}
// `self.headerImageView` should not be used to avoid creating the image.
// The owner might set the image first and then change the value of
// `self.headerImageType`.
_headerImageView.image = headerImage;
}
- (void)setHeaderAccessibilityLabel:(NSString*)headerAccessibilityLabel {
_headerAccessibilityLabel = headerAccessibilityLabel;
// `self.headerImageView` should not be used to avoid creating the image.
// The owner might set the accessibility label and then change the value of
// `self.headerImageType`.
_headerImageView.accessibilityLabel = headerAccessibilityLabel;
_headerImageView.isAccessibilityElement = headerAccessibilityLabel != nil;
}
- (UILabel*)titleLabel {
if (!_titleLabel) {
_titleLabel = [[UILabel alloc] init];
_titleLabel.numberOfLines = 0;
_titleLabel.font = GetFRETitleFont(self.titleLabelFontTextStyle);
_titleLabel.textColor = [UIColor colorNamed:kTextPrimaryColor];
_titleLabel.text = self.titleText;
_titleLabel.textAlignment = NSTextAlignmentCenter;
_titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
_titleLabel.adjustsFontForContentSizeCategory = YES;
_titleLabel.accessibilityIdentifier =
kPromoStyleTitleAccessibilityIdentifier;
_titleLabel.accessibilityTraits |= UIAccessibilityTraitHeader;
}
return _titleLabel;
}
- (UIView*)specificContentView {
if (!_specificContentView) {
_specificContentView = [[UIView alloc] init];
_specificContentView.translatesAutoresizingMaskIntoConstraints = NO;
}
return _specificContentView;
}
- (HighlightButton*)createHighlightButtonWithText:(NSString*)buttonText
accessibilityIdentifier:
(NSString*)accessibilityIdentifier {
UIButtonConfiguration* buttonConfiguration =
[UIButtonConfiguration plainButtonConfiguration];
buttonConfiguration.contentInsets = NSDirectionalEdgeInsetsMake(
kButtonVerticalInsets, 0, kButtonVerticalInsets, 0);
buttonConfiguration.titlePadding = kMoreArrowMargin;
buttonConfiguration.background.cornerRadius = kPrimaryButtonCornerRadius;
buttonConfiguration.title = buttonText;
buttonConfiguration.titleLineBreakMode = NSLineBreakByTruncatingTail;
HighlightButton* button = [[HighlightButton alloc] initWithFrame:CGRectZero];
button.configuration = buttonConfiguration;
[self setPrimaryActionButtonFont:button];
[self setPrimaryActionButtonColor:button];
button.translatesAutoresizingMaskIntoConstraints = NO;
button.pointerInteractionEnabled = YES;
button.pointerStyleProvider = CreateOpaqueButtonPointerStyleProvider();
button.accessibilityIdentifier = accessibilityIdentifier;
return button;
}
- (UIButton*)primaryActionButton {
if (!_primaryActionButton) {
// Use `primaryActionString` even if scrolling to the end is mandatory
// because at the viewDidLoad stage, the scroll view hasn't computed its
// content height, so there is no way to know if scrolling is needed.
// This label will be updated at the viewDidAppear stage if necessary.
_primaryActionButton =
[self createHighlightButtonWithText:self.primaryActionString
accessibilityIdentifier:
kPromoStylePrimaryActionAccessibilityIdentifier];
[_primaryActionButton addTarget:self
action:@selector(didTapPrimaryActionButton)
forControlEvents:UIControlEventTouchUpInside];
_primaryActionButton.configurationUpdateHandler = self.updateHandler;
_primaryActionButton.enabled = _primaryButtonEnabled;
_primaryActionButton.hidden =
(self.actionButtonsVisibility == ActionButtonsVisibility::kHidden);
}
return _primaryActionButton;
}
- (UITextView*)disclaimerView {
if (!self.disclaimerText) {
return nil;
}
if (!_disclaimerView) {
// Set up disclaimer view.
_disclaimerView = CreateUITextViewWithTextKit1();
_disclaimerView.accessibilityIdentifier =
kPromoStyleDisclaimerViewAccessibilityIdentifier;
_disclaimerView.textContainerInset = UIEdgeInsetsMake(0, 0, 0, 0);
_disclaimerView.scrollEnabled = NO;
_disclaimerView.editable = NO;
_disclaimerView.adjustsFontForContentSizeCategory = YES;
_disclaimerView.delegate = self;
_disclaimerView.backgroundColor = UIColor.clearColor;
_disclaimerView.linkTextAttributes =
@{NSForegroundColorAttributeName : [UIColor colorNamed:kBlueColor]};
_disclaimerView.translatesAutoresizingMaskIntoConstraints = NO;
_disclaimerView.attributedText = [self attributedStringForDisclaimer];
}
return _disclaimerView;
}
- (void)setDisclaimerText:(NSString*)disclaimerText {
_disclaimerText = disclaimerText;
NSAttributedString* attributedText = [self attributedStringForDisclaimer];
if (attributedText) {
self.disclaimerView.attributedText = attributedText;
}
}
- (void)setDisclaimerURLs:(NSArray<NSURL*>*)disclaimerURLs {
_disclaimerURLs = disclaimerURLs;
NSAttributedString* attributedText = [self attributedStringForDisclaimer];
if (attributedText) {
self.disclaimerView.attributedText = attributedText;
}
}
// Helper to create the learn more button.
- (UIButton*)learnMoreButton {
if (!_learnMoreButton) {
DCHECK(self.shouldShowLearnMoreButton);
_learnMoreButton = [UIButton buttonWithType:UIButtonTypeSystem];
[_learnMoreButton setImage:[UIImage imageNamed:@"help_icon"]
forState:UIControlStateNormal];
_learnMoreButton.accessibilityIdentifier =
kPromoStyleLearnMoreActionAccessibilityIdentifier;
_learnMoreButton.translatesAutoresizingMaskIntoConstraints = NO;
[_learnMoreButton addTarget:self
action:@selector(didTapLearnMoreButton)
forControlEvents:UIControlEventTouchUpInside];
}
return _learnMoreButton;
}
- (void)setPrimaryButtonEnabled:(BOOL)primaryButtonEnabled {
_primaryButtonEnabled = primaryButtonEnabled;
if (_primaryActionButton) {
_primaryActionButton.enabled = primaryButtonEnabled;
}
}
#pragma mark - Private
// Updates banner constraints.
- (void)setupBannerConstraints {
if (_scrollContentView == nil) {
return;
}
if (_bannerConstraints != nil) {
[NSLayoutConstraint deactivateConstraints:_bannerConstraints];
}
_bannerConstraints = @[
// Common banner image constraints, further constraints are added below.
// This one ensures the banner is well centered within the view.
[self.bannerImageView.centerXAnchor
constraintEqualToAnchor:self.view.centerXAnchor],
];
if (self.shouldHideBanner) {
_bannerConstraints = [_bannerConstraints arrayByAddingObjectsFromArray:@[
[self.bannerImageView.heightAnchor constraintEqualToConstant:0],
[self.bannerImageView.topAnchor
constraintEqualToAnchor:_scrollContentView.topAnchor
constant:kPromoStyleDefaultMargin]
]];
} else if (self.shouldBannerFillTopSpace) {
NSLayoutDimension* dimFromToOfViewToBottomOfBanner = [self.view.topAnchor
anchorWithOffsetToAnchor:self.bannerImageView.bottomAnchor];
// Constrain bottom of banner to top of view + C * height of view
// where C = isTallBanner ? tallMultiplier : defaultMultiplier.
_bannerConstraints = [_bannerConstraints arrayByAddingObjectsFromArray:@[
[dimFromToOfViewToBottomOfBanner
constraintEqualToAnchor:self.view.heightAnchor
multiplier:[self bannerMultiplier]]
]];
} else {
// Default.
_bannerConstraints = [_bannerConstraints arrayByAddingObjectsFromArray:@[
[self.bannerImageView.topAnchor
constraintEqualToAnchor:_scrollContentView.topAnchor],
]];
}
[NSLayoutConstraint activateConstraints:_bannerConstraints];
}
- (UIImage*)bannerImage {
if (self.shouldHideBanner && !self.bannerName) {
return [[UIImage alloc] init];
}
return [UIImage imageNamed:self.bannerName];
}
// Computes banner's image size.
- (CGSize)computeBannerImageSize {
if (self.shouldHideBanner) {
return CGSizeZero;
}
CGFloat bannerMultiplier = [self bannerMultiplier];
CGFloat bannerAspectRatio =
[self bannerImage].size.width / [self bannerImage].size.height;
CGFloat destinationHeight = 0;
CGFloat destinationWidth = 0;
if (!self.shouldBannerFillTopSpace) {
destinationHeight = roundf(self.view.bounds.size.height * bannerMultiplier);
destinationWidth = roundf(bannerAspectRatio * destinationHeight);
} else {
CGFloat minBannerWidth = self.view.bounds.size.width;
CGFloat minBannerHeight = self.view.bounds.size.height * bannerMultiplier;
destinationWidth =
roundf(fmax(minBannerWidth, bannerAspectRatio * minBannerHeight));
destinationHeight = roundf(bannerAspectRatio * destinationWidth);
}
CGSize newSize = CGSizeMake(destinationWidth, destinationHeight);
return newSize;
}
- (CGFloat)bannerMultiplier {
switch (self.bannerSize) {
case BannerImageSizeType::kShort:
return kShortBannerMultiplier;
case BannerImageSizeType::kStandard:
return kDefaultBannerMultiplier;
case BannerImageSizeType::kTall:
return kTallBannerMultiplier;
case BannerImageSizeType::kExtraTall:
return kExtraTallBannerMultiplier;
}
}
// Returns a new UIImage which is `sourceImage` resized to `newSize`. Returns
// `currentImage` if it is already at the correct size.
- (UIImage*)scaleBannerWithCurrentImage:(UIImage*)currentImage
toSize:(CGSize)newSize {
UIUserInterfaceStyle currentStyle =
UITraitCollection.currentTraitCollection.userInterfaceStyle;
if (CGSizeEqualToSize(newSize, currentImage.size) &&
_bannerStyle == currentStyle) {
return currentImage;
}
_bannerStyle = currentStyle;
return ResizeImage([self bannerImage], newSize, ProjectionMode::kAspectFit);
}
// Determines which font text style to use depending on the device size, the
// size class and if dynamic type is enabled.
- (UIFontTextStyle)titleLabelFontTextStyle {
return GetTitleLabelFontTextStyle(self);
}
- (void)setPrimaryActionButtonFont:(UIButton*)button {
DCHECK(button.configuration.title);
UIButtonConfiguration* buttonConfiguration = button.configuration;
UIFont* font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline];
NSDictionary* attributes = @{NSFontAttributeName : font};
NSMutableAttributedString* string = [[NSMutableAttributedString alloc]
initWithString:button.configuration.title];
[string addAttributes:attributes range:NSMakeRange(0, string.length)];
buttonConfiguration.attributedTitle = string;
button.configuration = buttonConfiguration;
}
- (void)setPrimaryActionButtonColor:(UIButton*)button {
UIButtonConfiguration* buttonConfiguration = button.configuration;
BOOL useEquallyWeightedButtons =
(self.actionButtonsVisibility ==
ActionButtonsVisibility::kEquallyWeightedButtonShown);
buttonConfiguration.background.backgroundColor =
useEquallyWeightedButtons ? [UIColor colorNamed:kBlueHaloColor]
: [UIColor colorNamed:kBlueColor];
buttonConfiguration.baseForegroundColor =
useEquallyWeightedButtons ? [UIColor colorNamed:kBlueColor]
: [UIColor colorNamed:kSolidButtonTextColor];
button.configuration = buttonConfiguration;
}
// Sets or resets the "Read More" text label when the bottom hasn't been
// reached yet and scrolling to the end is mandatory.
- (void)setReadMoreText {
if (!self.scrollToEndMandatory) {
return;
}
if (self.didReachBottom) {
return;
}
if (!_canUpdateViewsOnScroll) {
return;
}
DCHECK(self.readMoreString);
NSDictionary* textAttributes = @{
NSForegroundColorAttributeName : [UIColor colorNamed:kSolidButtonTextColor],
NSFontAttributeName :
[UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]
};
NSMutableAttributedString* attributedString =
[[NSMutableAttributedString alloc] initWithString:self.readMoreString
attributes:textAttributes];
// Use `ceilf()` when calculating the icon's bounds to ensure the
// button's content height does not shrink by fractional points, as the
// attributed string's actual height is slightly smaller than the
// assigned height.
NSTextAttachment* attachment = [[NSTextAttachment alloc] init];
attachment.image = [[UIImage imageNamed:@"read_more_arrow"]
imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
CGFloat height = ceilf(attributedString.size.height);
CGFloat capHeight = ceilf(
[UIFont preferredFontForTextStyle:UIFontTextStyleHeadline].capHeight);
CGFloat horizontalOffset =
base::i18n::IsRTL() ? -1.f * kMoreArrowMargin : kMoreArrowMargin;
CGFloat verticalOffset = (capHeight - height) / 2.f;
attachment.bounds =
CGRectMake(horizontalOffset, verticalOffset, height, height);
[attributedString
appendAttributedString:[NSAttributedString
attributedStringWithAttachment:attachment]];
self.primaryActionButton.accessibilityIdentifier =
kPromoStyleReadMoreActionAccessibilityIdentifier;
// Make the title change without animation, as the UIButton's default
// animation when using setTitle:forState: doesn't handle adding a
// UIImage well (the old title gets abruptly pushed to the side as it's
// fading out to make room for the new image, which looks awkward).
__weak PromoStyleViewController* weakSelf = self;
[UIView performWithoutAnimation:^{
UIButtonConfiguration* buttonConfiguration =
weakSelf.primaryActionButton.configuration;
buttonConfiguration.attributedTitle = attributedString;
weakSelf.primaryActionButton.configuration = buttonConfiguration;
[weakSelf.primaryActionButton layoutIfNeeded];
}];
}
- (UIButton*)createButtonWithText:(NSString*)buttonText
accessibilityIdentifier:(NSString*)accessibilityIdentifier {
UIButton* button = [UIButton buttonWithType:UIButtonTypeSystem];
UIButtonConfiguration* buttonConfiguration =
[UIButtonConfiguration plainButtonConfiguration];
buttonConfiguration.title = buttonText;
buttonConfiguration.background.backgroundColor = [UIColor clearColor];
buttonConfiguration.baseForegroundColor = [UIColor colorNamed:kBlueColor];
buttonConfiguration.contentInsets = NSDirectionalEdgeInsetsMake(
kButtonVerticalInsets, 0, kButtonVerticalInsets, 0);
UIFont* font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
NSDictionary* attributes = @{NSFontAttributeName : font};
NSMutableAttributedString* string =
[[NSMutableAttributedString alloc] initWithString:buttonText];
[string addAttributes:attributes range:NSMakeRange(0, string.length)];
buttonConfiguration.attributedTitle = string;
buttonConfiguration.titleLineBreakMode = NSLineBreakByTruncatingTail;
button.configuration = buttonConfiguration;
button.translatesAutoresizingMaskIntoConstraints = NO;
button.titleLabel.adjustsFontForContentSizeCategory = YES;
button.accessibilityIdentifier = accessibilityIdentifier;
button.pointerInteractionEnabled = YES;
button.pointerStyleProvider = CreateOpaqueButtonPointerStyleProvider();
return button;
}
// Add the scroll view delegate and setup the content views. Should be done only
// once all the view layouts are fully done.
- (void)setupScrollView {
_scrollView.delegate = self;
_canUpdateViewsOnScroll = YES;
// At this point, the scroll view has computed its content height. If
// scrolling to the end is needed, and the entire content is already
// fully visible (scrolled), set `didReachBottom` to YES. Otherwise, replace
// the primary button's label with the read more label to indicate that more
// scrolling is required.
_scrollViewBottomOffsetY = _scrollView.contentSize.height -
_scrollView.bounds.size.height +
_scrollView.contentInset.bottom;
BOOL isScrolledToBottom = [self isScrolledToBottom];
_separator.hidden = isScrolledToBottom;
if (self.didReachBottom || isScrolledToBottom) {
[self updateActionButtonsAndPushUpScrollViewIfMandatory];
} else {
[self setReadMoreText];
}
}
// Returns whether the scroll view's offset has reached the scroll view's
// content height, indicating that the scroll view has been fully scrolled.
- (BOOL)isScrolledToBottom {
CGFloat scrollPosition =
_scrollView.contentOffset.y + _scrollView.frame.size.height;
CGFloat scrollLimit =
_scrollView.contentSize.height + _scrollView.contentInset.bottom;
return scrollPosition >= scrollLimit;
}
- (void)scrollToBottom {
CGFloat scrollLimit = _scrollView.contentSize.height -
_scrollView.bounds.size.height +
_scrollView.contentInset.bottom;
[_scrollView setContentOffset:CGPointMake(0, scrollLimit) animated:YES];
}
// If scrolling to the end of the content is mandatory, this method updates the
// action buttons based on whether the scroll view is currently scrolled to the
// end. If the scroll view has scrolled to the end, also sets `didReachBottom`.
// It also updates the separator visibility based on scroll position.
- (void)updateViewsOnScrollViewUpdate {
if (!_canUpdateViewsOnScroll) {
return;
}
BOOL isScrolledToBottom = [self isScrolledToBottom];
_separator.hidden = isScrolledToBottom;
if (self.scrollToEndMandatory && !self.didReachBottom && isScrolledToBottom) {
[self updateActionButtonsAndPushUpScrollViewIfMandatory];
}
}
// This method should be called right before the view is scrolled to the bottom.
// It updates the primary button's label and adds secondary and/or tertiary
// buttons, and as a result, pushing the scroll view up by updating the bottom
// offset of the scroll view and scroll to the new offset if the change in
// action buttons is triggered by a scroll in a view that sets
// `self.scrollToEndMandatory=YES`. It also sets `self.didReachBottom` to YES.
- (void)updateActionButtonsAndPushUpScrollViewIfMandatory {
if (_buttonUpdated) {
return;
}
_buttonUpdated = YES;
HighlightButton* primaryActionButton = self.primaryActionButton;
UIButtonConfiguration* buttonConfiguration =
primaryActionButton.configuration;
buttonConfiguration.attributedTitle = nil;
buttonConfiguration.title = self.primaryActionString;
primaryActionButton.configuration = buttonConfiguration;
primaryActionButton.accessibilityIdentifier =
kPromoStylePrimaryActionAccessibilityIdentifier;
// Reset the font to make sure it is properly scaled.
[self setPrimaryActionButtonFont:primaryActionButton];
// Add other buttons with the correct margins.
if (self.secondaryActionString) {
_secondaryActionButton = [self createSecondaryActionButton];
[_actionButtonsStackView insertArrangedSubview:_secondaryActionButton
atIndex:1];
[self updateActionButtonsSpacing];
}
if (self.tertiaryActionString) {
_tertiaryActionButton = [self createTertiaryActionButton];
[_actionButtonsStackView insertArrangedSubview:_tertiaryActionButton
atIndex:0];
}
if (self.secondaryActionString || self.tertiaryActionString) {
// Update constraints.
[NSLayoutConstraint
deactivateConstraints:_buttonsVerticalAnchorConstraints];
_buttonsVerticalAnchorConstraints = @[
[_scrollView.bottomAnchor
constraintEqualToAnchor:_actionButtonsStackView.topAnchor
constant:self.tertiaryActionString
? 0
: -kPromoStyleDefaultMargin],
[_actionButtonsStackView.bottomAnchor
constraintLessThanOrEqualToAnchor:self.view.bottomAnchor
constant:-kActionsBottomMarginWithSafeArea],
[_actionButtonsStackView.bottomAnchor
constraintLessThanOrEqualToAnchor:self.view.safeAreaLayoutGuide
.bottomAnchor],
];
[NSLayoutConstraint activateConstraints:_buttonsVerticalAnchorConstraints];
}
if (self.scrollToEndMandatory) {
_shouldScrollToBottom = YES;
} else if (self.hideHeaderOnTallContent) {
dispatch_async(dispatch_get_main_queue(), ^{
[self hideHeaderOnTallContentIfNeeded];
});
}
self.didReachBottom = YES;
}
- (void)didTapPrimaryActionButton {
if (self.scrollToEndMandatory && !self.didReachBottom) {
// Calculate the offset needed to see the next content while keeping the
// current content partially visible.
CGFloat currentOffsetY = _scrollView.contentOffset.y;
CGPoint targetOffset = CGPointMake(
0, currentOffsetY + _scrollView.bounds.size.height *
(1.0 - kPreviousContentVisibleOnScroll));
// Add one point to maximum possible offset to work around some issues when
// the fonts are increased.
if (targetOffset.y < _scrollViewBottomOffsetY + 1) {
[_scrollView setContentOffset:targetOffset animated:YES];
} else {
[self updateActionButtonsAndPushUpScrollViewIfMandatory];
}
} else if ([self.delegate
respondsToSelector:@selector(didTapPrimaryActionButton)]) {
[self.delegate didTapPrimaryActionButton];
}
}
- (void)didTapSecondaryActionButton {
DCHECK(self.secondaryActionString);
if ([self.delegate
respondsToSelector:@selector(didTapSecondaryActionButton)]) {
[self.delegate didTapSecondaryActionButton];
}
}
- (void)didTapTertiaryActionButton {
DCHECK(self.tertiaryActionString);
if ([self.delegate
respondsToSelector:@selector(didTapTertiaryActionButton)]) {
[self.delegate didTapTertiaryActionButton];
}
}
// Handle taps on the help button.
- (void)didTapLearnMoreButton {
DCHECK(self.shouldShowLearnMoreButton);
if ([self.delegate respondsToSelector:@selector(didTapLearnMoreButton)]) {
[self.delegate didTapLearnMoreButton];
}
}
- (UIFontTextStyle)disclaimerLabelFontTextStyle {
return UIFontTextStyleCaption2;
}
// Helper class that returns the an NSAttributedString generated from the
// current disclaimer text and URLs.
- (NSAttributedString*)attributedStringForDisclaimer {
StringWithTags parsedString = ParseStringWithLinks(self.disclaimerText);
if (parsedString.ranges.size() != [self.disclaimerURLs count]) {
return nil;
}
NSMutableParagraphStyle* paragraphStyle =
[[NSParagraphStyle defaultParagraphStyle] mutableCopy];
paragraphStyle.alignment = NSTextAlignmentCenter;
NSDictionary* textAttributes = @{
NSForegroundColorAttributeName : [UIColor colorNamed:kTextSecondaryColor],
NSFontAttributeName :
[UIFont preferredFontForTextStyle:[self disclaimerLabelFontTextStyle]],
NSParagraphStyleAttributeName : paragraphStyle
};
NSMutableAttributedString* attributedText =
[[NSMutableAttributedString alloc] initWithString:parsedString.string
attributes:textAttributes];
size_t index = 0;
for (NSURL* url in self.disclaimerURLs) {
[attributedText addAttribute:NSLinkAttributeName
value:url
range:parsedString.ranges[index]];
index += 1;
}
return attributedText;
}
- (UIScrollView*)createScrollView {
UIScrollView* scrollView = [[UIScrollView alloc] init];
scrollView.translatesAutoresizingMaskIntoConstraints = NO;
scrollView.accessibilityIdentifier =
kPromoStyleScrollViewAccessibilityIdentifier;
return scrollView;
}
- (UIView*)createFullHeaderImageView {
switch (self.headerImageType) {
case PromoStyleImageType::kAvatar: {
UIImage* circleImage = [UIImage imageNamed:@"promo_style_avatar_circle"];
DCHECK(circleImage);
UIImageView* imageView = [[UIImageView alloc] initWithImage:circleImage];
imageView.translatesAutoresizingMaskIntoConstraints = NO;
return imageView;
}
case PromoStyleImageType::kImageWithShadow: {
UIView* frameView = [[UIView alloc] init];
frameView.translatesAutoresizingMaskIntoConstraints = NO;
frameView.backgroundColor = [UIColor colorNamed:kBackgroundColor];
if (self.headerViewForceStyleLight) {
frameView.overrideUserInterfaceStyle = UIUserInterfaceStyleLight;
}
frameView.layer.cornerRadius = kHeaderImageCornerRadius;
frameView.layer.shadowOffset =
CGSizeMake(kHeaderImageShadowOffsetX, kHeaderImageShadowOffsetY);
frameView.layer.shadowRadius = kHeaderImageShadowRadius;
frameView.layer.shadowOpacity = kHeaderImageShadowOpacity;
return frameView;
}
case PromoStyleImageType::kImage: {
UIView* frameView = [[UIView alloc] init];
frameView.translatesAutoresizingMaskIntoConstraints = NO;
return frameView;
}
case PromoStyleImageType::kNone:
NOTREACHED();
}
}
- (UIImageView*)createheaderBackgroundImageView {
CHECK(self.headerImageType != PromoStyleImageType::kNone);
UIImageView* imageView =
[[UIImageView alloc] initWithImage:self.headerBackgroundImage];
imageView.translatesAutoresizingMaskIntoConstraints = NO;
imageView.accessibilityIdentifier =
kPromoStyleHeaderViewBackgroundAccessibilityIdentifier;
return imageView;
}
- (UILabel*)createSubtitleLabel {
UILabel* subtitleLabel = [[UILabel alloc] init];
subtitleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
subtitleLabel.numberOfLines = 0;
subtitleLabel.textColor = [UIColor colorNamed:kGrey800Color];
subtitleLabel.text = self.subtitleText;
subtitleLabel.textAlignment = NSTextAlignmentCenter;
subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
subtitleLabel.adjustsFontForContentSizeCategory = YES;
subtitleLabel.accessibilityIdentifier =
kPromoStyleSubtitleAccessibilityIdentifier;
return subtitleLabel;
}
- (UIButton*)createSecondaryActionButton {
DCHECK(self.secondaryActionString);
UIButton* button;
if (self.actionButtonsVisibility ==
ActionButtonsVisibility::kEquallyWeightedButtonShown) {
// Create the secondaryActionButton matching the button type, colors, and
// text style of the primaryActionButton.
button = [self createHighlightButtonWithText:self.secondaryActionString
accessibilityIdentifier:
kPromoStyleSecondaryActionAccessibilityIdentifier];
} else {
button = [self createButtonWithText:self.secondaryActionString
accessibilityIdentifier:
kPromoStyleSecondaryActionAccessibilityIdentifier];
UILabel* titleLabel = button.titleLabel;
titleLabel.adjustsFontSizeToFitWidth = YES;
titleLabel.minimumScaleFactor = 0.7;
}
[button addTarget:self
action:@selector(didTapSecondaryActionButton)
forControlEvents:UIControlEventTouchUpInside];
return button;
}
- (UIButton*)createTertiaryActionButton {
DCHECK(self.tertiaryActionString);
UIButton* button = [self
createButtonWithText:self.tertiaryActionString
accessibilityIdentifier:kPromoStyleTertiaryActionAccessibilityIdentifier];
[button addTarget:self
action:@selector(didTapTertiaryActionButton)
forControlEvents:UIControlEventTouchUpInside];
return button;
}
- (void)hideHeaderOnTallContentIfNeeded {
// Once hidden, the header will not reappear.
if (!self.hideHeaderOnTallContent || !_canUpdateViewsOnScroll ||
_fullHeaderImageView.hidden) {
return;
}
CHECK(self.headerImageType != PromoStyleImageType::kNone);
const BOOL contentFits = [self isScrolledToBottom];
if (contentFits) {
return;
}
_fullHeaderImageView.hidden = YES;
_headerBackgroundImageView.hidden = YES;
_headerImageView.hidden = YES;
if (!_titleLabelNoHeaderTopMargin) {
_titleLabelNoHeaderTopMargin = [_titleLabel.topAnchor
constraintEqualToAnchor:_scrollContentView.topAnchor
constant:kTitleNoHeaderTopMargin];
}
_titleLabelNoHeaderTopMargin.active = YES;
_headerBackgroundImageViewTopMargin.active = NO;
[_scrollView layoutIfNeeded];
[self updateViewsOnScrollViewUpdate];
}
- (void)updateActionButtonsSpacing {
switch (self.actionButtonsVisibility) {
case ActionButtonsVisibility::kEquallyWeightedButtonShown:
// Spacing is needed when all buttons have filled background.
[_actionButtonsStackView
setCustomSpacing:kStackViewEquallyWeightedButtonSpacing
afterView:_primaryActionButton];
break;
case ActionButtonsVisibility::kRegularButtonsShown:
[_actionButtonsStackView setCustomSpacing:kStackViewDefaultButtonSpacing
afterView:_primaryActionButton];
break;
default:
// Do not add button spacing by default or when buttons are hidden.
break;
}
}
- (void)updateActionButtonsStackAlpha:(CGFloat)alpha {
if (_actionButtonsStackView) {
_actionButtonsStackView.alpha = alpha;
}
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
[self updateViewsOnScrollViewUpdate];
}
#pragma mark - UITextViewDelegate
- (BOOL)textView:(UITextView*)textView
shouldInteractWithURL:(NSURL*)URL
inRange:(NSRange)characterRange
interaction:(UITextItemInteraction)interaction {
if (textView == self.disclaimerView &&
[self.delegate respondsToSelector:@selector(didTapURLInDisclaimer:)]) {
[self.delegate didTapURLInDisclaimer:URL];
}
return NO;
}
- (void)textViewDidChangeSelection:(UITextView*)textView {
// Always force the `selectedTextRange` to `nil` to prevent users from
// selecting text. Setting the `selectable` property to `NO` doesn't help
// since it makes links inside the text view untappable. Another solution is
// to subclass `UITextView` and override `canBecomeFirstResponder` to return
// NO, but that workaround only works on iOS 13.5+. This is the simplest
// approach that works well on iOS 12, 13 & 14.
textView.selectedTextRange = nil;
}
@end