129

I have a UITextView which displays an NSAttributedString. This string contains words that I'd like to make tappable, such that when they are tapped I get called back so that I can perform an action. I realise that UITextView can detect taps on a URL and call back my delegate, but these aren't URLs.

It seems to me that with iOS 7 and the power of TextKit this should now be possible, however I can't find any examples and I'm not sure where to start.

I understand that it's now possible to create custom attributes in the string (although I haven't done this yet), and perhaps these will be useful to detecting if one of the magic words has been tapped? In any case, I still don't know how to intercept that tap and detect on which word the tap occurred.

Note that iOS 6 compatibility is not required.

1
  • Note: in iOS 10 and up, use the NSAttributedString.Key.link attribute instead. See my answer - however, prior to that it appears you'll have to go with the accepted answer here. Commented Nov 13, 2020 at 0:08

12 Answers 12

122

I just wanted to help others a little more. Following on from Shmidt's response it's possible to do exactly as I had asked in my original question.

1) Create an attributed string with custom attributes applied to the clickable words. eg.

NSAttributedString* attributedString = [[NSAttributedString alloc] initWithString:@"a clickable word" attributes:@{ @"myCustomTag" : @(YES) }]; [paragraph appendAttributedString:attributedString]; 

2) Create a UITextView to display that string, and add a UITapGestureRecognizer to it. Then handle the tap:

