Detecting taps on attributed text in a UITextView

2019-01-01 08:03发布

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 iOS7 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.

10条回答
临风纵饮
2楼-- · 2019-01-01 08:19

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:
}
查看更多
春风洒进眼中
3楼-- · 2019-01-01 08:23

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

    }
}
查看更多
零度萤火
4楼-- · 2019-01-01 08:28

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

查看更多
素衣白纱
5楼-- · 2019-01-01 08:30

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!!

查看更多
栀子花@的思念
6楼-- · 2019-01-01 08:35

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!

查看更多
刘海飞了
7楼-- · 2019-01-01 08:41

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.

查看更多
登录 后发表回答