chromium/ios/chrome/common/ui/confirmation_alert/confirmation_alert_view_controller.mm

// Copyright 2020 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/confirmation_alert/confirmation_alert_view_controller.h"

#import "base/check.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/confirmation_alert/confirmation_alert_action_handler.h"
#import "ios/chrome/common/ui/confirmation_alert/constants.h"
#import "ios/chrome/common/ui/elements/gradient_view.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/dynamic_type_util.h"
#import "ios/chrome/common/ui/util/pointer_interaction_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"

namespace {

const CGFloat kDefaultActionsBottomMargin = 10;
const CGFloat kActionButtonImageInsets = 10;
// Gradient height.
const CGFloat kGradientHeight = 40.;
const CGFloat kScrollViewBottomInsets = 20;
const CGFloat kStackViewSpacing = 8;
const CGFloat kStackViewSpacingAfterIllustration = 27;
// The multiplier used when in regular horizontal size class.
const CGFloat kSafeAreaMultiplier = 0.65;
const CGFloat kContentOptimalWidth = 327;

// The size of the symbol image.
const CGFloat kSymbolBadgeImagePointSize = 13;

// The size of the checkmark symbol in the confirmation state on the primary
// button.
const CGFloat kSymbolConfirmationCheckmarkPointSize = 17;

// The name of the checkmark symbol in filled circle.
NSString* const kCheckmarkSymbol = @"checkmark.circle.fill";

// Properties of the favicon.
const CGFloat kFaviconCornerRadius = 13;
const CGFloat kFaviconShadowOffsetX = 0;
const CGFloat kFaviconShadowOffsetY = 0;
const CGFloat kFaviconShadowRadius = 6;
const CGFloat kFaviconShadowOpacity = 0.1;

// Length of each side of the favicon frame (which contains the favicon and the
// surrounding whitespace).
const CGFloat kFaviconFrameSideLength = 60;

// Length of each side of the favicon.
const CGFloat kFaviconSideLength = 30;

// Length of each side of the favicon badge.
const CGFloat kFaviconBadgeSideLength = 24;

// Sets the activity indicator of the button in the button configuration.
void SetConfigurationActivityIndicator(UIButton* button,
                                       BOOL shows_activity_indicator,
                                       UIColor* activity_indicator_color) {
  UIButtonConfiguration* button_configuration = button.configuration;
  button_configuration.showsActivityIndicator = shows_activity_indicator;
  button_configuration.activityIndicatorColorTransformer =
      ^UIColor*(UIColor* _) {
        return activity_indicator_color;
      };
  button.configuration = button_configuration;
}

// Sets the image in the button's configuration and the accessiblitityIdentifier
// on the button's image.
void SetConfigurationImage(UIButton* button,
                           UIImage* image,
                           UIColor* image_color) {
  UIButtonConfiguration* button_configuration = button.configuration;
  button_configuration.image = image;
  button_configuration.imageColorTransformer = ^UIColor*(UIColor* input_color) {
    return image_color ? image_color : input_color;
  };
  button.configuration = button_configuration;
  button.imageView.accessibilityIdentifier = image.accessibilityIdentifier;
}

// Sets the color of the button's background in the button configuration's
// background configuration.
void SetButtonColor(UIButton* button, UIColor* color) {
  UIButtonConfiguration* configuration = button.configuration;
  configuration.background.backgroundColor = color;
  button.configuration = configuration;
}

// Gets the default checkmark circle fill symbol with default configuration of
// the given point size.
//
// Since this code is in ios/chrome/common we cannot include the standard
// symbol helpers from ios/chrome/browser/shared/ui/symbols.
UIImage* DefaultCheckmarkCircleFillSymbol(CGFloat point_size) {
  UIImageSymbolConfiguration* configuration = [UIImageSymbolConfiguration
      configurationWithPointSize:point_size
                          weight:UIImageSymbolWeightMedium
                           scale:UIImageSymbolScaleMedium];
  UIImage* image = [UIImage systemImageNamed:kCheckmarkSymbol
                           withConfiguration:configuration];
  image.accessibilityIdentifier = kConfirmationAlertCheckmarkSymbolIdentifier;
  return image;
}

}  // namespace

@interface ConfirmationAlertViewController () <UIScrollViewDelegate>

// References to the UI properties that need to be updated when the trait
// collection changes.
@property(nonatomic, strong) UIButton* secondaryActionButton;
@property(nonatomic, strong) UIButton* tertiaryActionButton;
@property(nonatomic, strong) UINavigationBar* navigationBar;
@property(nonatomic, strong) UIImageView* imageView;
@property(nonatomic, strong) UIView* imageContainerView;
@property(nonatomic, strong) NSLayoutConstraint* imageViewAspectRatioConstraint;
@property(nonatomic, strong) UIScrollView* scrollView;
@property(nonatomic, strong) GradientView* gradientView;
@property(nonatomic, strong) NSLayoutConstraint* gradientViewHeightConstraint;
@property(nonatomic, strong)
    NSLayoutConstraint* scrollViewBottomAnchorConstraint;
@end

@implementation ConfirmationAlertViewController

#pragma mark - Public