- (void)textTapped:(UITapGestureRecognizer *)recognizer { UITextView *textView = (UITextView *)recognizer.view; // Location of the tap in text-container coordinates NSLayoutManager *layoutManager = textView.layoutManager; CGPoint location = [recognizer locationInView:textView]; location.x -= textView.textContainerInset.left; location.y -= textView.textContainerInset.top; // Find the character that's been tapped on NSUInteger characterIndex; characterIndex = [layoutManager characterIndexForPoint:location inTextContainer:textView.textContainer fractionOfDistanceBetweenInsertionPoints:NULL]; if (characterIndex < textView.textStorage.length) { NSRange range; id value = [textView.attributedText attribute:@"myCustomTag" atIndex:characterIndex effectiveRange:&range]; // Handle as required... NSLog(@"%@, %d, %d", value, range.location, range.length); } } 

So easy when you know how!

Sign up to request clarification or add additional context in comments.

13 Comments

How would you solve this in IOS 6 ? Can you please take a look at this question ?stackoverflow.com/questions/19837522/…
Actually characterIndexForPoint:inTextContainer: fractionOfDistanceBetweenInsertionPoints is available on iOS 6, so I think it should work. Let us know! See this project for an example: github.com/laevandus/NSTextFieldHyperlinks/blob/master/…
Documentation says it's only available in IOS 7 or later :)
Yes, sorry. I was getting myself confused with Mac OS! This is iOS7 only.
It doesn't seem to work, when you have not-selectable UITextView
|
68

Detecting taps on attributed text with Swift

Sometimes for beginners it is a little hard to know how to do get things set up (it was for me anyway), so this example is a little fuller.

Add a UITextView to your project.

Outlet

Connect the UITextView to the ViewController with an outlet named textView.

Custom attribute

We are going to make a custom attribute by making an Extension.

Note: This step is technically optional, but if you don't do it you will need to edit the code in the next part to use a standard attribute like NSAttributedString.Key.foregroundColor. The advantage of using a custom attribute is that you can define what values you want to store in the attributed text range.

Add a new swift file with File > New > File... > iOS > Source > Swift File. You can call it what you want. I am calling mine NSAttributedStringKey+CustomAttribute.swift.

Paste in the following code:

import Foundation extension NSAttributedString.Key { static let myAttributeName = NSAttributedString.Key(rawValue: "MyCustomAttribute") } 

Code

Replace the code in ViewController.swift with the following. Note the UIGestureRecognizerDelegate.

import UIKit class ViewController: UIViewController, UIGestureRecognizerDelegate { @IBOutlet weak var textView: UITextView! override func viewDidLoad() { super.viewDidLoad() // Create an attributed string let myString = NSMutableAttributedString(string: "Swift attributed text") // Set an attribute on part of the string let myRange = NSRange(location: 0, length: 5) // range of "Swift" let myCustomAttribute = [ NSAttributedString.Key.myAttributeName: "some value"] myString.addAttributes(myCustomAttribute, range: myRange) textView.attributedText = myString // Add tap gesture recognizer to Text View let tap = UITapGestureRecognizer(target: self, action: #selector(myMethodToHandleTap(_:))) tap.delegate = self textView.addGestureRecognizer(tap) } @objc func myMethodToHandleTap(_ sender: UITapGestureRecognizer) { let myTextView = sender.view as! UITextView let layoutManager = myTextView.layoutManager // location of tap in myTextView coordinates and taking the inset into account var location = sender.location(in: myTextView) location.x -= myTextView.textContainerInset.left; location.y -= myTextView.textContainerInset.top; // character index at tap location let characterIndex = layoutManager.characterIndex(for: location, in: myTextView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) // if index is valid then do something. if characterIndex < myTextView.textStorage.length { // print the character index print("character index: \(characterIndex)") // print the character at the index let myRange = NSRange(location: characterIndex, length: 1) let substring = (myTextView.attributedText.string as NSString).substring(with: myRange) print("character at index: \(substring)") // check if the tap location has a certain attribute let attributeName = NSAttributedString.Key.myAttributeName let attributeValue = myTextView.attributedText?.attribute(attributeName, at: characterIndex, effectiveRange: nil) if let value = attributeValue { print("You tapped on \(attributeName.rawValue) and the value is: \(value)") } } } } 

enter image description here

Now if you tap on the "w" of "Swift", you should get the following result:

character index: 1 character at index: w You tapped on MyCustomAttribute and the value is: some value 

Notes

  • Here I used a custom attribute, but it could have just as easily been NSAttributedString.Key.foregroundColor (text color) that has a value of UIColor.green.
  • Formerly the text view could not be editable or selectable, but in my updated answer for Swift 4.2 it seems to be working fine no matter whether these are selected or not.

Further study

This answer was based on several other answers to this question. Besides these, see also

17 Comments

use myTextView.textStorage instead of myTextView.attributedText.string
Detecting tap through tap gesture in iOS 9 doesnt work for successive taps. Any updates on that?
@WaqasMahmood, I started a new question for this issue. You can star it and check back later for any answers. Feel free to edit that question or add comments if there are any more pertinent details.
@dejix I resolve the problem by adding each time another " " empty string to the end of my TextView. That way the detection stops after your last word. Hope it helps
Works perfectly with multiple taps, I just put in a short routine to prove this: if characterIndex < 12 { textView.textColor = UIColor.magenta }else{ textView.textColor = UIColor.blue } Really clear & simple code
|
32

This is a slightly modified version, building off of @tarmes answer. I couldn't get the valuevariable to return anything but null without the tweak below. Also, I needed the full attribute dictionary returned in order to determine the resulting action. I would have put this in the comments but don't appear to have the rep to do so. Apologies in advance if I have violated protocol.

Specific tweak is to use textView.textStorage instead of textView.attributedText. As a still learning iOS programmer, I am not really sure why this is, but perhaps someone else can enlighten us.

Specific modification in the tap handling method:

 NSDictionary *attributesOfTappedText = [textView.textStorage attributesAtIndex:characterIndex effectiveRange:&range]; 

Full code in my view controller

- (void)viewDidLoad { [super viewDidLoad]; self.textView.attributedText = [self attributedTextViewString]; UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(textTapped:)]; [self.textView addGestureRecognizer:tap]; } - (NSAttributedString *)attributedTextViewString { NSMutableAttributedString *paragraph = [[NSMutableAttributedString alloc] initWithString:@"This is a string with " attributes:@{NSForegroundColorAttributeName:[UIColor blueColor]}]; NSAttributedString* attributedString = [[NSAttributedString alloc] initWithString:@"a tappable string" attributes:@{@"tappable":@(YES), @"networkCallRequired": @(YES), @"loadCatPicture": @(NO)}]; NSAttributedString* anotherAttributedString = [[NSAttributedString alloc] initWithString:@" and another tappable string" attributes:@{@"tappable":@(YES), @"networkCallRequired": @(NO), @"loadCatPicture": @(YES)}]; [paragraph appendAttributedString:attributedString]; [paragraph appendAttributedString:anotherAttributedString]; return [paragraph copy]; } - (void)textTapped:(UITapGestureRecognizer *)recognizer { UITextView *textView = (UITextView *)recognizer.view; // Location of the tap in text-container coordinates NSLayoutManager *layoutManager = textView.layoutManager; CGPoint location = [recognizer locationInView:textView]; location.x -= textView.textContainerInset.left; location.y -= textView.textContainerInset.top; NSLog(@"location: %@", NSStringFromCGPoint(location)); // Find the character that's been tapped on NSUInteger characterIndex; characterIndex = [layoutManager characterIndexForPoint:location inTextContainer:textView.textContainer fractionOfDistanceBetweenInsertionPoints:NULL]; if (characterIndex < textView.textStorage.length) { NSRange range; NSDictionary *attributes = [textView.textStorage attributesAtIndex:characterIndex effectiveRange:&range]; NSLog(@"%@, %@", attributes, NSStringFromRange(range)); //Based on the attributes, do something ///if ([attributes objectForKey:...)] //make a network call, load a cat Pic, etc } } 

2 Comments

Had the same problem with the textView.attributedText! THANK YOU for the textView.textStorage hint!
Detecting tap through tap gesture in iOS 9 doesnt work for successive taps.
26

Making custom link and doing what you want on the tap has become much easier with iOS 7. There is very good example at Ray Wenderlich

3 Comments

This is a much cleaner solution than trying to calculate string positions relative to their container view.
The problem is textView needs to be selectable, and I don't want this behavior.
@ThomásC. +1 for the pointer on why my UITextView was not detecting links even when I had set it to detect them via IB. (I had also made it unselectable)
13

WWDC 2013 example:

NSLayoutManager *layoutManager = textView.layoutManager; CGPoint location = [touch locationInView:textView]; NSUInteger characterIndex; characterIndex = [layoutManager characterIndexForPoint:location inTextContainer:textView.textContainer fractionOfDistanceBetweenInsertionPoints:NULL]; if (characterIndex < textView.textStorage.length) { // valid index // Find the word range here // using -enumerateSubstringsInRange:options:usingBlock: } 

2 Comments

Thank you! I'll watch the WWDC video too.
@Suragch "Advanced Text Layouts and Effects with Text Kit".
11

I was able to solve this pretty simply with NSLinkAttributeName

Swift 2

class MyClass: UIViewController, UITextViewDelegate { @IBOutlet weak var tvBottom: UITextView! override func viewDidLoad() { super.viewDidLoad() let attributedString = NSMutableAttributedString(string: "click me ok?") attributedString.addAttribute(NSLinkAttributeName, value: "cs://moreinfo", range: NSMakeRange(0, 5)) tvBottom.attributedText = attributedString tvBottom.delegate = self } func textView(textView: UITextView, shouldInteractWithURL URL: NSURL, inRange characterRange: NSRange) -> Bool { UtilityFunctions.alert("clicked", message: "clicked") return false } } 

2 Comments

You should check that your URL was tapped and not another URL with if URL.scheme == "cs" and return true outside of the if statement so the UITextView can handle normal https:// links that are tapped
I did that and it worked reasonably well on iPhone 6 and 6+ but didn't work at all on iPhone 5. Went with Suragch solution above, which just works. Never found out why iPhone 5 would have a problem with this, made no sense.
11

Complete example for detect actions on attributed text with Swift 3

let termsAndConditionsURL = TERMS_CONDITIONS_URL; let privacyURL = PRIVACY_URL; override func viewDidLoad() { super.viewDidLoad() self.txtView.delegate = self let str = "By continuing, you accept the Terms of use and Privacy policy" let attributedString = NSMutableAttributedString(string: str) var foundRange = attributedString.mutableString.range(of: "Terms of use") //mention the parts of the attributed text you want to tap and get an custom action attributedString.addAttribute(NSLinkAttributeName, value: termsAndConditionsURL, range: foundRange) foundRange = attributedString.mutableString.range(of: "Privacy policy") attributedString.addAttribute(NSLinkAttributeName, value: privacyURL, range: foundRange) txtView.attributedText = attributedString } 

And then you can catch the action with shouldInteractWith URL UITextViewDelegate delegate method.So make sure you have set the delegate properly.

func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { let storyboard = UIStoryboard(name: "Main", bundle: nil) let vc = storyboard.instantiateViewController(withIdentifier: "WebView") as! SKWebViewController if (URL.absoluteString == termsAndConditionsURL) { vc.strWebURL = TERMS_CONDITIONS_URL self.navigationController?.pushViewController(vc, animated: true) } else if (URL.absoluteString == privacyURL) { vc.strWebURL = PRIVACY_URL self.navigationController?.pushViewController(vc, animated: true) } return false } 

Like wise you can perform any action according to your requirement.

Cheers!!

Comments

4

It's possible to do that with characterIndexForPoint:inTextContainer:fractionOfDistanceBetweenInsertionPoints:. It'll work somewhat differently than you wanted - you'll have to test if a tapped character belongs to a magic word. But it shouldn't be complicated.

BTW I highly recommend watching Introducing Text Kit from WWDC 2013.

Comments

4

With Swift 5 and iOS 12, you can create a subclass of UITextView and override point(inside:with:) with some TextKit implementation in order to make only some NSAttributedStrings in it tappable.


The following code shows how to create a UITextView that only reacts to taps on underlined NSAttributedStrings in it:

InteractiveUnderlinedTextView.swift

import UIKit class InteractiveUnderlinedTextView: UITextView { override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) configure() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) configure() } func configure() { isScrollEnabled = false isEditable = false isSelectable = false isUserInteractionEnabled = true } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let superBool = super.point(inside: point, with: event) let characterIndex = layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) guard characterIndex < textStorage.length else { return false } let attributes = textStorage.attributes(at: characterIndex, effectiveRange: nil) return superBool && attributes[NSAttributedString.Key.underlineStyle] != nil } } 

