Detecting taps on attributed text in a UITextView in iOS
Asked Answered
L

12

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.

Loewi answered 12/10, 2013 at 8:38 Comment(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.Symonds
L
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!

Loewi answered 12/10, 2013 at 12:47 Comment(13)
How would you solve this in IOS 6 ? Can you please take a look at this question ?#19838022Tullis
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/…Loewi
Documentation says it's only available in IOS 7 or later :)Tullis
Yes, sorry. I was getting myself confused with Mac OS! This is iOS7 only.Loewi
It doesn't seem to work, when you have not-selectable UITextViewReceive
But this isn't the answer to your own question. You asked for tappable words. Just return NO from - (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange and do there whatever you want to do. You don't need all this position calculationZosi
I need certain words to be tappable. That's exactly what this code does. And it is the answer to my own question.Loewi
Please not this is a UITextView not an UILabel which is what I thought it was for a while and ran into a lot of problems.Lithosphere
For this to work in a UITableViewCell make sure the UITextView is set to Selectable as @PaulBrewczynski mentionedOctosyllable
This does not work for iOS 9 @all. Works like charm in iOS 7, iOS 8 though. Tap doesn't work for another attributed string in the same textfield.Spendable
@KevinJantzer Does having the textview in a tableviewcell in iOS 9 cause any problem?Spendable
@DheerajJami - I'm not sure, I'm not using it in a tableviewcellLalla
What's the preferred way? to use the nslinkattribute name or check the tap target manually in this method?Sievert
E
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

Eliathan answered 28/8, 2015 at 2:26 Comment(17)
use myTextView.textStorage instead of myTextView.attributedText.stringJuvenile
Detecting tap through tap gesture in iOS 9 doesnt work for successive taps. Any updates on that?Spendable
@DheerajJami: I made this example using iOS 9 and successive taps worked for me, so I don't know what to tell you. You might try opening your own question with more details. If you get an answer (or you have already found an answer), leave a comment here so that others can benefit.Eliathan
I am using objective c with similar approach. And, successive taps for different attributed objects doesn't work for me. A keyboard shows when you tap on a attributed string, but when you tap on another attributed string with a keyboard showing, the second attributed string doesn't call the tapped gesture recognizer method.Spendable
@Suragch: So in iOS 9 I have checked the tapped gesture, it works without the keyboard, but when I have the keyboard showing, the tap doesn't get recognized for subsequent clicks.Spendable
@DheerajJami Did you find any solution for iOS 9? I am also facing the same problem when the keyboard is showing the tap doesn't get recognized. But its working fine in iOS 8.Rodenticide
@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.Eliathan
@WaqasMahmood There is a new answer on the question I asked for you. If this is a solution to your problem, please leave a comment there and I will mark it as solved.Eliathan
I am getting the taps for every character. But from the end of the last word until the end of the textview, I am getting the last letter. How can I avoid it ? @EliathanAsphodel
@Suragch.. this one is detecting for only first . i want to detect in middle .Widmer
I need more than 1 line and that causes some problems on text detection. I think its the same that mentioned @Asphodel .Any ideas for checking string's height too?Settee
@Settee 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 helpsAsphodel
Using word "my" in method names says about "quality" of the code.Sociolinguistics
@AlexanderVolkov, I don't recommend using "my" in production code, but I use it in teaching examples to make it very clear that it is not any sort of system variable but one that we created ourselves. (In the past when learning a new concept I've had trouble making that distinction with other people's code.)Eliathan
@suragch This is ridiculous. I think let <varname> clearly indicates that the var is defined in code. my prefix just makes it harder to read the code. It's one of the indications of duty code.Sociolinguistics
@suragch also the solution is not complete. It's not working without a fix that allows all gesture recognizers to process.Sociolinguistics
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 codeUndersheriff
S
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

    }
}
Submarginal answered 1/12, 2013 at 18:59 Comment(2)
Had the same problem with the textView.attributedText! THANK YOU for the textView.textStorage hint!Circuit
Detecting tap through tap gesture in iOS 9 doesnt work for successive taps.Spendable
C
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

Conduce answered 22/4, 2014 at 7:40 Comment(3)
This is a much cleaner solution than trying to calculate string positions relative to their container view.Korwin
The problem is textView needs to be selectable, and I don't want this behavior.Hiroko
@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)Explosion
N
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:
}
Nahshu answered 12/10, 2013 at 9:38 Comment(2)
Thank you! I'll watch the WWDC video too.Loewi
@Eliathan "Advanced Text Layouts and Effects with Text Kit".Nahshu
U
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
  }

}
Unpolitic answered 7/1, 2016 at 1:40 Comment(2)
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 tappedGerbold
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.Ballata
L
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!!

Levi answered 28/5, 2017 at 10:57 Comment(1)
Thanks! You save my day!Leafy
S
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.

Stertorous answered 12/10, 2013 at 9:22 Comment(0)
V
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")
    }

}
Vetchling answered 1/7, 2018 at 22:37 Comment(1)
Hi, Is there any way to make this conform to multiple attributes rather than just one?Adlare
H
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
}
Hehre answered 23/8, 2019 at 15:31 Comment(0)
T
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;
}
Terrorist answered 26/11, 2014 at 1:20 Comment(1)
Detecting tap through tap gesture in iOS 9 doesnt work for successive taps.Spendable
S
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

Symonds answered 13/11, 2020 at 0:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.