chromium/ios/chrome/common/ui/elements/popover_label_view_controller.mm

// Copyright 2019 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/elements/popover_label_view_controller.h"

#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/text_view_util.h"

namespace {

// Vertical inset for the text content.
constexpr CGFloat kVerticalInsetValue = 16;
// Horizontal inset for the text content.
constexpr CGFloat kHorizontalInsetValue = 16;
// Desired percentage of the width of the presented view controller.
constexpr CGFloat kWidthProportion = 0.75;
// Max width for the popover.
constexpr CGFloat kMaxWidth = 300;
// Distance between the primary text label and the secondary text label.
constexpr CGFloat kVerticalDistance = 10;
// Distance between the icon and the first letter in the secondary text box.
constexpr CGFloat kIconDistance = 10;
// The size of the icon at the left of the secondary text.
constexpr CGFloat kIconSize = 16;

}  // namespace

@interface PopoverLabelViewController () <
    UIPopoverPresentationControllerDelegate,
    UITextViewDelegate>

// UIScrollView which is used for size calculation.
@property(nonatomic, strong) UIScrollView* scrollView;

// The attributed string being presented as primary text.
@property(nonatomic, strong, readonly)
    NSAttributedString* primaryAttributedString;

// The attributed string being presented as secondary text.
@property(nonatomic, strong, readonly)
    NSAttributedString* secondaryAttributedString;

// The image of the icon at the left of the secondary text. No icon if left
// nil.
@property(nonatomic, strong, readonly) UIImage* icon;

// Visual effect view used to add a blur effect to the popover.
@property(nonatomic, strong) UIVisualEffectView* blurBackgroundView;

@end

@implementation PopoverLabelViewController

- (instancetype)initWithMessage:(NSString*)message {
  NSDictionary* generalAttributes = @{
    NSForegroundColorAttributeName : [UIColor colorNamed:kTextPrimaryColor],
    NSFontAttributeName :
        [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]
  };

  NSAttributedString* attributedString =
      [[NSAttributedString alloc] initWithString:message
                                      attributes:generalAttributes];
  return [self initWithPrimaryAttributedString:attributedString
                     secondaryAttributedString:nil];
}

- (instancetype)initWithPrimaryAttributedString:
                    (NSAttributedString*)primaryAttributedString
                      secondaryAttributedString:
                          (NSAttributedString*)secondaryAttributedString {
  self = [self initWithPrimaryAttributedString:primaryAttributedString
                     secondaryAttributedString:secondaryAttributedString
                                          icon:nil];
  return self;
}

- (instancetype)initWithPrimaryAttributedString:
                    (NSAttributedString*)primaryAttributedString
                      secondaryAttributedString:
                          (NSAttributedString*)secondaryAttributedString
                                           icon:(UIImage*)icon {
  self = [super initWithNibName:nil bundle:nil];
  if (self) {
    _primaryAttributedString = primaryAttributedString;
    _secondaryAttributedString = secondaryAttributedString;
    _icon = icon;
    self.modalPresentationStyle = UIModalPresentationPopover;
    self.popoverPresentationController.delegate = self;
  }
  return self;
}

#pragma mark - UIViewController