- (instancetype)init {
  self = [super initWithNibName:nil bundle:nil];
  if (self) {
    _customSpacingAfterImage = kStackViewSpacingAfterIllustration;
    _customGradientViewHeight = kGradientHeight;
    _customScrollViewBottomInsets = kScrollViewBottomInsets;
    _customSpacing = kStackViewSpacing;
    _showsVerticalScrollIndicator = YES;
    _scrollEnabled = YES;
    _showDismissBarButton = YES;
    _dismissBarButtonSystemItem = UIBarButtonSystemItemDone;
    _shouldFillInformationStack = NO;
    _actionStackBottomMargin = kDefaultActionsBottomMargin;
    _activityIndicatorColor = [UIColor colorNamed:kSolidWhiteColor];
    _confirmationButtonColor = [UIColor colorNamed:kBlue100Color];
    _confirmationCheckmarkColor = [UIColor colorNamed:kBlue700Color];
  }
  return self;
}

- (void)viewDidLoad {
  [super viewDidLoad];

  self.view.backgroundColor = [UIColor colorNamed:kPrimaryBackgroundColor];

  if (self.hasNavigationBar) {
    self.navigationBar = [self createNavigationBar];
    [self.view addSubview:self.navigationBar];
  }

  NSMutableArray* stackSubviews = [[NSMutableArray alloc] init];

  if (self.image) {
    if (self.imageEnclosedWithShadowAndBadge ||
        self.imageEnclosedWithShadowWithoutBadge) {
      // The image view is set within the helper method.
      self.imageContainerView =
          [self createImageContainerViewWithShadowAndBadge];
    } else {
      // The image container and the image view are the same.
      self.imageView = [self createImageView];
      self.imageContainerView = self.imageView;
    }
    [stackSubviews addObject:self.imageContainerView];
  }

  if (self.aboveTitleView) {
    [stackSubviews addObject:self.aboveTitleView];
  }

  if (self.titleString.length) {
    UILabel* title = [self createTitleLabel];
    [stackSubviews addObject:title];
  }

  if (self.secondaryTitleString.length) {
    UITextView* secondaryTitle = [self createSecondaryTitleView];
    [stackSubviews addObject:secondaryTitle];
  }

  if (self.subtitleString.length) {
    UITextView* subtitle = [self createSubtitleView];
    [stackSubviews addObject:subtitle];
  }

  if (self.underTitleView) {
    self.underTitleView.accessibilityIdentifier =
        kConfirmationAlertUnderTitleViewAccessibilityIdentifier;
    [stackSubviews addObject:self.underTitleView];
  }

  DCHECK(stackSubviews);

  UIStackView* stackView =
      [self createStackViewWithArrangedSubviews:stackSubviews];

  self.scrollView = [self createScrollView];
  [self.scrollView addSubview:stackView];
  [self.view addSubview:self.scrollView];

  self.view.preservesSuperviewLayoutMargins = YES;
  UILayoutGuide* margins = self.view.layoutMarginsGuide;

  if (self.hasNavigationBar) {
    // Constraints the navigation bar to the top.
    AddSameConstraintsToSides(
        self.navigationBar, self.view.safeAreaLayoutGuide,
        LayoutSides::kTrailing | LayoutSides::kTop | LayoutSides::kLeading);
  }

  // Constraint top/bottom of the stack view to the scroll view. This defines
  // the content area. No need to contraint horizontally as we don't want
  // horizontal scroll.
  [NSLayoutConstraint activateConstraints:@[
    [stackView.topAnchor constraintEqualToAnchor:self.scrollView.topAnchor],
    [stackView.bottomAnchor
        constraintEqualToAnchor:self.scrollView.bottomAnchor
                       constant:-self.customScrollViewBottomInsets]
  ]];

  // Scroll View constraints to the height of its content. This allows to center
  // the scroll view.
  NSLayoutConstraint* heightConstraint = [self.scrollView.heightAnchor
      constraintEqualToAnchor:self.scrollView.contentLayoutGuide.heightAnchor];
  // UILayoutPriorityDefaultHigh is the default priority for content
  // compression. Setting this lower avoids compressing the content of the
  // scroll view.
  heightConstraint.priority = UILayoutPriorityDefaultHigh - 1;
  heightConstraint.active = YES;

  [NSLayoutConstraint activateConstraints:@[
    [stackView.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
    // Width Scroll View constraint for regular mode.
    [stackView.widthAnchor
        constraintGreaterThanOrEqualToAnchor:margins.widthAnchor
                                  multiplier:kSafeAreaMultiplier],
    // Disable horizontal scrolling.
    [stackView.widthAnchor
        constraintLessThanOrEqualToAnchor:margins.widthAnchor],
  ]];

  // This constraint is added to enforce that the content width should be as
  // close to the optimal width as possible, within the range already activated
  // for "stackView.widthAnchor" previously, with a higher priority.
  NSLayoutConstraint* contentLayoutGuideWidthConstraint =
      [stackView.widthAnchor constraintEqualToConstant:kContentOptimalWidth];
  contentLayoutGuideWidthConstraint.priority = UILayoutPriorityRequired - 1;
  contentLayoutGuideWidthConstraint.active = YES;

  // The bottom anchor for the scroll view.
  NSLayoutYAxisAnchor* scrollViewBottomAnchor =
      self.view.safeAreaLayoutGuide.bottomAnchor;

  BOOL hasActionButton = self.primaryActionString ||
                         self.secondaryActionString ||
                         self.tertiaryActionString;
  if (hasActionButton) {
    UIView* actionStackView = [self createActionStackView];
    [self.view addSubview:actionStackView];

    // Add a low priority width constraints to make sure that the buttons are
    // taking as much width as they can.
    CGFloat extraBottomMargin =
        self.secondaryActionString ? 0 : self.actionStackBottomMargin;
    NSLayoutConstraint* lowPriorityWidthConstraint =
        [actionStackView.widthAnchor
            constraintEqualToConstant:kContentOptimalWidth];
    lowPriorityWidthConstraint.priority = UILayoutPriorityDefaultHigh + 1;
    // 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 = [actionStackView.bottomAnchor
        constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor];
    actionBottomConstraint.priority = UILayoutPriorityDefaultLow;
    actionBottomConstraint.active = YES;

    [NSLayoutConstraint activateConstraints:@[
      [actionStackView.leadingAnchor
          constraintGreaterThanOrEqualToAnchor:self.scrollView.leadingAnchor],
      [actionStackView.trailingAnchor
          constraintLessThanOrEqualToAnchor:self.scrollView.trailingAnchor],
      [actionStackView.centerXAnchor
          constraintEqualToAnchor:self.view.centerXAnchor],
      [actionStackView.widthAnchor
          constraintEqualToAnchor:stackView.widthAnchor],
      [actionStackView.bottomAnchor
          constraintLessThanOrEqualToAnchor:self.view.bottomAnchor
                                   constant:-self.actionStackBottomMargin -
                                            extraBottomMargin],
      [actionStackView.bottomAnchor
          constraintLessThanOrEqualToAnchor:self.view.safeAreaLayoutGuide
                                                .bottomAnchor
                                   constant:-extraBottomMargin],
      lowPriorityWidthConstraint
    ]];
    scrollViewBottomAnchor = actionStackView.topAnchor;

    self.gradientView = [self createGradientView];
    [self.view addSubview:self.gradientView];

    [NSLayoutConstraint activateConstraints:@[
      [self.gradientView.bottomAnchor
          constraintEqualToAnchor:actionStackView.topAnchor],
      [self.gradientView.leadingAnchor
          constraintEqualToAnchor:self.scrollView.leadingAnchor],
      [self.gradientView.trailingAnchor
          constraintEqualToAnchor:self.scrollView.trailingAnchor],
    ]];
    self.gradientViewHeightConstraint = [self.gradientView.heightAnchor
        constraintEqualToConstant:self.customGradientViewHeight];
    self.gradientViewHeightConstraint.active = YES;
  }

  self.scrollViewBottomAnchorConstraint = [self.scrollView.bottomAnchor
      constraintLessThanOrEqualToAnchor:scrollViewBottomAnchor
                               constant:-kScrollViewBottomInsets];
  self.scrollViewBottomAnchorConstraint.active = YES;

  [NSLayoutConstraint activateConstraints:@[
    [self.scrollView.leadingAnchor
        constraintEqualToAnchor:self.view.leadingAnchor],
    [self.scrollView.trailingAnchor
        constraintEqualToAnchor:self.view.trailingAnchor],
  ]];

  NSLayoutYAxisAnchor* scrollViewTopAnchor;
  CGFloat scrollViewTopConstant = 0;
  if (self.hasNavigationBar) {
    scrollViewTopAnchor = self.navigationBar.bottomAnchor;
  } else {
    scrollViewTopAnchor = self.view.safeAreaLayoutGuide.topAnchor;
    scrollViewTopConstant = self.customSpacingBeforeImageIfNoNavigationBar;
  }
  if (self.topAlignedLayout) {
    [self.scrollView.topAnchor constraintEqualToAnchor:scrollViewTopAnchor
                                              constant:scrollViewTopConstant]
        .active = YES;
  } else {
    [self.scrollView.topAnchor
        constraintGreaterThanOrEqualToAnchor:scrollViewTopAnchor
                                    constant:scrollViewTopConstant]
        .active = YES;

    // Scroll View constraint to the vertical center.
    NSLayoutConstraint* centerYConstraint = [self.scrollView.centerYAnchor
        constraintEqualToAnchor:margins.centerYAnchor];
    // This needs to be lower than the height constraint, so it's deprioritized.
    // If this breaks, the scroll view is still constrained to the navigation
    // bar and the bottom safe area or button.
    centerYConstraint.priority = heightConstraint.priority - 1;
    centerYConstraint.active = YES;
  }

  // Only add the constraint for imageView with an image that has a variable
  // size.
  if (self.image && !self.imageHasFixedSize) {
    // Constrain the image to the scroll view size and its aspect ratio.
    [self.imageView
        setContentCompressionResistancePriority:UILayoutPriorityDefaultLow
                                        forAxis:
                                            UILayoutConstraintAxisHorizontal];
    [self.imageView
        setContentCompressionResistancePriority:UILayoutPriorityDefaultLow
                                        forAxis:UILayoutConstraintAxisVertical];
    CGFloat imageAspectRatio =
        self.imageView.image.size.width / self.imageView.image.size.height;

    self.imageViewAspectRatioConstraint = [self.imageView.widthAnchor
        constraintEqualToAnchor:self.imageView.heightAnchor
                     multiplier:imageAspectRatio];
    self.imageViewAspectRatioConstraint.active = YES;
  }
  [self updateButtonState];

  if (@available(iOS 17, *)) {
    NSArray<UITrait>* traits = @[
      UITraitPreferredContentSizeCategory.self, UITraitHorizontalSizeClass.self,
      UITraitVerticalSizeClass.self
    ];
    auto* __weak weakSelf = self;
    id handler = ^(id<UITraitEnvironment> traitEnvironment,
                   UITraitCollection* previousCollection) {
      [weakSelf updateRegisteredTraits:previousCollection];
    };
    [self registerForTraitChanges:traits withHandler:handler];
  }
}

