在iOS中检测UITextView中带属性的文本的轻击操作

129

我有一个显示NSAttributedStringUITextView。 这个字符串包含我想要点击的单词,当它们被点击时我会被回调以便执行某个操作。 我知道UITextView可以检测URL的点击并回调我的代理,但这些不是URL。

在我看来,随着iOS 7和TextKit的强大功能,现在应该是可能的。 但是我找不到任何例子,也不确定从哪里开始。

我了解现在可以在字符串中创建自定义属性(尽管我尚未这样做),也许这些属性将有助于检测是否已经点击了其中一个单词? 无论如何,我仍然不知道如何拦截该点击并检测点击发生在哪个单词上。

请注意,不需要兼容iOS 6。


注意:在iOS 10及以上版本中,请使用NSAttributedString.Key.link属性。请参见我的答案 - 不过,在那之前,似乎你必须采用这里接受的答案。 - Arafangion
12个回答

121

我希望能够更好地帮助他人。继Shmidt的回答之后,可以完全按照我在原问题中所要求的方式进行操作。

1)创建具有自定义属性的属性字符串,并将其应用于可点击的单词。例如:

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

2)创建一个UITextView来显示该字符串,并添加一个UITapGestureRecognizer。然后处理点击事件:

- (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);

    }
}

只要你知道如何操作,这就非常容易!


你会如何在IOS 6中解决这个问题?你能否请看一下这个问题?http://stackoverflow.com/questions/19837522/line-up-three-uilabels-next-to-each-other-and-each-label-can-be-multiline?noredirect=1#comment29503448_19837522 - Steaphann
实际上,characterIndexForPoint:inTextContainer:fractionOfDistanceBetweenInsertionPoints在iOS 6上是可用的,所以我认为它应该可以工作。让我们知道!请参阅此项目的示例:https://github.com/laevandus/NSTextFieldHyperlinks/blob/master/NSTextFieldHyperlinks/AppDelegate.m - tarmes
1
是的,抱歉。我把Mac OS和iOS7搞混了!这只适用于iOS7。 - tarmes
但是这不是你的问题的答案。您要求可点击的单词。只需从“-(BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange”返回NO,然后在那里进行任何您想要执行的操作即可。您不需要所有这些位置计算。 - tomk
我需要某些单词可以被点击。这正是这段代码所做的。而且这就是我自己问题的答案。 - tarmes
显示剩余8条评论

68

使用 Swift 检测带属性文本上的轻拍

对于初学者来说,有时候设置东西可能有些困难(至少我是这样),所以这个示例会更详细一些。

在项目中添加一个 UITextView

Outlet

使用一个名为 textView 的 outlet 将 UITextView 连接到 ViewController

自定义属性

我们将通过创建一个扩展来创建一个自定义属性。

注意:这个步骤技术上是可选的,但是如果你不这么做,下一部分的代码需要使用标准属性,例如 NSAttributedString.Key.foregroundColor。使用自定义属性的好处在于你可以定义你想要存储在属性文本范围内的值。

使用文件 > 新建 > 文件... > iOS > 源代码 > Swift 文件添加一个新的 Swift 文件。你可以给它取任何你想要的名字。我要把我的命名为 NSAttributedStringKey+CustomAttribute.swift

将以下代码粘贴到文件中:

import Foundation

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

代码

将ViewController.swift中的代码替换为以下内容。注意 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)")
            }

        }
    }
}

在此输入图像描述

现在,如果您点击“Swift”中的“w”,您应该会得到以下结果:

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

笔记

  • 在这里,我使用了自定义属性,但它也可以很容易地是颜色为UIColor.green的文本颜色NSAttributedString.Key.foregroundColor
  • 以前,文本视图不能编辑或选择,但在我的Swift 4.2的更新答案中,无论是否选择这些选项,它似乎都能正常工作。

深入学习

这个答案基于这个问题的几个其他答案。除此之外,请参阅:


在iOS 9中,通过轻拍手势检测轻击不适用于连续的轻击。有关此问题的任何更新吗? - Dheeraj Jami
@Suragch:所以在iOS 9中,我已经检查了点击手势,它在没有键盘的情况下可用,但是当键盘显示时,后续的点击无法被识别。 - Dheeraj Jami
1
@WaqasMahmood,我为这个问题提出了一个新的问题。你可以收藏它并稍后查看任何答案。如果有更多相关细节,请随意编辑该问题或添加评论。 - Suragch
1
@dejix 我通过每次在我的TextView末尾添加另一个“”空字符串来解决问题。这样一来,检测就会在您的最后一个单词之后停止。希望有所帮助。 - PoolHallJunkie
1
能够很好地处理多个触控,我只是编写了一个简短的例程来证明这一点:如果字符索引小于12 { textView.textColor = UIColor.magenta } else { textView.textColor = UIColor.blue }非常清晰和简单易懂的代码。 - Jeremy Andrews
显示剩余12条评论

32

这是一个稍作修改的版本,基于@tarmes答案。在没有下面的微调之前,我无法让value变量返回除null以外的任何内容。此外,为了确定结果操作,我需要完整的属性字典返回。如果我违反了协议,请提前谅解。

具体的微调是使用textView.textStorage而不是textView.attributedText。身为iOS程序员新手,我真的不知道为什么,但也许其他人可以给我们启示。

在点击处理方法中的具体修改:

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

在我的视图控制器中的完整代码

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

    }
}

遇到了textView.attributedText的同样问题!感谢textView.textStorage提示! - Kai Burghardt
在iOS 9中,通过轻拍手势检测轻击不适用于连续的轻击。 - Dheeraj Jami

26

iOS 7使制作自定义链接并在触摸时执行所需操作变得更加容易。 在Ray Wenderlich网站上有非常好的例子。


这是比尝试计算字符串相对于其容器视图的位置更清晰的解决方案。 - Chris C
2
问题在于textView需要可选择,而我不想要这种行为。 - Thomás Pereira
@ThomásC. 感谢您指出我在IB中设置了UITextView检测链接,但它仍然无法检测到链接的原因。我还将其设置为不可选择状态。+1 - Kedar Paranjape

13

WWDC 2013示例:

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:
}

谢谢!我也会观看WWDC视频。 - tarmes
@Suragch 的 "使用 Text Kit 实现高级文本布局和效果"。 - Shmidt

11

我可以很简单地用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
  }

}

1
你应该检查你的URL是否被点击,而不是其他带有if URL.scheme == "cs"的URL,并且在if语句之外设置return true,这样UITextView就可以处理正常点击的https://链接。 - Daniel Storm
我在 iPhone 6 和 6+ 上实现了这个功能,并且效果还不错,但是 iPhone 5 上完全无法工作。最终采用了上面 Suragch 的解决方案,它能够正常运行。我一直没搞清楚为什么 iPhone 5 会有这个问题,毫无道理可言。 - n13

11

使用 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
}

然后您可以使用 shouldInteractWith URL UITextViewDelegate 委托方法来捕获操作。因此,请确保已正确设置委托。

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
    }

同样地,您可以根据您的需求执行任何操作。

干杯!


谢谢!你救了我的一天! - Dmih

4

在Swift 5和iOS 12中,您可以创建UITextView的子类,并覆盖point(inside:with:)以进行一些TextKit实现,以使其中的某些NSAttributedStrings具有可点击性。


以下代码展示了如何创建一个UITextView,它只响应其中带下划线的NSAttributedString的点击:

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")
    }

}

嗨,有没有办法使其符合多个属性而不仅仅是一个? - David Henry

4

3

使用这个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)
    }
}

为您的文本视图添加以下选择器的UITapGestureRecognizer

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
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接