- (void)viewDidLoad {
  [super viewDidLoad];

  self.view.backgroundColor = UIColor.clearColor;

  _scrollView = [[UIScrollView alloc] init];
  _scrollView.backgroundColor = UIColor.clearColor;
  _scrollView.delaysContentTouches = NO;
  _scrollView.showsVerticalScrollIndicator = YES;
  _scrollView.showsHorizontalScrollIndicator = NO;
  _scrollView.translatesAutoresizingMaskIntoConstraints = NO;
  [self.view addSubview:_scrollView];

  AddSameConstraints(self.view.safeAreaLayoutGuide, _scrollView);

  // TODO(crbug.com/40138105): Remove the following workaround:
  // Using a UIView instead of UILayoutGuide as the later behaves weirdly with
  // the scroll view.
  UIView* textContainerView = [[UIView alloc] init];
  textContainerView.backgroundColor = UIColor.clearColor;
  textContainerView.translatesAutoresizingMaskIntoConstraints = NO;
  [_scrollView addSubview:textContainerView];
  AddSameConstraints(textContainerView, _scrollView);

  UITextView* textView = CreateUITextViewWithTextKit1();
  textView.scrollEnabled = NO;
  textView.editable = NO;
  textView.delegate = self;
  textView.backgroundColor = [UIColor clearColor];
  textView.font = [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline];
  textView.adjustsFontForContentSizeCategory = YES;
  textView.translatesAutoresizingMaskIntoConstraints = NO;
  textView.textContainerInset = UIEdgeInsetsZero;
  textView.textColor = [UIColor colorNamed:kTextSecondaryColor];
  textView.linkTextAttributes =
      @{NSForegroundColorAttributeName : [UIColor colorNamed:kBlueColor]};

  if (self.primaryAttributedString) {
    textView.attributedText = self.primaryAttributedString;
  }

  [_scrollView addSubview:textView];

  // Only create secondary TextView when `secondaryAttributedString` is not nil
  // or empty. Set the constraint accordingly.
  if (self.secondaryAttributedString.length) {
    UITextView* secondaryTextView = CreateUITextViewWithTextKit1();
    secondaryTextView.scrollEnabled = NO;
    secondaryTextView.editable = NO;
    secondaryTextView.delegate = self;
    secondaryTextView.backgroundColor = [UIColor clearColor];
    secondaryTextView.font =
        [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline];
    secondaryTextView.adjustsFontForContentSizeCategory = YES;
    secondaryTextView.translatesAutoresizingMaskIntoConstraints = NO;
    secondaryTextView.textContainerInset = UIEdgeInsetsZero;
    secondaryTextView.textColor = [UIColor colorNamed:kTextSecondaryColor];
    secondaryTextView.linkTextAttributes =
        @{NSForegroundColorAttributeName : [UIColor colorNamed:kBlueColor]};
    secondaryTextView.attributedText = self.secondaryAttributedString;

    [_scrollView addSubview:secondaryTextView];

    if (self.icon) {
      // Add an icon at the left of the secondary text box.

      UIImageView* imageView = [[UIImageView alloc] initWithImage:self.icon];
      imageView.translatesAutoresizingMaskIntoConstraints = NO;
      imageView.contentMode = UIViewContentModeScaleAspectFit;
      imageView.clipsToBounds = YES;

      [_scrollView addSubview:imageView];

      const CGFloat lineFragmentPadding =
          secondaryTextView.textContainer.lineFragmentPadding;

      [NSLayoutConstraint activateConstraints:@[
        [textContainerView.leadingAnchor
            constraintEqualToAnchor:imageView.leadingAnchor
                           constant:-kHorizontalInsetValue -
                                    lineFragmentPadding],
        [secondaryTextView.leadingAnchor
            constraintEqualToAnchor:imageView.trailingAnchor
                           constant:kIconDistance - lineFragmentPadding],
        [textView.bottomAnchor constraintEqualToAnchor:imageView.topAnchor
                                              constant:-kVerticalDistance],
        [imageView.heightAnchor constraintEqualToConstant:kIconSize],
        [imageView.widthAnchor constraintEqualToConstant:kIconSize],

      ]];
    } else {
      // Set default secondary text constraints when there is no icon.
      [NSLayoutConstraint activateConstraints:@[
        [textContainerView.leadingAnchor
            constraintEqualToAnchor:secondaryTextView.leadingAnchor
                           constant:-kHorizontalInsetValue],

      ]];
    }

    [NSLayoutConstraint activateConstraints:@[
      [textContainerView.widthAnchor
          constraintEqualToAnchor:_scrollView.widthAnchor],
      [textContainerView.leadingAnchor
          constraintEqualToAnchor:textView.leadingAnchor
                         constant:-kHorizontalInsetValue],
      [textContainerView.trailingAnchor
          constraintEqualToAnchor:textView.trailingAnchor
                         constant:kHorizontalInsetValue],
      [textContainerView.trailingAnchor
          constraintEqualToAnchor:secondaryTextView.trailingAnchor
                         constant:kHorizontalInsetValue],
      [textView.bottomAnchor constraintEqualToAnchor:secondaryTextView.topAnchor
                                            constant:-kVerticalDistance],
      [textContainerView.topAnchor
          constraintEqualToAnchor:textView.topAnchor
                         constant:-kVerticalInsetValue],
      [textContainerView.bottomAnchor
          constraintEqualToAnchor:secondaryTextView.bottomAnchor
                         constant:kVerticalInsetValue],
    ]];
  } else {
    // Constraints used when only have primary TextView.
    [NSLayoutConstraint activateConstraints:@[
      [textContainerView.widthAnchor
          constraintEqualToAnchor:_scrollView.widthAnchor],
      [textContainerView.leadingAnchor
          constraintEqualToAnchor:textView.leadingAnchor
                         constant:-kHorizontalInsetValue],
      [textContainerView.trailingAnchor
          constraintEqualToAnchor:textView.trailingAnchor
                         constant:kHorizontalInsetValue],
      [textContainerView.topAnchor
          constraintEqualToAnchor:textView.topAnchor
                         constant:-kVerticalInsetValue],
      [textContainerView.bottomAnchor
          constraintEqualToAnchor:textView.bottomAnchor
                         constant:kVerticalInsetValue],
    ]];
  }

  NSLayoutConstraint* heightConstraint = [_scrollView.heightAnchor
      constraintEqualToAnchor:_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;

  [self updateBackgroundColor];
}

- (void)viewWillAppear:(BOOL)animated {
  [self updatePreferredContentSize];
  [super viewWillAppear:animated];
}

- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
  [super traitCollectionDidChange:previousTraitCollection];
  if ((self.traitCollection.verticalSizeClass !=
       previousTraitCollection.verticalSizeClass) ||
      (self.traitCollection.horizontalSizeClass !=
       previousTraitCollection.horizontalSizeClass)) {
    [self updatePreferredContentSize];
  }

  if (self.traitCollection.userInterfaceStyle !=
      previousTraitCollection.userInterfaceStyle) {
    [self updateBackgroundColor];
  }
}