- (void)setIsLoading:(BOOL)isLoading {
  if (_isLoading != isLoading) {
    _isLoading = isLoading;
    [self updateButtonState];
  }
}

- (void)setIsConfirmed:(BOOL)isConfirmed {
  if (_isConfirmed != isConfirmed) {
    _isConfirmed = isConfirmed;
    [self updateButtonState];
  }
}

- (void)viewDidAppear:(BOOL)animated {
  [super viewDidAppear:animated];

  // Flash the scroll indicators when the view appeared.
  [self.scrollView flashScrollIndicators];
}

#if !defined(__IPHONE_17_0) || __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_17_0
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
  [super traitCollectionDidChange:previousTraitCollection];
  if (@available(iOS 17, *)) {
    return;
  }

  [self updateRegisteredTraits:previousTraitCollection];
}
#endif

- (void)viewSafeAreaInsetsDidChange {
  [super viewSafeAreaInsetsDidChange];
  [self.view setNeedsUpdateConstraints];
}

- (void)viewLayoutMarginsDidChange {
  [super viewLayoutMarginsDidChange];
  [self.view setNeedsUpdateConstraints];
}

- (void)updateViewConstraints {
  BOOL showImageView =
      self.alwaysShowImage || (self.traitCollection.verticalSizeClass !=
                               UIUserInterfaceSizeClassCompact);

  // Hiding the image causes the UIStackView to change the image's height to 0.
  // Because its width and height are related, if the aspect ratio constraint
  // is active, the image's width also goes to 0, which causes the stack view
  // width to become 0 too.
  [self.imageView setHidden:!showImageView];
  [self.imageContainerView setHidden:!showImageView];
  self.imageViewAspectRatioConstraint.active = showImageView;

  // Allow the navigation bar to update its height based on new layout.
  [self.navigationBar invalidateIntrinsicContentSize];

  [super updateViewConstraints];
}

