Détecter les taps sur le texte attribué dans un UITextView sous iOS

122

J'ai un UITextViewqui affiche un NSAttributedString. Cette chaîne contient des mots que j'aimerais rendre tapables, de sorte que lorsqu'ils sont tapés, je suis rappelé afin que je puisse effectuer une action. Je me rends compte que cela UITextViewpeut détecter les tapotements sur une URL et rappeler mon délégué, mais ce ne sont pas des URL.

Il me semble qu'avec iOS 7 et la puissance de TextKit, cela devrait maintenant être possible, mais je ne trouve aucun exemple et je ne sais pas par où commencer.

Je comprends qu'il est maintenant possible de créer des attributs personnalisés dans la chaîne (bien que je ne l'ai pas encore fait), et peut-être que ceux-ci seront utiles pour détecter si l'un des mots magiques a été tapé? Dans tous les cas, je ne sais toujours pas comment intercepter ce tap et détecter sur quel mot le tap s'est produit.

Notez que la compatibilité iOS 6 n'est pas requise.

tarmes
la source

Réponses:

118

Je voulais juste aider un peu plus les autres. Suite à la réponse de Shmidt, il est possible de faire exactement ce que j'avais demandé dans ma question initiale.

1) Créez une chaîne attribuée avec des attributs personnalisés appliqués aux mots cliquables. par exemple.

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

2) Créez un UITextView pour afficher cette chaîne et ajoutez-y un UITapGestureRecognizer. Puis manipulez le robinet:

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

    }
}

Tellement facile quand on sait comment!

tarmes
la source
Comment résoudriez-vous cela dans IOS 6? Pouvez-vous jeter un oeil à cette question? stackoverflow.com/questions/19837522/…
Steaphann
En fait, characterIndexForPoint: inTextContainer: fractionOfDistanceBetweenInsertionPoints est disponible sur iOS 6, donc je pense que cela devrait fonctionner. Faites le nous savoir! Voir ce projet pour un exemple: github.com/laevandus/NSTextFieldHyperlinks/blob/master/…
tarmes
La documentation indique qu'il n'est disponible que dans IOS 7 ou version ultérieure :)
Steaphann
1
Oui désolé. Je me confondais avec Mac OS! Ceci est iOS7 uniquement.
tarmes
Cela ne semble pas fonctionner, lorsque vous n'avez pas UITextView sélectionnable
Paul Brewczynski
64

Détecter les tapotements sur le texte attribué avec Swift

Parfois, pour les débutants, il est un peu difficile de savoir comment mettre en place les choses (c'était pour moi en tout cas), donc cet exemple est un peu plus complet.

Ajoutez un UITextViewà votre projet.

Sortie

Connectez le UITextViewau ViewControlleravec une prise nommée textView.

Attribut personnalisé

Nous allons créer un attribut personnalisé en créant une extension .

Remarque: Cette étape est techniquement facultative, mais si vous ne le faites pas, vous devrez éditer le code dans la partie suivante pour utiliser un attribut standard comme NSAttributedString.Key.foregroundColor. L'avantage de l'utilisation d'un attribut personnalisé est que vous pouvez définir les valeurs que vous souhaitez stocker dans la plage de texte attribuée.

Ajoutez un nouveau fichier Swift avec Fichier> Nouveau> Fichier ...> iOS> Source> Fichier Swift . Vous pouvez l'appeler comme vous le souhaitez. J'appelle le mien NSAttributedStringKey + CustomAttribute.swift .

Collez le code suivant:

import Foundation

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

Code

Remplacez le code dans ViewController.swift par ce qui suit. Notez le 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)")
            }

        }
    }
}

entrez la description de l'image ici

Maintenant, si vous appuyez sur le "w" de "Swift", vous devriez obtenir le résultat suivant:

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

Remarques

  • Ici, j'ai utilisé un attribut personnalisé, mais il aurait tout aussi bien pu être NSAttributedString.Key.foregroundColor(couleur du texte) qui a une valeur de UIColor.green.
  • Auparavant, la vue texte ne pouvait pas être modifiable ou sélectionnable, mais dans ma réponse mise à jour pour Swift 4.2, elle semble fonctionner correctement, qu'elles soient sélectionnées ou non.

Une étude plus approfondie

Cette réponse était basée sur plusieurs autres réponses à cette question. Outre ceux-ci, voir aussi

