Detectando toques no texto atribuído em um UITextView no iOS

122

Eu tenho um UITextViewque exibe um NSAttributedString. Essa sequência contém palavras que eu gostaria de tornar tocáveis, de modo que, quando elas são tocadas, recebo uma chamada de volta para que eu possa executar uma ação. Sei que isso UITextViewpode detectar toques em um URL e ligar de volta para meu representante, mas esses não são URLs.

Parece-me que, com o iOS 7 e o poder do TextKit, isso agora deve ser possível, no entanto, não consigo encontrar nenhum exemplo e não sei por onde começar.

Entendo que agora é possível criar atributos personalizados na string (embora ainda não o tenha feito), e talvez sejam úteis para detectar se uma das palavras mágicas foi tocada? De qualquer forma, ainda não sei como interceptar esse toque e detectar em qual palavra o toque ocorreu.

Observe que a compatibilidade com o iOS 6 não é necessária.

tarmes
fonte

Respostas:

118

Eu só queria ajudar os outros um pouco mais. Após a resposta de Shmidt, é possível fazer exatamente o que eu havia perguntado na minha pergunta original.

1) Crie uma sequência atribuída com atributos personalizados aplicados às palavras clicáveis. por exemplo.

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

2) Crie um UITextView para exibir essa sequência e adicione um UITapGestureRecognizer a ela. Em seguida, manuseie a torneira:

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

    }
}

Tão fácil quando você sabe como!

tarmes
fonte
Como você resolveria isso no IOS 6? Você pode dar uma olhada nesta pergunta? stackoverflow.com/questions/19837522/…
Steaphann 8/13
Na verdade, characterIndexForPoint: inTextContainer: fractOfDistanceBetweenInsertionPoints está disponível no iOS 6, então acho que deve funcionar. Nos informe! Veja este projeto para um exemplo: github.com/laevandus/NSTextFieldHyperlinks/blob/master/…
tarmes
Documentação diz que é apenas disponível em IOS 7 ou mais tarde :)
Steaphann
1
Sim, desculpe. Eu estava me confundindo com o Mac OS! Este é apenas o iOS7.
Tarmes
Ele não parece trabalho, quando você tem UITextView não-selecionável
Paul Brewczynski
64

Detectando toques no texto atribuído com o Swift

Às vezes, para iniciantes, é um pouco difícil saber como configurar as coisas (foi mesmo para mim), então esse exemplo é um pouco mais completo.

Adicione um UITextViewao seu projeto.

Saída

Conecte UITextViewao ViewControllercom uma tomada chamada textView.

Atributo personalizado

Vamos criar um atributo personalizado criando uma extensão .

Nota: Esta etapa é tecnicamente opcional, mas se você não fizer isso, precisará editar o código na próxima parte para usar um atributo padrão como NSAttributedString.Key.foregroundColor. A vantagem de usar um atributo customizado é que você pode definir quais valores deseja armazenar no intervalo de texto atribuído.

Adicione um novo arquivo rápido com Arquivo> Novo> Arquivo ...> iOS> Origem> Arquivo Swift . Você pode chamá-lo como quiser. Estou chamando o meu NSAttributedStringKey + CustomAttribute.swift .

Cole o seguinte código:

import Foundation

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

Código

Substitua o código em ViewController.swift pelo seguinte. Observe o 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)")
            }

        }
    }
}

insira a descrição da imagem aqui

Agora, se você tocar no "w" de "Swift", deverá obter o seguinte resultado:

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

Notas

  • Aqui eu usei um atributo personalizado, mas poderia ter sido tão facilmente NSAttributedString.Key.foregroundColor(cor do texto) que tem um valor de UIColor.green.
  • Antigamente, a exibição de texto não podia ser editável ou selecionável, mas, na minha resposta atualizada para o Swift 4.2, parece estar funcionando bem, independentemente de eles estarem selecionados ou não.

Um estudo mais aprofundado

Esta resposta foi baseada em várias outras respostas para esta pergunta. Além destes, veja também