- (void)customizeSecondaryTitle:(UITextView*)secondaryTitle {
  // Do nothing by default. Subclasses can override this.
}

- (void)customizeSubtitle:(UITextView*)subtitle {
  // Do nothing by default. Subclasses can override this.
}

- (void)displayGradientView:(BOOL)shouldShow {
  self.gradientView.hidden = !shouldShow;
}

- (BOOL)isScrolledToBottom {
  CGFloat scrollPosition =
      self.scrollView.contentOffset.y + self.scrollView.frame.size.height;
  CGFloat scrollLimit =
      self.scrollView.contentSize.height + self.scrollView.contentInset.bottom;
  return scrollPosition >= scrollLimit;
}

- (UISheetPresentationControllerDetent*)preferredHeightDetent {
  __typeof(self) __weak weakSelf = self;
  auto resolver = ^CGFloat(
      id<UISheetPresentationControllerDetentResolutionContext> context) {
    return [weakSelf detentForPreferredHeightInContext:context];
  };
  return [UISheetPresentationControllerDetent
      customDetentWithIdentifier:@"preferred_height"
                        resolver:resolver];
}

- (CGFloat)preferredHeightForContent {
  // Obtain container view from presentation controller directly because
  // this view may not have been added to its container view yet.
  UIView* containerView = self.sheetPresentationController.containerView;

  // Measure compressed height without safe area inset (detent values are
  // generally expressed without safe area insets).
  CGFloat fittingWidth = containerView.bounds.size.width;
  CGSize fittingSize =
      CGSizeMake(fittingWidth, UILayoutFittingCompressedSize.height);
  CGFloat height = [self.view systemLayoutSizeFittingSize:fittingSize].height;

  // These adjustments are necessary for devices in a non regular x regular size
  // class with home indicators in their displays, as they have a non zero safe
  // area inset at their bottom edge and a bottom sheet that attaches to it.
  if (!IsRegularXRegularSizeClass(containerView.traitCollection) &&
      containerView.safeAreaInsets.bottom != 0) {
    height -= containerView.safeAreaInsets.bottom;

    // Replace bottom margin calculated based on view's own safe area with
    // bottom margin calculated based on the safe area of the container view
    // it will eventually live in. This is needed in case the detent value
    // is requested before the view has been added to its superview.
    height -=
        MAX(self.actionStackBottomMargin, self.view.safeAreaInsets.bottom);
    height +=
        MAX(self.actionStackBottomMargin, containerView.safeAreaInsets.bottom);
  }
  return height;
}

#pragma mark - Private