- (void)viewDidLayoutSubviews {
  [super viewDidLayoutSubviews];
  [self updatePreferredContentSize];
}

#pragma mark - UIPopoverPresentationControllerDelegate

- (void)popoverPresentationController:
            (UIPopoverPresentationController*)popoverPresentationController
          willRepositionPopoverToRect:(inout CGRect*)rect
                               inView:(inout UIView**)view {
  // Popover moved to a different location, there might be more space available
  // now so a new layout pass is needed.
  [self.view setNeedsLayout];
}

#pragma mark - Private methods

- (void)updateBackgroundColor {
  // The popover background in dark mode needs more contrast.
  BOOL darkMode = UITraitCollection.currentTraitCollection.userInterfaceStyle ==
                  UIUserInterfaceStyleDark;

  self.view.backgroundColor =
      darkMode ? [UIColor colorNamed:kTertiaryBackgroundColor]
               : UIColor.clearColor;

  if (darkMode && self.blurBackgroundView.superview) {
    // Remove blurred background in dark mode if it has been added.
    [self.blurBackgroundView removeFromSuperview];
  } else if (!darkMode && !self.blurBackgroundView.superview) {
    // Add blurred background in light mode only if it has not been added
    // already.
    [self.view insertSubview:self.blurBackgroundView atIndex:0];
  }
}

#pragma mark - Properties

- (UIVisualEffectView*)blurBackgroundView {
  if (!_blurBackgroundView) {
    // Set up a blurred background.
    UIBlurEffect* blurEffect =
        [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemThickMaterial];
    _blurBackgroundView =
        [[UIVisualEffectView alloc] initWithEffect:blurEffect];
    _blurBackgroundView.translatesAutoresizingMaskIntoConstraints = NO;
    _blurBackgroundView.frame = self.view.bounds;
  }
  return _blurBackgroundView;
}

#pragma mark - UIAdaptivePresentationControllerDelegate

- (UIModalPresentationStyle)
    adaptivePresentationStyleForPresentationController:
        (UIPresentationController*)controller
                                       traitCollection:
                                           (UITraitCollection*)traitCollection {
  return UIModalPresentationNone;
}

#pragma mark - Helpers

// Updates the preferred content size according to the presenting view size and
// the layout size of the view.
- (void)updatePreferredContentSize {
  // Expected width of the `self.scrollView`.
  CGFloat width =
      self.presentingViewController.view.bounds.size.width * kWidthProportion;
  // Cap max width at 300pt.
  if (width > kMaxWidth) {
    width = kMaxWidth;
  }
  // `scrollView` is used here instead of `self.view`, because `self.view`
  // includes arrow size during calculation although it's being added to the
  // result size anyway.
  CGSize size =
      [self.scrollView systemLayoutSizeFittingSize:CGSizeMake(width, 0)
                     withHorizontalFittingPriority:UILayoutPriorityRequired
                           verticalFittingPriority:500];
  [UIView performWithoutAnimation:^{
    self.preferredContentSize = size;
  }];
}

#pragma mark - UITextViewDelegate

- (BOOL)textView:(UITextView*)textView
    shouldInteractWithURL:(NSURL*)URL
                  inRange:(NSRange)characterRange
              interaction:(UITextItemInteraction)interaction {
  if (URL) {
    [self.delegate didTapLinkURL:URL];
  }
  // Returns NO as the app is handling the opening of the URL.
  return NO;
}

@end