Suragch
fonte
use em myTextView.textStoragevez de myTextView.attributedText.string
fatihyildizhan # 8/15
A detecção do toque por toque no iOS 9 não funciona para toques sucessivos. Alguma atualização sobre isso?
Dheeraj Jami
1
@WaqasMahmood, iniciei uma nova pergunta para este problema. Você pode estrelá-lo e voltar mais tarde para obter respostas. Sinta-se à vontade para editar essa pergunta ou adicionar comentários, se houver mais detalhes pertinentes.
Suragch 12/11/2015
1
@dejix Eu resolvo o problema adicionando cada vez "" outra string vazia ao final do meu TextView. Dessa forma, a detecção é interrompida após sua última palavra. Espero que ajude
PoolHallJunkie
1
Funciona perfeitamente com vários toques, acabei de colocar uma pequena rotina para provar isso: if characterIndex <12 {textView.textColor = UIColor.magenta} else {textView.textColor = UIColor.blue} Código realmente claro e simples
Jeremy Andrews
32

Esta é uma versão ligeiramente modificada, baseada na resposta @tarmes. Não consegui fazer com que a valuevariável retornasse nada, mas nullsem o ajuste abaixo. Além disso, eu precisava que o dicionário de atributos completo retornasse para determinar a ação resultante. Eu teria colocado isso nos comentários, mas não parece ter o representante para fazê-lo. Desculpas antecipadamente se eu violar o protocolo.

Ajuste específico é usar em textView.textStoragevez de textView.attributedText. Como um programador de iOS ainda aprendendo, não tenho muita certeza do porquê disso, mas talvez alguém possa nos esclarecer.

Modificação específica no método de manuseio de torneira:

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

Código completo no meu controlador de exibição

- (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
fonte
Teve o mesmo problema com o textView.attributedText! OBRIGADO pela dica textView.textStorage!
Kai Burghardt
A detecção do toque por toque no iOS 9 não funciona para toques sucessivos.
Dheeraj Jami
25

Tornar o link personalizado e fazer o que você deseja na torneira ficou muito mais fácil com o iOS 7. Há um exemplo muito bom em Ray Wenderlich

Aditya Mathur
fonte
Essa é uma solução muito mais limpa do que tentar calcular as posições das strings em relação à visualização do contêiner.
Chris C
2
O problema é que o textView precisa ser selecionável e não quero esse comportamento.
Thomás Calmon
@ ThomásC. +1 para o ponteiro sobre o motivo pelo qual eu UITextViewnão estava detectando links, mesmo quando eu o havia definido para detectá-los via IB. (Eu também tinha feito unselectable)
Kedar Paranjape
13

Exemplo da 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
fonte
Obrigado! Também vou assistir o vídeo da WWDC.
Tarmes
@ Sururch "Layouts e efeitos avançados de texto com o Kit de texto".
Shmidt
10

Consegui resolver isso de maneira simples com 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
fonte
Você deve verificar se o seu URL foi aproveitado e não outra URL com if URL.scheme == "cs"e return truefora da ifdeclaração para que o UITextViewpode lidar normais https://links que são exploradas
Daniel tempestade
Fiz isso e funcionou razoavelmente bem no iPhone 6 e 6+, mas não funcionou no iPhone 5. Fui com a solução Suragch acima, que simplesmente funciona. Nunca descobri por que o iPhone 5 teria um problema com isso, não fazia sentido.
n13 21/12/16
9

Exemplo completo para detectar ações no texto atribuído com o 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
}

Você pode capturar a ação com o shouldInteractWith URLmétodo delegado UITextViewDelegate. Portanto, verifique se você definiu o delegado corretamente.

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
    }

Da mesma forma, você pode executar qualquer ação de acordo com sua exigência.

Felicidades!!

Akila Wasala
fonte
Obrigado! Você salva meu dia!
Dmih
4

Com o Swift 5 e o iOS 12, você pode criar uma subclasse UITextViewe substituir point(inside:with:)alguma implementação do TextKit para fazer apenas algumas NSAttributedStringsdelas tocarem.


O código a seguir mostra como criar um UITextViewque reaja apenas aos toques em NSAttributedStrings sublinhados nele:

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
fonte
Oi, Existe alguma maneira de fazer isso em conformidade com vários atributos, em vez de apenas um?
David Lintin 28/04
1

Este pode funcionar bem com o link curto, com várias conexões em uma visualização de texto. Funciona bem com o 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
fonte
A detecção do toque por toque no iOS 9 não funciona para toques sucessivos.
Dheeraj Jami
1

Use esta extensão para 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)
    }
}

Adicione UITapGestureRecognizerà sua exibição de texto com o seguinte seletor:

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
fonte