- (CGFloat)detentForPreferredHeightInContext:
    (id<UISheetPresentationControllerDetentResolutionContext>)context
    API_AVAILABLE(ios(16)) {
  // Only activate this detent in portrait orientation on iPhone.
  UITraitCollection* traitCollection = context.containerTraitCollection;
  if (traitCollection.horizontalSizeClass != UIUserInterfaceSizeClassCompact ||
      traitCollection.verticalSizeClass != UIUserInterfaceSizeClassRegular) {
    return UISheetPresentationControllerDetentInactive;
  }

  CGFloat height = [self preferredHeightForContent];

  // Make sure detent is not larger than 75% of the maximum detent value but at
  // least as large as a standard medium detent.
  height = MIN(height, 0.75 * context.maximumDetentValue);
  CGFloat mediumDetentHeight = [UISheetPresentationControllerDetent.mediumDetent
      resolvedValueInContext:context];
  height = MAX(height, mediumDetentHeight);
  return height;
}

// Handle taps on the dismiss button.
- (void)didTapDismissBarButton {
  DCHECK(self.showDismissBarButton);
  if ([self.actionHandler
          respondsToSelector:@selector(confirmationAlertDismissAction)]) {
    [self.actionHandler confirmationAlertDismissAction];
  }
}

// Handle taps on the help button.
- (void)didTapHelpButton {
  if ([self.actionHandler
          respondsToSelector:@selector(confirmationAlertLearnMoreAction)]) {
    [self.actionHandler confirmationAlertLearnMoreAction];
  }
}

// Handle taps on the primary action button.
- (void)didTapPrimaryActionButton {
  [self.actionHandler confirmationAlertPrimaryAction];
}

// Handle taps on the secondary action button
- (void)didTapSecondaryActionButton {
  DCHECK(self.secondaryActionString);
  if ([self.actionHandler
          respondsToSelector:@selector(confirmationAlertSecondaryAction)]) {
    [self.actionHandler confirmationAlertSecondaryAction];
  }
}

- (void)didTapTertiaryActionButton {
  DCHECK(self.tertiaryActionString);
  if ([self.actionHandler
          respondsToSelector:@selector(confirmationAlertTertiaryAction)]) {
    [self.actionHandler confirmationAlertTertiaryAction];
  }
}

// Helper to create the navigation bar.
- (UINavigationBar*)createNavigationBar {
  UINavigationBar* navigationBar = [[UINavigationBar alloc] init];
  navigationBar.translucent = NO;
  [navigationBar setShadowImage:[[UIImage alloc] init]];
  [navigationBar setBarTintColor:[UIColor colorNamed:kPrimaryBackgroundColor]];

  UINavigationItem* navigationItem = [[UINavigationItem alloc] init];
  if (self.helpButtonAvailable) {
    UIBarButtonItem* helpButton =
        [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"help_icon"]
                                         style:UIBarButtonItemStylePlain
                                        target:self
                                        action:@selector(didTapHelpButton)];
    navigationItem.leftBarButtonItem = helpButton;

    if (self.helpButtonAccessibilityLabel) {
      helpButton.isAccessibilityElement = YES;
      helpButton.accessibilityLabel = self.helpButtonAccessibilityLabel;
    }

    helpButton.accessibilityIdentifier =
        kConfirmationAlertMoreInfoAccessibilityIdentifier;
    // Set the help button as the left button item so it can be used as a
    // popover anchor.
    _helpButton = helpButton;
  }

  if (self.titleView) {
    navigationItem.titleView = self.titleView;
  }

  if (self.showDismissBarButton) {
    UIBarButtonItem* dismissButton;
    if (self.customDismissBarButtonImage) {
      dismissButton = [[UIBarButtonItem alloc]
          initWithImage:self.customDismissBarButtonImage
                  style:UIBarButtonItemStylePlain
                 target:self
                 action:@selector(didTapDismissBarButton)];
    } else {
      dismissButton = [[UIBarButtonItem alloc]
          initWithBarButtonSystemItem:self.dismissBarButtonSystemItem
                               target:self
                               action:@selector(didTapDismissBarButton)];
    }
    navigationItem.rightBarButtonItem = dismissButton;
  }

  navigationBar.translatesAutoresizingMaskIntoConstraints = NO;
  [navigationBar setItems:@[ navigationItem ]];

  return navigationBar;
}

- (void)setImage:(UIImage*)image {
  _image = image;
  _imageView.image = image;
}

- (void)setScrollEnabled:(BOOL)scrollEnabled {
  _scrollEnabled = scrollEnabled;
  if (_scrollView) {
    _scrollView.scrollEnabled = _scrollEnabled;
  }
}

// Helper to create the image view.
- (UIImageView*)createImageView {
  UIImageView* imageView = [[UIImageView alloc] initWithImage:self.image];
  imageView.contentMode = UIViewContentModeScaleAspectFit;
  if (self.imageViewAccessibilityLabel) {
    imageView.isAccessibilityElement = YES;
    imageView.accessibilityLabel = self.imageViewAccessibilityLabel;
  }

  imageView.translatesAutoresizingMaskIntoConstraints = NO;
  return imageView;
}

