Create tap-able "links" in the NSAttributedString of a UILabel? Create tap-able "links" in the NSAttributedString of a UILabel? ios ios

Create tap-able "links" in the NSAttributedString of a UILabel?


In general, if we want to have a clickable link in text displayed by UILabel, we would need to resolve two independent tasks:

  1. Changing the appearance of a portion of the text to look like a link
  2. Detecting and handling touches on the link (opening an URL is a particular case)

The first one is easy. Starting from iOS 6 UILabel supports display of attributed strings. All you need to do is to create and configure an instance of NSMutableAttributedString:

NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:@"String with a link" attributes:nil];NSRange linkRange = NSMakeRange(14, 4); // for the word "link" in the string aboveNSDictionary *linkAttributes = @{ NSForegroundColorAttributeName : [UIColor colorWithRed:0.05 green:0.4 blue:0.65 alpha:1.0],                                  NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle) };[attributedString setAttributes:linkAttributes range:linkRange];// Assign attributedText to UILabellabel.attributedText = attributedString;

That's it! The code above makes UILabel to display String with a link

Now we should detect touches on this link. The idea is to catch all taps within UILabel and figure out whether the location of the tap was close enough to the link. To catch touches we can add tap gesture recognizer to the label. Make sure to enable userInteraction for the label, it's turned off by default:

label.userInteractionEnabled = YES;[label addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapOnLabel:)]]; 

Now the most sophisticated stuff: finding out whether the tap was on where the link is displayed and not on any other portion of the label. If we had single-lined UILabel, this task could be solved relatively easy by hardcoding the area bounds where the link is displayed, but let's solve this problem more elegantly and for general case - multiline UILabel without preliminary knowledge about the link layout.

One of the approaches is to use capabilities of Text Kit API introduced in iOS 7:

// Create instances of NSLayoutManager, NSTextContainer and NSTextStorageNSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeZero];NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString];// Configure layoutManager and textStorage[layoutManager addTextContainer:textContainer];[textStorage addLayoutManager:layoutManager];// Configure textContainertextContainer.lineFragmentPadding = 0.0;textContainer.lineBreakMode = label.lineBreakMode;textContainer.maximumNumberOfLines = label.numberOfLines;

Save created and configured instances of NSLayoutManager, NSTextContainer and NSTextStorage in properties in your class (most likely UIViewController's descendant) - we'll need them in other methods.

Now, each time the label changes its frame, update textContainer's size:

- (void)viewDidLayoutSubviews{    [super viewDidLayoutSubviews];    self.textContainer.size = self.label.bounds.size;}

And finally, detect whether the tap was exactly on the link:

- (void)handleTapOnLabel:(UITapGestureRecognizer *)tapGesture{    CGPoint locationOfTouchInLabel = [tapGesture locationInView:tapGesture.view];    CGSize labelSize = tapGesture.view.bounds.size;    CGRect textBoundingBox = [self.layoutManager usedRectForTextContainer:self.textContainer];    CGPoint textContainerOffset = CGPointMake((labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,                                              (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y);    CGPoint locationOfTouchInTextContainer = CGPointMake(locationOfTouchInLabel.x - textContainerOffset.x,                                                         locationOfTouchInLabel.y - textContainerOffset.y);    NSInteger indexOfCharacter = [self.layoutManager characterIndexForPoint:locationOfTouchInTextContainer                                                            inTextContainer:self.textContainer                                   fractionOfDistanceBetweenInsertionPoints:nil];    NSRange linkRange = NSMakeRange(14, 4); // it's better to save the range somewhere when it was originally used for marking link in attributed string    if (NSLocationInRange(indexOfCharacter, linkRange)) {        // Open an URL, or handle the tap on the link in any other way        [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"https://stackoverflow.com/"]];    }}


I am extending @NAlexN original detailed solution, with @zekel excellent extension of UITapGestureRecognizer, and providing in Swift.

Extending UITapGestureRecognizer

extension UITapGestureRecognizer {    func didTapAttributedTextInLabel(label: UILabel, inRange targetRange: NSRange) -> Bool {        // Create instances of NSLayoutManager, NSTextContainer and NSTextStorage        let layoutManager = NSLayoutManager()        let textContainer = NSTextContainer(size: CGSize.zero)        let textStorage = NSTextStorage(attributedString: label.attributedText!)        // Configure layoutManager and textStorage        layoutManager.addTextContainer(textContainer)        textStorage.addLayoutManager(layoutManager)        // Configure textContainer        textContainer.lineFragmentPadding = 0.0        textContainer.lineBreakMode = label.lineBreakMode        textContainer.maximumNumberOfLines = label.numberOfLines        let labelSize = label.bounds.size        textContainer.size = labelSize        // Find the tapped character location and compare it to the specified range        let locationOfTouchInLabel = self.location(in: label)        let textBoundingBox = layoutManager.usedRect(for: textContainer)        let textContainerOffset = CGPoint(            x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,            y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y        )        let locationOfTouchInTextContainer = CGPoint(            x: locationOfTouchInLabel.x - textContainerOffset.x,            y: locationOfTouchInLabel.y - textContainerOffset.y        )        let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)        return NSLocationInRange(indexOfCharacter, targetRange)    }}

Usage

Setup UIGestureRecognizer to send actions to tapLabel:, and you can detect if the target ranges is being tapped on in myLabel.

@IBAction func tapLabel(gesture: UITapGestureRecognizer) {    if gesture.didTapAttributedTextInLabel(myLabel, inRange: targetRange1) {        print("Tapped targetRange1")    } else if gesture.didTapAttributedTextInLabel(myLabel, inRange: targetRange2) {        print("Tapped targetRange2")    } else {        print("Tapped none")    }}

IMPORTANT: The UILabel line break mode must be set to wrap by word/char. Somehow, NSTextContainer will assume that the text is single line only if the line break mode is otherwise.


Old question but if anyone can use a UITextView instead of a UILabel, then it is easy. Standard URLs, phone numbers etc will be automatically detected (and be clickable).

However, if you need custom detection, that is, if you want to be able to call any custom method after a user clicks on a particular word, you need to use NSAttributedStrings with an NSLinkAttributeName attribute that will point to a custom URL scheme(as opposed to having the http url scheme by default). Ray Wenderlich has it covered here

Quoting the code from the aforementioned link:

NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:@"This is an example by @marcelofabri_"];[attributedString addAttribute:NSLinkAttributeName                     value:@"username://marcelofabri_"                     range:[[attributedString string] rangeOfString:@"@marcelofabri_"]];NSDictionary *linkAttributes = @{NSForegroundColorAttributeName: [UIColor greenColor],                             NSUnderlineColorAttributeName: [UIColor lightGrayColor],                             NSUnderlineStyleAttributeName: @(NSUnderlinePatternSolid)};// assume that textView is a UITextView previously created (either by code or Interface Builder)textView.linkTextAttributes = linkAttributes; // customizes the appearance of linkstextView.attributedText = attributedString;textView.delegate = self;

To detect those link clicks, implement this:

- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange {    if ([[URL scheme] isEqualToString:@"username"]) {        NSString *username = [URL host];         // do something with this username        // ...        return NO;    }    return YES; // let the system open this URL}

PS: Make sure your UITextView is selectable.