Detección de toques en texto atribuido en un UITextView en iOS

122

Tengo un UITextViewque muestra un NSAttributedString. Esta cadena contiene palabras que me gustaría hacer tappable, de modo que cuando se tocan me devuelven la llamada para que pueda realizar una acción. Me doy cuenta de que UITextViewpuede detectar toques en una URL y devolver la llamada a mi delegado, pero estas no son URL.

Me parece que con iOS 7 y el poder de TextKit esto ahora debería ser posible, sin embargo, no puedo encontrar ningún ejemplo y no estoy seguro de por dónde empezar.

Entiendo que ahora es posible crear atributos personalizados en la cadena (aunque todavía no lo he hecho), y tal vez estos serán útiles para detectar si se ha tocado una de las palabras mágicas. En cualquier caso, todavía no sé cómo interceptar ese toque y detectar en qué palabra ocurrió el toque.

Tenga en cuenta que no se requiere compatibilidad con iOS 6 .

tarmes
fuente

Respuestas:

118

Solo quería ayudar a los demás un poco más. Siguiendo con la respuesta de Shmidt, es posible hacer exactamente lo que había hecho en mi pregunta original.

1) Cree una cadena de atributos con atributos personalizados aplicados a las palabras en las que se puede hacer clic. p.ej.

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

2) Cree un UITextView para mostrar esa cadena y agréguele un UITapGestureRecognizer. Luego maneje el grifo:

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

    }
}

¡Tan fácil cuando sabes cómo!

tarmes
fuente
¿Cómo resolverías esto en iOS 6? ¿Puedes por favor echar un vistazo a esta pregunta? stackoverflow.com/questions/19837522/…
Steaphann
En realidad, characterIndexForPoint: inTextContainer: fracciónOfDistanceBetweenInsertionPoints está disponible en iOS 6, por lo que creo que debería funcionar. ¡Haznos saber! Vea este proyecto para ver un ejemplo: github.com/laevandus/NSTextFieldHyperlinks/blob/master/…
tarmes
La documentación dice que solo está disponible en iOS 7 o posterior :)
Steaphann
1
Si, lo siento. ¡Me estaba confundiendo con Mac OS! Esto es solo para iOS7.
Tarmes
No parece funcionar, cuando tienes UITextView no seleccionable
Paul Brewczynski
64

Detección de toques en texto atribuido con Swift

A veces para los principiantes es un poco difícil saber cómo configurar las cosas (de todos modos fue para mí), por lo que este ejemplo es un poco más completo.

Agregue un UITextViewa su proyecto.

Toma de corriente

Conecte el UITextViewal ViewControllercon una toma de corriente con nombre textView.

Atributo personalizado

Vamos a hacer un atributo personalizado haciendo una Extensión .

Nota: Este paso es técnicamente opcional, pero si no lo hace, deberá editar el código en la siguiente parte para usar un atributo estándar como NSAttributedString.Key.foregroundColor. La ventaja de utilizar un atributo personalizado es que puede definir qué valores desea almacenar en el rango de texto atribuido.

Agregue un nuevo archivo swift con Archivo> Nuevo> Archivo ...> iOS> Fuente> Archivo Swift . Puedes llamarlo como quieras. Estoy llamando al mío NSAttributedStringKey + CustomAttribute.swift .

Pega el siguiente código:

import Foundation

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

Código

Reemplace el código en ViewController.swift con lo siguiente. Tenga en cuenta el 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)")
            }

        }
    }
}

ingrese la descripción de la imagen aquí

Ahora, si toca la "w" de "Swift", debería obtener el siguiente resultado:

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

Notas

  • Aquí utilicé un atributo personalizado, pero podría haber sido igual de fácil NSAttributedString.Key.foregroundColor(color de texto) que tiene un valor de UIColor.green.
  • Anteriormente, la vista de texto no podía ser editable o seleccionable, pero en mi respuesta actualizada para Swift 4.2 parece funcionar bien sin importar si están seleccionados o no.

Estudio adicional

Esta respuesta se basó en varias otras respuestas a esta pregunta. Además de estos, ver también