// Helper to create the image view enclosed in a frame with a shadow and a
// corner badge with a green checkmark. `self.imageView` is set in this method.
- (UIView*)createImageContainerViewWithShadowAndBadge {
  UIImageView* faviconBadgeView = [[UIImageView alloc] init];
  faviconBadgeView.translatesAutoresizingMaskIntoConstraints = NO;
  faviconBadgeView.image =
      DefaultCheckmarkCircleFillSymbol(kSymbolBadgeImagePointSize);
  faviconBadgeView.tintColor = [UIColor colorNamed:kGreenColor];

  UIImageView* faviconView = [[UIImageView alloc] initWithImage:self.image];
  faviconView.translatesAutoresizingMaskIntoConstraints = NO;
  faviconView.contentMode = UIViewContentModeScaleAspectFit;

  UIView* frameView = [[UIView alloc] init];
  frameView.translatesAutoresizingMaskIntoConstraints = NO;
  frameView.backgroundColor = [UIColor colorNamed:kBackgroundColor];
  frameView.layer.cornerRadius = kFaviconCornerRadius;
  frameView.layer.shadowOffset =
      CGSizeMake(kFaviconShadowOffsetX, kFaviconShadowOffsetY);
  frameView.layer.shadowRadius = kFaviconShadowRadius;
  frameView.layer.shadowOpacity = kFaviconShadowOpacity;
  [frameView addSubview:faviconView];

  UIView* containerView = [[UIView alloc] init];
  [containerView addSubview:frameView];
  [containerView addSubview:faviconBadgeView];

  if (self.imageEnclosedWithShadowWithoutBadge) {
    [faviconBadgeView setHidden:YES];
  }

  CGFloat faviconSideLength = self.customFaviconSideLength > 0
                                  ? self.customFaviconSideLength
                                  : kFaviconSideLength;

  [NSLayoutConstraint activateConstraints:@[
    // Size constraints.
    [frameView.widthAnchor constraintEqualToConstant:kFaviconFrameSideLength],
    [frameView.heightAnchor constraintEqualToConstant:kFaviconFrameSideLength],
    [faviconView.widthAnchor constraintEqualToConstant:faviconSideLength],
    [faviconView.heightAnchor constraintEqualToConstant:faviconSideLength],
    [faviconBadgeView.widthAnchor
        constraintEqualToConstant:kFaviconBadgeSideLength],
    [faviconBadgeView.heightAnchor
        constraintEqualToConstant:kFaviconBadgeSideLength],

    // Badge is on the upper right corner of the frame.
    [frameView.topAnchor
        constraintEqualToAnchor:faviconBadgeView.centerYAnchor],
    [frameView.trailingAnchor
        constraintEqualToAnchor:faviconBadgeView.centerXAnchor],

    // Favicon is centered in the frame.
    [frameView.centerXAnchor constraintEqualToAnchor:faviconView.centerXAnchor],
    [frameView.centerYAnchor constraintEqualToAnchor:faviconView.centerYAnchor],

    // Frame and badge define the whole view returned by this method.
    [containerView.leadingAnchor
        constraintEqualToAnchor:frameView.leadingAnchor
                       constant:-kFaviconBadgeSideLength / 2],
    [containerView.bottomAnchor constraintEqualToAnchor:frameView.bottomAnchor],
    [containerView.topAnchor
        constraintEqualToAnchor:faviconBadgeView.topAnchor],
    [containerView.trailingAnchor
        constraintEqualToAnchor:faviconBadgeView.trailingAnchor],
  ]];

  self.imageView = faviconView;
  return containerView;
}

// Creates a UITextView with subtitle defaults.
- (UITextView*)createTextView {
  UITextView* view = [[UITextView alloc] init];
  view.textAlignment = NSTextAlignmentCenter;
  view.translatesAutoresizingMaskIntoConstraints = NO;
  view.adjustsFontForContentSizeCategory = YES;
  view.editable = NO;
  view.selectable = NO;
  view.scrollEnabled = NO;
  view.backgroundColor = [UIColor colorNamed:kPrimaryBackgroundColor];
  return view;
}

// Helper to create the title label.
- (UILabel*)createTitleLabel {
  if (!self.titleTextStyle) {
    self.titleTextStyle = UIFontTextStyleTitle1;
  }
  UILabel* title = [[UILabel alloc] init];
  title.numberOfLines = 0;
  UIFontDescriptor* descriptor = [UIFontDescriptor
      preferredFontDescriptorWithTextStyle:self.titleTextStyle];
  UIFont* font = [UIFont systemFontOfSize:descriptor.pointSize
                                   weight:UIFontWeightBold];
  UIFontMetrics* fontMetrics =
      [UIFontMetrics metricsForTextStyle:self.titleTextStyle];
  title.font = [fontMetrics scaledFontForFont:font];
  title.textColor = [UIColor colorNamed:kTextPrimaryColor];
  title.text = self.titleString;
  title.textAlignment = NSTextAlignmentCenter;
  title.translatesAutoresizingMaskIntoConstraints = NO;
  title.adjustsFontForContentSizeCategory = YES;
  title.accessibilityIdentifier =
      kConfirmationAlertTitleAccessibilityIdentifier;
  title.accessibilityTraits = UIAccessibilityTraitHeader;
  return title;
}

// Helper to create the title description view.
- (UITextView*)createSecondaryTitleView {
  UITextView* secondaryTitle = [self createTextView];
  secondaryTitle.font =
      [UIFont preferredFontForTextStyle:UIFontTextStyleTitle2];
  secondaryTitle.text = self.secondaryTitleString;
  secondaryTitle.textColor = [UIColor colorNamed:kTextPrimaryColor];
  secondaryTitle.accessibilityIdentifier =
      kConfirmationAlertSecondaryTitleAccessibilityIdentifier;
  [self customizeSecondaryTitle:secondaryTitle];
  return secondaryTitle;
}

