From 6bb3ece3935540754d20adeb45eaa856a9f63766 Mon Sep 17 00:00:00 2001 From: Galia Kaufman Date: Mon, 1 Jun 2020 08:42:16 -0700 Subject: [PATCH] [Dialogs] Adding tappable link support to the alert message Adding an action API for notifying caller when a message link is tapped. PiperOrigin-RevId: 314135831 --- ...ialogsAccessoryExampleViewController.swift | 147 ++++++++---------- components/Dialogs/src/MDCAlertController.h | 31 +++- components/Dialogs/src/MDCAlertController.m | 18 ++- .../private/MDCAlertControllerView+Private.m | 2 + .../tests/unit/MDCAlertControllerTests.m | 14 ++ 5 files changed, 124 insertions(+), 88 deletions(-) diff --git a/components/Dialogs/examples/DialogsAccessoryExampleViewController.swift b/components/Dialogs/examples/DialogsAccessoryExampleViewController.swift index 85f69cd1f05..46144e7250f 100644 --- a/components/Dialogs/examples/DialogsAccessoryExampleViewController.swift +++ b/components/Dialogs/examples/DialogsAccessoryExampleViewController.swift @@ -27,19 +27,21 @@ class DialogsAccessoryExampleViewController: MDCCollectionViewController { let attributedText: NSAttributedString = { typealias AttrDict = [NSAttributedString.Key: Any] - let bgAttr: AttrDict = [.backgroundColor: UIColor.blue.withAlphaComponent(0.2)] + let bgAttr: AttrDict = [.backgroundColor: UIColor.systemBlue.withAlphaComponent(0.3)] let orangeAttr: AttrDict = [.foregroundColor: UIColor.orange] - let linkAttr: AttrDict = [.link: URL(string: "https://www.google.com/search?q=lorem+ipsum")!] + let urlAttr: AttrDict = [.link: "https://www.google.com/search?q=lorem+ipsum"] + let customLinkAttr: AttrDict = [.link: "mdccatalog://"] // A custom link. let attributedText = NSMutableAttributedString() - attributedText.append(NSAttributedString(string: "Lorem ipsum", attributes: linkAttr)) + attributedText.append(NSAttributedString(string: "Lorem ipsum", attributes: urlAttr)) attributedText.append(NSAttributedString(string: " dolor sit amet, ", attributes: nil)) attributedText.append( NSAttributedString( string: "consectetur adipiscing elit, sed do ", attributes: nil)) attributedText.append(NSAttributedString(string: " eiusmod ", attributes: bgAttr)) - attributedText.append(NSAttributedString(string: " tempor incididunt ut ", attributes: nil)) + attributedText.append(NSAttributedString(string: " tempor ", attributes: customLinkAttr)) + attributedText.append(NSAttributedString(string: "incididunt ut ", attributes: nil)) attributedText.append(NSAttributedString(string: "labore magna ", attributes: orangeAttr)) attributedText.append(NSAttributedString(string: "aliqua.", attributes: nil)) return attributedText @@ -59,11 +61,10 @@ class DialogsAccessoryExampleViewController: MDCCollectionViewController { view.backgroundColor = containerScheme.colorScheme.backgroundColor loadCollectionView(menu: [ - "Text View", + "Attributed Message With Links and a Custom Button", + "Material's Filled Text Field", "Title + Message + UI Text Field", - "Material Filled Text Field", "Custom Label and Button (autolayout)", - "Default Attributed Label and Button", ]) } @@ -83,44 +84,69 @@ class DialogsAccessoryExampleViewController: MDCCollectionViewController { private func performActionFor(row: Int) -> MDCAlertController? { switch row { case 0: - return performTextView() + return performAttributedMessageWithLinks() case 1: - return performTextField() - case 2: return performMDCTextField() + case 2: + return performTextField() case 3: return performCustomLabelWithButton() - case 4: - return performDefaultLabelWithButton() default: print("No row is selected") return nil } } - func performTextView() -> MDCAlertController { - let alert = MDCAlertController() - let textView = DialogTextView() - textView.attributedText = attributedText - textView.font = MDCTypographyScheme().body1 - textView.isEditable = false - textView.isScrollEnabled = false - alert.accessoryView = textView + // Demonstrate Material Dialog's attributed message text with tappable links, used in conjunction + // with a custom accessory view. + func performAttributedMessageWithLinks() -> MDCAlertController { + // Set an attributed text as the message, with both internal and external URLs as tappable links. + let alert = MDCAlertController(title: "Title", attributedMessage: attributedText) alert.addAction(MDCAlertAction(title: "Dismiss", emphasis: .medium, handler: handler)) - alert.applyTheme(withScheme: self.containerScheme) - return alert - } - func performTextField() -> MDCAlertController { - let alert = MDCAlertController(title: "This is a title", message: "This is a message") - let textField = UITextField() - textField.placeholder = "This is a text field" - alert.accessoryView = textField - alert.addAction(MDCAlertAction(title: "Dismiss", emphasis: .medium, handler: handler)) + // Setup a custom accessory view. + let button = MDCButton() + button.setTitle("Learn More", for: UIControl.State.normal) + button.contentEdgeInsets = .zero + button.applyTextTheme(withScheme: containerScheme) + button.sizeToFit() + + let size = button.bounds.size + let view = UIView(frame: CGRect(x: 0, y: 0, width: size.width, height: size.height)) + view.addSubview(button) + + alert.accessoryView = view + if let alertView = alert.view as? MDCAlertControllerView { + alertView.accessoryViewVerticalInset = 0 + alertView.contentInsets = UIEdgeInsets(top: 24, left: 24, bottom: 10, right: 24) + } + + // Enable dynamic type. + alert.mdc_adjustsFontForContentSizeCategory = true + + // Respond to a link-tap event: + alert.attributedMessageAction = { url, range, interaction in + // Defer to the UITextView's default URL interaction for non-custom links. + guard url.absoluteString == "mdccatalog://" else { return true } + + print("A custom action for link:", url.absoluteString, " in range:", range) + + // Dismiss the alert for short-tap interactions. + if interaction == .invokeDefaultAction { + alert.dismiss(animated: true) + } + + // Disable UITextView's default URL interaction. + return false + } + + // Theming updates the message's text color, which may override foreground text attributes. alert.applyTheme(withScheme: self.containerScheme) return alert } + // Demonstrate a custom view with MDCFilledTextField being assigned to the accessoryView API. + // This example also demonstrates the use of autolayout in custom views. func performMDCTextField() -> MDCAlertController { let alert = MDCAlertController(title: "Rename File", message: nil) alert.addAction(MDCAlertAction(title: "Rename", emphasis: .medium, handler: handler)) @@ -161,7 +187,17 @@ class DialogsAccessoryExampleViewController: MDCCollectionViewController { return alert } - // Demonstrating a custom accessory view with auto layout, presenting a label and a button. + func performTextField() -> MDCAlertController { + let alert = MDCAlertController(title: "This is a title", message: "This is a message") + let textField = UITextField() + textField.placeholder = "This is a text field" + alert.accessoryView = textField + alert.addAction(MDCAlertAction(title: "Dismiss", emphasis: .medium, handler: handler)) + alert.applyTheme(withScheme: self.containerScheme) + return alert + } + + // Demonstrate a custom accessory view with auto layout, presenting a label and a button. func performCustomLabelWithButton() -> MDCAlertController { let alert = MDCAlertController(title: "Title", message: nil) alert.addAction(MDCAlertAction(title: "Dismiss", emphasis: .medium, handler: handler)) @@ -191,32 +227,7 @@ class DialogsAccessoryExampleViewController: MDCCollectionViewController { alert.accessoryView = view alert.applyTheme(withScheme: self.containerScheme) - return alert - } - - // Demonstrating a custom accessory view with manual layout, presenting a button, while using the - // alert's default label. - func performDefaultLabelWithButton() -> MDCAlertController { - let alert = MDCAlertController(title: "Title", attributedMessage: attributedText) - alert.addAction(MDCAlertAction(title: "Dismiss", emphasis: .medium, handler: handler)) - - let button = MDCButton() - button.setTitle("Learn More", for: UIControl.State.normal) - button.contentEdgeInsets = .zero - button.applyTextTheme(withScheme: containerScheme) - button.sizeToFit() - - let size = button.bounds.size - let view = UIView(frame: CGRect(x: 0, y: 0, width: size.width, height: size.height)) - view.addSubview(button) - - alert.accessoryView = view - if let alertView = alert.view as? MDCAlertControllerView { - alertView.accessoryViewVerticalInset = 0 - alertView.contentInsets = UIEdgeInsets(top: 24, left: 24, bottom: 10, right: 24) - } - alert.applyTheme(withScheme: self.containerScheme) return alert } @@ -285,9 +296,10 @@ extension DialogsAccessoryExampleViewController { } } - @objc func testTextView() { + @objc func testAttributedMessageWithLinks() { resetTests() - self.present(performTextView(), animated: false, completion: nil) + self.present( + performAttributedMessageWithLinks(), animated: false, completion: nil) } @objc func testTextField() { @@ -305,25 +317,4 @@ extension DialogsAccessoryExampleViewController { self.present(performCustomLabelWithButton(), animated: false, completion: nil) } - @objc func testDefaultLabelWithButton() { - resetTests() - self.present(performDefaultLabelWithButton(), animated: false, completion: nil) - } - -} - -/// Custom subclass of UITextView - to avoid resizing issue after 3D touch (b/155127456). -private class DialogTextView: UITextView { - - override func systemLayoutSizeFitting( - _ targetSize: CGSize, - withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, - verticalFittingPriority: UILayoutPriority - ) -> CGSize { - return self.sizeThatFits(targetSize) - } - - override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize { - return self.sizeThatFits(targetSize) - } } diff --git a/components/Dialogs/src/MDCAlertController.h b/components/Dialogs/src/MDCAlertController.h index f8352886144..d43045b3b80 100644 --- a/components/Dialogs/src/MDCAlertController.h +++ b/components/Dialogs/src/MDCAlertController.h @@ -42,8 +42,6 @@ After creating the alert controller, add actions to the controller by calling -addAction. - @note Most alerts don't need titles. Use only for high-risk situations. - @param title The title of the alert. @param message Descriptive text that summarizes a decision in a sentence of two. @return An initialized MDCAlertController object. @@ -56,13 +54,11 @@ After creating the alert controller, add actions to the controller by calling -addAction. - @note Most alerts don't need titles. Use only for high-risk situations. + @note Set `attributedMessageAction` to respond to link-tap events, if needed. @note This method receives an @c NSAttributedString for the display message. Use @c alertControllerWithTitle:message: for regular @c NSString support. - @note Currently tappable embedded links within @c attributedMessage are not supported. - @param alertTitle The title of the alert. @param attributedMessage Descriptive text that summarizes a decision in a sentence of two. @return An initialized MDCAlertController object. @@ -77,6 +73,25 @@ /** Alert controllers must be created with alertControllerWithTitle:message: */ - (nullable instancetype)initWithCoder:(nonnull NSCoder *)aDecoder NS_UNAVAILABLE; +/** + A block that is invoked whan a link (a URL) in the attributed message text is tapped. + + @param URL The URL of the link that was tapped. May include external or internal URLs. + @param characterRange The range of characters (in the attributed text) of the link that was tapped. + @param interaction The UITextItemInteraction type of interaction performed by the user. + + @return true if UIKit's default implementation of the interaction should proceed after this block + is invoked. +*/ +typedef BOOL (^MDCAttributedMessageActionHandler)(NSURL *_Nonnull URL, NSRange range, + UITextItemInteraction interaction); + +/** + An action that is invoked when a link (URL) in the attributed message is interacted with. Applies + only when `attributedMessage` is set. +*/ +@property(nonatomic, copy, nullable) MDCAttributedMessageActionHandler attributedMessageAction; + /** An object conforming to @c MDCAlertControllerDelegate. When non-nil, the @c MDCAlertController will call the appropriate @c MDCAlertControllerDelegate methods on this object. @@ -178,15 +193,15 @@ */ @property(nonatomic, nullable, copy) NSString *titleAccessibilityLabel; -/** Descriptive text that summarizes a decision in a sentence of two. */ +/** Descriptive text that summarizes a decision in a sentence or two. */ @property(nonatomic, nullable, copy) NSString *message; /** - Descriptive attributed text that summarizes a decision in a sentence of two. + Descriptive text that summarizes a decision in a sentence or two, in an attributed string format. If provided and non-empty, will be used instead of @c message property. - @note Currently tappable embedded links within @c attributedMessage are not supported. + @note Set `attributedMessageAction` to respond to link-tap events, if needed. */ @property(nonatomic, nullable, copy) NSAttributedString *attributedMessage; diff --git a/components/Dialogs/src/MDCAlertController.m b/components/Dialogs/src/MDCAlertController.m index b0c5ea3d8dc..e70ff01d853 100644 --- a/components/Dialogs/src/MDCAlertController.m +++ b/components/Dialogs/src/MDCAlertController.m @@ -75,7 +75,7 @@ - (id)copyWithZone:(__unused NSZone *)zone { @end -@interface MDCAlertController () +@interface MDCAlertController () @property(nonatomic, nullable, weak) MDCAlertControllerView *alertView; @property(nonatomic, strong) MDCDialogTransitionController *transitionController; @@ -594,6 +594,18 @@ - (void)actionButtonPressed:(id)button forEvent:(UIEvent *)event { }]; } +#pragma mark - Text View Delegate + +- (BOOL)textView:(UITextView *)textView + shouldInteractWithURL:(NSURL *)URL + inRange:(NSRange)characterRange + interaction:(UITextItemInteraction)interaction { + if (self.attributedMessageAction != nil) { + return self.attributedMessageAction(URL, characterRange, interaction); + } + return YES; +} + #pragma mark - UIViewController - (void)loadView { @@ -750,7 +762,9 @@ - (void)setupAlertView { } self.alertView.titleLabel.accessibilityLabel = self.titleAccessibilityLabel ?: self.title; self.alertView.messageTextView.accessibilityLabel = - self.messageAccessibilityLabel ?: self.message; + self.messageAccessibilityLabel ?: self.message ?: self.attributedMessage.string; + self.alertView.messageTextView.delegate = self; + self.alertView.titleIconImageView.accessibilityLabel = self.imageAccessibilityLabel; self.alertView.titleIconView.accessibilityLabel = self.imageAccessibilityLabel; diff --git a/components/Dialogs/src/private/MDCAlertControllerView+Private.m b/components/Dialogs/src/private/MDCAlertControllerView+Private.m index 77c5e451dd3..d0696338f4e 100644 --- a/components/Dialogs/src/private/MDCAlertControllerView+Private.m +++ b/components/Dialogs/src/private/MDCAlertControllerView+Private.m @@ -92,6 +92,8 @@ - (instancetype)initWithFrame:(CGRect)frame { self.messageTextView.textContainer.lineFragmentPadding = 0.0f; self.messageTextView.editable = NO; self.messageTextView.scrollEnabled = NO; + self.messageTextView.selectable = YES; // Enables link tap. + self.messageTextView.dataDetectorTypes = UIDataDetectorTypeLink; if (self.mdc_adjustsFontForContentSizeCategory) { self.messageTextView.font = [UIFont mdc_preferredFontForMaterialTextStyle:MDCFontTextStyleBody1]; diff --git a/components/Dialogs/tests/unit/MDCAlertControllerTests.m b/components/Dialogs/tests/unit/MDCAlertControllerTests.m index ad84e9eb30b..0b7c8d64e7b 100644 --- a/components/Dialogs/tests/unit/MDCAlertControllerTests.m +++ b/components/Dialogs/tests/unit/MDCAlertControllerTests.m @@ -183,6 +183,7 @@ - (void)testAttributedMessageInit { // Then XCTAssertNotNil(self.attributedAlert.actions); XCTAssertNotNil(self.attributedAlert.title); + XCTAssertNil(self.attributedAlert.attributedMessageAction); XCTAssertTrue(self.attributedAlert.adjustsFontForContentSizeCategoryWhenScaledFontIsUnavailable); XCTAssertEqualObjects(self.attributedAlert.shadowColor, UIColor.blackColor); @@ -404,6 +405,19 @@ - (void)testAlertControllerMessageAccessibilityLabelWhenOnlyMessageIsSetWhenView XCTAssertEqualObjects(view.messageTextView.accessibilityLabel, message); } +- (void)testAlertControllerMessageAccessibilityLabelWhenOnlyAttributedMessageIsSet { + // Given + NSAttributedString *message = [[NSAttributedString alloc] initWithString:@"Foo"]; + + // When + self.attributedAlert.attributedMessage = message; + MDCAlertControllerView *view = (MDCAlertControllerView *)self.attributedAlert.view; + self.attributedAlert.messageAccessibilityLabel = nil; + + // Then + XCTAssertEqualObjects(view.messageTextView.accessibilityLabel, message.string); +} + - (void)testAlertControllerSetTitleAccessibilityLabelWhenTitleIsSetWhenViewIsNotLoaded { // Given NSString *title = @"Foo";