Suragch
la source
utiliser à la myTextView.textStorageplace de myTextView.attributedText.string
fatihyildizhan
La détection du tap par le geste du tap dans iOS 9 ne fonctionne pas pour les taps successifs. Des mises à jour à ce sujet?
Dheeraj Jami
1
@WaqasMahmood, j'ai lancé une nouvelle question pour ce numéro. Vous pouvez le mettre en étoile et revenir plus tard pour obtenir des réponses. N'hésitez pas à modifier cette question ou à ajouter des commentaires s'il y a des détails plus pertinents.
Suragch
1
@dejix Je résous le problème en ajoutant à chaque fois une autre chaîne vide "" à la fin de mon TextView. De cette façon, la détection s'arrête après votre dernier mot. J'espère que ça aide
PoolHallJunkie
1
Fonctionne parfaitement avec plusieurs robinets, je viens de mettre dans une courte routine pour le prouver: if characterIndex <12 {textView.textColor = UIColor.magenta} else {textView.textColor = UIColor.blue} Code vraiment clair et simple
Jeremy Andrews
32

Il s'agit d'une version légèrement modifiée, basée sur la réponse @tarmes. Je ne pouvais pas obtenir la valuevariable pour retourner quoi que ce soit, mais nullsans le réglage ci-dessous. De plus, j'avais besoin du dictionnaire d'attributs complet renvoyé afin de déterminer l'action résultante. J'aurais mis cela dans les commentaires mais je ne semble pas avoir le représentant pour le faire. Je m'excuse à l'avance si j'ai violé le protocole.

Un ajustement spécifique consiste à utiliser à la textView.textStorageplace de textView.attributedText. En tant que programmeur iOS toujours en apprentissage, je ne sais pas vraiment pourquoi, mais peut-être que quelqu'un d'autre peut nous éclairer.

Modification spécifique de la méthode de gestion des prises:

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

Code complet dans mon contrôleur de vue

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

    }
}
natenash203
la source
Eu le même problème avec le textView.attributedText! MERCI pour l'astuce textView.textStorage!
Kai Burghardt
La détection du tap par le geste du tap dans iOS 9 ne fonctionne pas pour les taps successifs.
Dheeraj Jami
25

Créer un lien personnalisé et faire ce que vous voulez sur le robinet est devenu beaucoup plus facile avec iOS 7. Il y a un très bon exemple chez Ray Wenderlich

Aditya Mathur
la source
C'est une solution beaucoup plus propre que d'essayer de calculer les positions des chaînes par rapport à leur vue de conteneur.
Chris C
2
Le problème est que textView doit être sélectionnable et je ne veux pas de ce comportement.
Thomás Calmon
@ ThomásC. +1 pour le pointeur sur la raison pour laquelle mon UITextViewne détectait pas les liens même lorsque je l'avais configuré pour les détecter via IB. (Je l'avais également rendu non sélectionnable)
Kedar Paranjape
13

Exemple 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:
}
Shmidt
la source
Je vous remercie! Je vais aussi regarder la vidéo de la WWDC.
tarmes
@Suragch "Dispositions et effets de texte avancés avec kit de texte".
Shmidt
10

J'ai pu résoudre ce problème assez simplement avec 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
  }

}
Jase Whatson
la source
Vous devez vérifier que votre URL a été tapée et non une autre URL avec if URL.scheme == "cs"et en return truedehors de la ifdéclaration afin que le UITextViewpuisse gérer les https://liens normaux qui sont exploités
Daniel Storm
Je l'ai fait et cela a fonctionné raisonnablement bien sur iPhone 6 et 6+, mais n'a pas du tout fonctionné sur iPhone 5. Je suis allé avec la solution Suragch ci-dessus, qui fonctionne juste. Je n'ai jamais découvert pourquoi l'iPhone 5 aurait un problème avec cela, cela n'avait aucun sens.
n13
9

Exemple complet pour détecter les actions sur le texte attribué avec 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
}

Et puis vous pouvez attraper l'action avec la shouldInteractWith URLméthode de délégué UITextViewDelegate.Assurez-vous donc que vous avez correctement défini le délégué.

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
    }

De même, vous pouvez effectuer n'importe quelle action en fonction de vos besoins.

À votre santé!!

Akila Wasala
la source
Merci! Vous sauvez ma journée!
Dmih
4

Avec Swift 5 et iOS 12, vous pouvez créer une sous-classe de UITextViewet remplacer point(inside:with:)avec une implémentation de TextKit afin de n'en rendre qu'une partie NSAttributedStringsexploitable.


Le code suivant montre comment créer un UITextViewqui ne réagit qu'aux tapotements sur les NSAttributedStrings soulignés :

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

}
Imanou Petit
la source
Salut, Y a-t-il un moyen de rendre cela conforme à plusieurs attributs plutôt qu'à un seul?
David Lintin le
1

Celui-ci pourrait fonctionner correctement avec un lien court, un lien multiple dans une vue de texte. Cela fonctionne bien avec 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;
}
Tony TRAN
la source
La détection du tap par le geste du tap dans iOS 9 ne fonctionne pas pour les taps successifs.
Dheeraj Jami
1

Utilisez cette extension pour 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)
    }
}

Ajoutez UITapGestureRecognizerà votre vue de texte avec le sélecteur suivant:

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