Skip to content

Commit

Permalink
[Dialogs] Adding tappable link support to the alert message
Browse files Browse the repository at this point in the history
Adding an action API for notifying caller when a message link is tapped.

PiperOrigin-RevId: 314135831
  • Loading branch information
galiak11 authored and material-automation committed Jun 1, 2020
1 parent 11db172 commit 6bb3ece
Show file tree
Hide file tree
Showing 5 changed files with 124 additions and 88 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
])
}

Expand All @@ -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))
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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)
}
}
31 changes: 23 additions & 8 deletions components/Dialogs/src/MDCAlertController.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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;

Expand Down
18 changes: 16 additions & 2 deletions components/Dialogs/src/MDCAlertController.m
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ - (id)copyWithZone:(__unused NSZone *)zone {

@end

@interface MDCAlertController ()
@interface MDCAlertController () <UITextViewDelegate>

@property(nonatomic, nullable, weak) MDCAlertControllerView *alertView;
@property(nonatomic, strong) MDCDialogTransitionController *transitionController;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
14 changes: 14 additions & 0 deletions components/Dialogs/tests/unit/MDCAlertControllerTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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";
Expand Down

0 comments on commit 6bb3ece

Please sign in to comment.