Обнаружение касаний к тексту с атрибутами в UITextView в iOS

122

У меня есть UITextViewфайл с расширением NSAttributedString. Эта строка содержит слова, которые я хотел бы сделать доступными, чтобы при нажатии на них мне перезванивали, чтобы я мог выполнить действие. Я понимаю, что UITextViewмогу обнаруживать нажатия на URL-адрес и перезванивать моему представителю, но это не URL-адреса.

Мне кажется, что с iOS 7 и мощью TextKit это стало возможным, однако я не могу найти никаких примеров и не уверен, с чего начать.

Я понимаю, что теперь можно создавать настраиваемые атрибуты в строке (хотя я еще этого не делал), и, возможно, они будут полезны для определения, было ли нажато одно из волшебных слов? В любом случае, я до сих пор не знаю, как перехватить это касание и определить, по какому слову произошло касание.

Обратите внимание, что совместимость с iOS 6 не требуется.

tarmes
источник

Ответы:

118

Я просто хотел еще немного помочь другим. Следуя ответу Шмидта, можно сделать именно то, что я задал в своем первоначальном вопросе.

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

    }
}

Так просто, когда знаешь как!

tarmes
источник
Как бы вы решили это в IOS 6? Не могли бы вы взглянуть на этот вопрос? stackoverflow.com/questions/19837522/…
Steaphann 08
На самом деле characterIndexForPoint: inTextContainer: FractionOfDistanceBetweenInsertionPoints доступен в iOS 6, поэтому я думаю, что он должен работать. Дайте нам знать! См. Пример в этом проекте: github.com/laevandus/NSTextFieldHyperlinks/blob/master/…
tarmes
В документации указано, что он доступен только в IOS 7 или новее :)
Steaphann
1
Да, прости. Я запутался в Mac OS! Это только iOS7.
tarmes
Кажется, что это не работает, когда у вас нет возможности выбора UITextView
Пол Брючински
64

Обнаружение нажатий на текст с атрибутами с помощью Swift

Иногда новичкам бывает сложно понять, как все настроить (по крайней мере, это было для меня), поэтому этот пример немного полнее.

Добавьте UITextViewв свой проект.

Торговая точка

Подключите UITextViewк ViewControllerрозетке с именем textView.

Настраиваемый атрибут

Мы собираемся создать собственный атрибут, сделав расширение .

Примечание. Этот шаг технически необязателен, но если вы этого не сделаете, вам нужно будет отредактировать код в следующей части, чтобы использовать стандартный атрибут, например NSAttributedString.Key.foregroundColor. Преимущество использования настраиваемого атрибута заключается в том, что вы можете определить, какие значения вы хотите сохранить в текстовом диапазоне с атрибутами.

Добавьте новый быстрый файл с помощью File> New> File ...> iOS> Source> Swift File . Вы можете называть это как хотите. Я называю свой 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)")
            }

        }
    }
}

введите описание изображения здесь

Теперь, если вы нажмете на «w» в «Swift», вы должны получить следующий результат:

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

Ноты

  • Здесь я использовал настраиваемый атрибут, но с таким же успехом он мог быть NSAttributedString.Key.foregroundColor(цвет текста) со значением UIColor.green.
  • Раньше текстовое представление нельзя было редактировать или выбирать, но в моем обновленном ответе для Swift 4.2 он, похоже, работает нормально независимо от того, выбраны они или нет.

Дальнейшее обучение

Этот ответ был основан на нескольких других ответах на этот вопрос. Помимо этого, см. Также

Suragch
источник
использовать myTextView.textStorageвместо myTextView.attributedText.string
fatihyildizhan 08
Обнаружение касания через жест касания в iOS 9 не работает для последовательных касаний. Есть новости по этому поводу?
Дирадж Джами,
1
@WaqasMahmood, я начал новый вопрос по этому вопросу. Вы можете пометить его и проверить позже, чтобы получить ответы. Не стесняйтесь редактировать этот вопрос или добавлять комментарии, если есть какие-то более важные детали.
Suragch
1
@dejix Я решаю проблему, добавляя каждый раз "" пустую строку в конец моего TextView. Таким образом, обнаружение остановится после вашего последнего слова. Надеюсь, это поможет
PoolHallJunkie
1
Отлично работает с несколькими нажатиями, я просто ввел короткую процедуру, чтобы доказать это: if characterIndex <12 {textView.textColor = UIColor.magenta} else {textView.textColor = UIColor.blue} Действительно чистый и простой код
Джереми Эндрюс,
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

    }
}
natenash203
источник
Была такая же проблема с textView.attributedText! СПАСИБО за подсказку textView.textStorage!
Кай Бургхардт
Обнаружение касания через жест касания в iOS 9 не работает для последовательных касаний.
Дирадж Джами,
25

Создавать настраиваемые ссылки и делать то, что вы хотите, стало намного проще с iOS 7. Хороший пример есть у Рэя Вендерлиха.

Адитья Матур
источник
Это гораздо более чистое решение, чем попытка вычислить позиции строк относительно их представления контейнера.
Chris C
2
Проблема в том, что textView необходимо выбирать, и я не хочу такого поведения.
Thomás Calmon
@ ThomásC. +1 за указатель на то, почему я UITextViewне обнаруживал ссылки, даже когда я настроил его на обнаружение их через IB. (Я также сделал его невыбираемым)
Кедар Паранджапе
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:
}
Shmidt
источник
Спасибо! Я тоже посмотрю видео WWDC.
tarmes
@Suragch "Расширенные макеты текста и эффекты с текстовым набором".
Шмидт
10

Я смог решить это довольно просто с помощью 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
  }

}
Джейс Уотсон
источник
Вы должны убедиться, что ваш URL был нажат, а не другой URL с оператором if URL.scheme == "cs"и return trueвне его, ifчтобы он UITextViewмог обрабатывать обычные https://ссылки, которые нажимаются
Дэниел Сторм,
Я сделал это, и он работал достаточно хорошо на iPhone 6 и 6+, но совсем не работал на iPhone 5. Пошел с решением Suragch выше, которое просто работает. Так и не выяснил, почему у iPhone 5 с этим проблемы, не имело смысла.
n13
9

Полный пример для обнаружения действий с атрибутированным текстом с помощью 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
    }

Подобным образом вы можете выполнять любые действия в соответствии с вашими требованиями.

Ура !!

Акила Васала
источник
Спасибо! Ты спас мне день!
Дмих
4

Это можно сделать с помощью characterIndexForPoint:inTextContainer:fractionOfDistanceBetweenInsertionPoints:. Это будет работать несколько иначе, чем вы хотели - вам нужно будет проверить, принадлежит ли повернутый символ волшебному слову . Но это не должно быть сложно.

Кстати, я настоятельно рекомендую посмотреть Introduction Text Kit с WWDC 2013.

Арек Холко
источник
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")
    }

}
Иману Пети
источник
Привет! Есть ли способ сделать так, чтобы это соответствовало нескольким атрибутам, а не только одному?
Дэвид Линтин
1

Это может работать нормально с короткой ссылкой, многозвенной в текстовом виде. Работает нормально с 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;
}
Тони ТРАН
источник
Обнаружение касания через жест касания в iOS 9 не работает для последовательных касаний.
Дирадж Джами,
1

Используйте это расширение для 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: tapableText),
      tapGesture.didTapAttributedTextInTextView(textView: textTextView, inRange: NSRange(range, in: text)) {
                // Tap recognized
}
Mol0ko
источник