Suragch
fuente
usar en myTextView.textStoragelugar de myTextView.attributedText.string
fatihyildizhan
Detectar el toque a través del gesto de toque en iOS 9 no funciona para los toques sucesivos. ¿Alguna actualización sobre eso?
Dheeraj Jami
1
@WaqasMahmood, comencé una nueva pregunta para este problema. Puede marcarlo y volver más tarde para ver las respuestas. Siéntase libre de editar esa pregunta o agregar comentarios si hay más detalles pertinentes.
Suragch
1
@dejix Resuelvo el problema agregando cada vez "" una cadena vacía al final de mi TextView. De esa manera, la detección se detiene después de su última palabra. Espero que ayude
PoolHallJunkie 03 de
1
Funciona perfectamente con múltiples toques, acabo de poner una breve rutina para probar esto: if characterIndex <12 {textView.textColor = UIColor.magenta} else {textView.textColor = UIColor.blue} Código realmente claro y simple
Jeremy Andrews
32

Esta es una versión ligeramente modificada, basada en la respuesta @tarmes. No pude hacer que la valuevariable devuelva nada, pero nullsin el ajuste a continuación. Además, necesitaba que se devolviera el diccionario de atributos completo para determinar la acción resultante. Hubiera puesto esto en los comentarios, pero no parece tener el representante para hacerlo. Disculpas de antemano si he violado el protocolo.

Se debe usar un ajuste específico en textView.textStoragelugar de textView.attributedText. Como todavía estoy aprendiendo programador de iOS, no estoy muy seguro de por qué, pero quizás alguien más pueda iluminarnos.

Modificación específica en el método de manejo del grifo:

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

Código completo en mi controlador de vista

- (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
fuente
Tuve el mismo problema con el textView.attributedText! ¡GRACIAS por la sugerencia textView.textStorage!
Kai Burghardt
Detectar el toque a través del gesto de toque en iOS 9 no funciona para los toques sucesivos.
Dheeraj Jami
25

Hacer un enlace personalizado y hacer lo que quieras en el tap se ha vuelto mucho más fácil con iOS 7. Hay un muy buen ejemplo en Ray Wenderlich

Aditya Mathur
fuente
Esta es una solución mucho más limpia que intentar calcular las posiciones de las cadenas en relación con su vista de contenedor.
Chris C
2
El problema es que textView debe ser seleccionable, y no quiero este comportamiento.
Thomás Calmon
@ ThomásC. +1 para el puntero sobre por qué mi UITextViewno detectaba enlaces incluso cuando lo había configurado para detectarlos a través de IB. (También lo había hecho no seleccionable)
Kedar Paranjape
13

WWDC 2013 ejemplo :

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
fuente
¡Gracias! Veré el video de WWDC también.
Tarmes
@Suragch "Diseños de texto avanzados y efectos con kit de texto".
Shmidt
10

Pude resolver esto bastante simple con 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
fuente
Debe verificar que su URL se haya tocado y no otra URL con if URL.scheme == "cs"y return truefuera de la ifdeclaración para que UITextViewpueda manejar los https://enlaces normales que se tocan
Daniel Storm
Lo hice y funcionó razonablemente bien en iPhone 6 y 6+, pero no funcionó en absoluto en iPhone 5. Fui con la solución Suragch anterior, que simplemente funciona. Nunca descubrí por qué el iPhone 5 tendría un problema con esto, no tenía sentido.
n13
9

Ejemplo completo para detectar acciones en texto atribuido con 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
}

Y luego puede ver la acción con el shouldInteractWith URLmétodo de delegado UITextViewDelegate. Por lo tanto, asegúrese de haber configurado el delegado correctamente.

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
    }

Del mismo modo, puede realizar cualquier acción de acuerdo con sus requisitos.

¡¡Salud!!

Akila Wasala
fuente
¡Gracias! ¡Me salvas el día!
Dmih
4

Con Swift 5 e iOS 12, puede crear una subclase UITextViewy anular point(inside:with:)algunas implementaciones de TextKit para que solo algunas NSAttributedStringsde ellas se puedan tocar.


El siguiente código muestra cómo crear un UITextViewmensaje que solo reaccione a los toques en NSAttributedStrings subrayados :

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
fuente
Hola, ¿hay alguna forma de hacer que esto se ajuste a múltiples atributos en lugar de solo uno?
David Lintin
1

Este podría funcionar bien con un enlace corto, multienlace en una vista de texto. Funciona bien con 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
fuente
Detectar el toque a través del gesto de toque en iOS 9 no funciona para los toques sucesivos.
Dheeraj Jami
1

Use esta extensión 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)
    }
}

Agregue UITapGestureRecognizera su vista de texto con el siguiente selector:

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
fuente