// Helper to create the subtitle view.
- (UITextView*)createSubtitleView {
  if (!self.subtitleTextStyle) {
    self.subtitleTextStyle = UIFontTextStyleBody;
  }
  UITextView* subtitle = [self createTextView];
  subtitle.font = [UIFont preferredFontForTextStyle:self.subtitleTextStyle];
  subtitle.text = self.subtitleString;
  subtitle.textColor = [UIColor colorNamed:kTextSecondaryColor];
  subtitle.accessibilityIdentifier =
      kConfirmationAlertSubtitleAccessibilityIdentifier;
  [self customizeSubtitle:subtitle];
  return subtitle;
}

- (BOOL)hasNavigationBar {
  return self.helpButtonAvailable || self.showDismissBarButton ||
         self.titleView;
}

// Helper to create the scroll view.
- (UIScrollView*)createScrollView {
  UIScrollView* scrollView = [[UIScrollView alloc] init];
  scrollView.alwaysBounceVertical = NO;
  scrollView.showsHorizontalScrollIndicator = NO;
  scrollView.translatesAutoresizingMaskIntoConstraints = NO;
  scrollView.scrollEnabled = self.scrollEnabled;
  [scrollView
      setShowsVerticalScrollIndicator:self.showsVerticalScrollIndicator];
  scrollView.delegate = self;
  return scrollView;
}

// Helper to create the gradient view.
- (GradientView*)createGradientView {
  GradientView* gradientView = [[GradientView alloc]
      initWithTopColor:[[UIColor colorNamed:kPrimaryBackgroundColor]
                           colorWithAlphaComponent:0]
           bottomColor:[UIColor colorNamed:kPrimaryBackgroundColor]];
  gradientView.translatesAutoresizingMaskIntoConstraints = NO;
  return gradientView;
}

// Helper to create the stack view.
- (UIStackView*)createStackViewWithArrangedSubviews:
    (NSArray<UIView*>*)subviews {
  UIStackView* stackView =
      [[UIStackView alloc] initWithArrangedSubviews:subviews];
  [stackView setCustomSpacing:self.customSpacingAfterImage
                    afterView:self.imageContainerView];

  if (self.imageHasFixedSize && !self.shouldFillInformationStack) {
    stackView.alignment = UIStackViewAlignmentCenter;
  } else {
    stackView.alignment = UIStackViewAlignmentFill;
  }

  stackView.axis = UILayoutConstraintAxisVertical;
  stackView.translatesAutoresizingMaskIntoConstraints = NO;
  stackView.spacing = self.customSpacing;
  return stackView;
}

- (UIView*)createActionStackView {
  UIStackView* actionStackView = [[UIStackView alloc] init];
  actionStackView.alignment = UIStackViewAlignmentFill;
  actionStackView.axis = UILayoutConstraintAxisVertical;
  actionStackView.translatesAutoresizingMaskIntoConstraints = NO;

  if (self.primaryActionString) {
    _primaryActionButton = [self createPrimaryActionButton];
    [actionStackView addArrangedSubview:self.primaryActionButton];
  }

  if (self.secondaryActionString) {
    self.secondaryActionButton = [self createSecondaryActionButton];
    [actionStackView addArrangedSubview:self.secondaryActionButton];
  }
  // Tertiary button should show above the primary one.
  if (self.tertiaryActionString) {
    self.tertiaryActionButton = [self createTertiaryButton];
    [actionStackView insertArrangedSubview:self.tertiaryActionButton atIndex:0];
  }
  return actionStackView;
}

// Helper to create the primary action button.
- (UIButton*)createPrimaryActionButton {
  UIButton* primaryActionButton = PrimaryActionButton(YES);
  [primaryActionButton addTarget:self
                          action:@selector(didTapPrimaryActionButton)
                forControlEvents:UIControlEventTouchUpInside];
  SetConfigurationTitle(primaryActionButton, self.primaryActionString);
  primaryActionButton.accessibilityIdentifier =
      kConfirmationAlertPrimaryActionAccessibilityIdentifier;

  return primaryActionButton;
}