ViewController.swift

import UIKit class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let linkTextView = InteractiveUnderlinedTextView() linkTextView.backgroundColor = .orange let mutableAttributedString = NSMutableAttributedString(string: "Some text\n\n") let attributes = [NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue] let underlinedAttributedString = NSAttributedString(string: "Some other text", attributes: attributes) mutableAttributedString.append(underlinedAttributedString) linkTextView.attributedText = mutableAttributedString let tapGesture = UITapGestureRecognizer(target: self, action: #selector(underlinedTextTapped)) linkTextView.addGestureRecognizer(tapGesture) view.addSubview(linkTextView) linkTextView.translatesAutoresizingMaskIntoConstraints = false linkTextView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true linkTextView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true linkTextView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor).isActive = true } @objc func underlinedTextTapped(_ sender: UITapGestureRecognizer) { print("Hello") } } 

1 Comment

Hi, Is there any way to make this conform to multiple attributes rather than just one?
3

Use this extension for Swift:

import UIKit extension UITapGestureRecognizer { func didTapAttributedTextInTextView(textView: UITextView, inRange targetRange: NSRange) -> Bool { let layoutManager = textView.layoutManager let locationOfTouch = self.location(in: textView) let index = layoutManager.characterIndex(for: locationOfTouch, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) return NSLocationInRange(index, targetRange) } } 

Add UITapGestureRecognizer to your text view with following selector:

guard let text = textView.attributedText?.string else { return } let textToTap = "Tap me" if let range = text.range(of: textToTap), tapGesture.didTapAttributedTextInTextView(textView: textTextView, inRange: NSRange(range, in: text)) { // Tap recognized } 

Comments

1

This one might work OK with short link, multilink in a textview. It work OK with iOS 6,7,8.

- (void)tappedTextView:(UITapGestureRecognizer *)tapGesture { if (tapGesture.state != UIGestureRecognizerStateEnded) { return; } UITextView *textView = (UITextView *)tapGesture.view; CGPoint tapLocation = [tapGesture locationInView:textView]; NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink|NSTextCheckingTypePhoneNumber error:nil]; NSArray* resultString = [detector matchesInString:self.txtMessage.text options:NSMatchingReportProgress range:NSMakeRange(0, [self.txtMessage.text length])]; BOOL isContainLink = resultString.count > 0; if (isContainLink) { for (NSTextCheckingResult* result in resultString) { CGRect linkPosition = [self frameOfTextRange:result.range inTextView:self.txtMessage]; if(CGRectContainsPoint(linkPosition, tapLocation) == 1){ if (result.resultType == NSTextCheckingTypePhoneNumber) { NSString *phoneNumber = [@"telprompt://" stringByAppendingString:result.phoneNumber]; [[UIApplication sharedApplication] openURL:[NSURL URLWithString:phoneNumber]]; } else if (result.resultType == NSTextCheckingTypeLink) { [[UIApplication sharedApplication] openURL:result.URL]; } } } } } - (CGRect)frameOfTextRange:(NSRange)range inTextView:(UITextView *)textView { UITextPosition *beginning = textView.beginningOfDocument; UITextPosition *start = [textView positionFromPosition:beginning offset:range.location]; UITextPosition *end = [textView positionFromPosition:start offset:range.length]; UITextRange *textRange = [textView textRangeFromPosition:start toPosition:end]; CGRect firstRect = [textView firstRectForRange:textRange]; CGRect newRect = [textView convertRect:firstRect fromView:textView.textInputView]; return newRect; } 

1 Comment

Detecting tap through tap gesture in iOS 9 doesnt work for successive taps.
1

This has changed as of iOS 10. In iOS 10, you can use the .link attribute and it all just works.

No need for custom attributes, tap gesture recognisers or anything. It works like an ordinary URL.

To do this, instead of adding the url to the NSMutableAttributedString, add what you want to call the url instead (eg, 'cats' to go to the wikipedia page about cats), and then add the standard attribute NSAttributedString.Key.link (I'm using Swift here), with the NSURL containing the target URL.

Reference: https://medium.com/real-solutions-artificial-intelligence/create-clickable-links-with-nsmutableattributedstring-12b6661a357d

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.