| // 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]; |
| |
| if (@available(iOS 17, *)) { |
| NSArray<UITrait>* traits = @[ |
| UITraitVerticalSizeClass.class, UITraitHorizontalSizeClass.class, |
| UITraitUserInterfaceStyle.class |
| ]; |
| __weak __typeof(self) weakSelf = self; |
| UITraitChangeHandler handler = ^(id<UITraitEnvironment> traitEnvironment, |
| UITraitCollection* previousCollection) { |
| [weakSelf updateUIOnTraitChange:previousCollection]; |
| }; |
| [self registerForTraitChanges:traits withHandler:handler]; |
| } |
| } |
| |
| - (void)viewWillAppear:(BOOL)animated { |
| [self updatePreferredContentSize]; |
| [super viewWillAppear:animated]; |
| } |
| |
| - (void)updateUIOnTraitChange:(UITraitCollection*)previousTraitCollection { |
| if ((self.traitCollection.verticalSizeClass != |
| previousTraitCollection.verticalSizeClass) || |
| (self.traitCollection.horizontalSizeClass != |
| previousTraitCollection.horizontalSizeClass)) { |
| [self updatePreferredContentSize]; |
| } |
| |
| if (self.traitCollection.userInterfaceStyle != |
| previousTraitCollection.userInterfaceStyle) { |
| [self updateBackgroundColor]; |
| } |
| } |
| |
| #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 updateUIOnTraitChange:previousTraitCollection]; |
| } |
| #endif |
| |
| - (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:kGrey100Color] : 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 |
| |
| #if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_17_0 |
| - (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; |
| } |
| #endif |
| |
| - (UIAction*)textView:(UITextView*)textView |
| primaryActionForTextItem:(UITextItem*)textItem |
| defaultAction:(UIAction*)defaultAction API_AVAILABLE(ios(17.0)) { |
| NSURL* URL = textItem.link; |
| if (URL) { |
| __weak __typeof(self) weakSelf = self; |
| return [UIAction actionWithHandler:^(UIAction* action) { |
| [weakSelf.delegate didTapLinkURL:URL]; |
| }]; |
| } |
| |
| return defaultAction; |
| } |
| |
| @end |