// Helper to create the primary action button.
- (UIButton*)createSecondaryActionButton {
  DCHECK(self.secondaryActionString);
  UIButton* secondaryActionButton =
      [UIButton buttonWithType:UIButtonTypeSystem];
  [secondaryActionButton addTarget:self
                            action:@selector(didTapSecondaryActionButton)
                  forControlEvents:UIControlEventTouchUpInside];

  UIButtonConfiguration* buttonConfiguration =
      secondaryActionButton.configuration
          ? secondaryActionButton.configuration
          : [UIButtonConfiguration plainButtonConfiguration];
  buttonConfiguration.contentInsets = NSDirectionalEdgeInsetsMake(
      kButtonVerticalInsets, 0, kButtonVerticalInsets, 0);

  if (self.secondaryActionImage) {
    buttonConfiguration.image = self.secondaryActionImage;
    buttonConfiguration.imagePadding = kActionButtonImageInsets;
  }

  UIFont* font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
  NSDictionary* attributes = @{NSFontAttributeName : font};
  NSMutableAttributedString* string = [[NSMutableAttributedString alloc]
      initWithString:self.secondaryActionString];
  [string addAttributes:attributes range:NSMakeRange(0, string.length)];
  buttonConfiguration.attributedTitle = string;

  UIColor* titleColor = [UIColor colorNamed:self.secondaryActionTextColor
                                                ? self.secondaryActionTextColor
                                                : kBlueColor];
  buttonConfiguration.baseForegroundColor = titleColor;
  buttonConfiguration.background.backgroundColor = [UIColor clearColor];
  secondaryActionButton.configuration = buttonConfiguration;

  secondaryActionButton.translatesAutoresizingMaskIntoConstraints = NO;
  secondaryActionButton.accessibilityIdentifier =
      kConfirmationAlertSecondaryActionAccessibilityIdentifier;
  secondaryActionButton.pointerInteractionEnabled = YES;
  secondaryActionButton.pointerStyleProvider =
      CreateOpaqueButtonPointerStyleProvider();

  return secondaryActionButton;
}

- (UIButton*)createTertiaryButton {
  DCHECK(self.tertiaryActionString);
  UIButton* tertiaryActionButton = [UIButton buttonWithType:UIButtonTypeSystem];
  [tertiaryActionButton addTarget:self
                           action:@selector(didTapTertiaryActionButton)
                 forControlEvents:UIControlEventTouchUpInside];

  UIButtonConfiguration* buttonConfiguration =
      [UIButtonConfiguration plainButtonConfiguration];
  buttonConfiguration.contentInsets = NSDirectionalEdgeInsetsMake(
      kButtonVerticalInsets, 0, kButtonVerticalInsets, 0);
  buttonConfiguration.background.backgroundColor = [UIColor clearColor];
  buttonConfiguration.baseForegroundColor = [UIColor colorNamed:kBlueColor];

  // Customize title string.
  UIFont* font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
  NSDictionary* attributes = @{NSFontAttributeName : font};
  NSMutableAttributedString* string = [[NSMutableAttributedString alloc]
      initWithString:self.tertiaryActionString];
  [string addAttributes:attributes range:NSMakeRange(0, string.length)];
  buttonConfiguration.attributedTitle = string;
  tertiaryActionButton.configuration = buttonConfiguration;

  tertiaryActionButton.translatesAutoresizingMaskIntoConstraints = NO;
  tertiaryActionButton.accessibilityIdentifier =
      kConfirmationAlertTertiaryActionAccessibilityIdentifier;
  tertiaryActionButton.pointerInteractionEnabled = YES;
  tertiaryActionButton.pointerStyleProvider =
      CreateOpaqueButtonPointerStyleProvider();

  return tertiaryActionButton;
}

// Applies an activity indicator to the primary button and disables buttons when
// loading is true; otherwise, applies button labels and enables buttons.
- (void)updateButtonState {
  const BOOL showingProgressState = _isLoading || _isConfirmed;
  _primaryActionButton.enabled = !showingProgressState;
  if (_isConfirmed) {
    SetButtonColor(_primaryActionButton, _confirmationButtonColor);
    SetConfigurationImage(
        _primaryActionButton,
        DefaultCheckmarkCircleFillSymbol(kSymbolConfirmationCheckmarkPointSize),
        _confirmationCheckmarkColor);
  } else {
    UpdateButtonColorOnEnableDisable(_primaryActionButton);
    SetConfigurationImage(_primaryActionButton, /*image=*/nil, /*color=*/nil);
  }
  SetConfigurationActivityIndicator(_primaryActionButton, _isLoading,
                                    _activityIndicatorColor);
  SetConfigurationTitle(_primaryActionButton,
                        showingProgressState ? @"" : _primaryActionString);

  _secondaryActionButton.enabled = !showingProgressState;
  _tertiaryActionButton.enabled = !showingProgressState;
}

// Checks which trait has been changed and adapts the UI to reflect this new
// environment.
- (void)updateRegisteredTraits:(UITraitCollection*)previousTraitCollection {
  // Update fonts for specific content sizes.
  if (previousTraitCollection.preferredContentSizeCategory !=
      self.traitCollection.preferredContentSizeCategory) {
    SetConfigurationFont(self.primaryActionButton,
                         PreferredFontForTextStyleWithMaxCategory(
                             UIFontTextStyleHeadline,
                             self.traitCollection.preferredContentSizeCategory,
                             UIContentSizeCategoryExtraExtraExtraLarge));

    UIFont* newFont = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
    if (self.secondaryActionString) {
      SetConfigurationFont(self.secondaryActionButton, newFont);
    }
    if (self.tertiaryActionString) {
      SetConfigurationFont(self.tertiaryActionButton, newFont);
    }
  }

  // Update constraints for different size classes.
  BOOL hasNewHorizontalSizeClass =
      previousTraitCollection.horizontalSizeClass !=
      self.traitCollection.horizontalSizeClass;
  BOOL hasNewVerticalSizeClass = previousTraitCollection.verticalSizeClass !=
                                 self.traitCollection.verticalSizeClass;

  if (hasNewHorizontalSizeClass || hasNewVerticalSizeClass) {
    [self.view setNeedsUpdateConstraints];
  }
}

@end