web-dev-qa-db-fra.com

Comment puis-je détecter avec précision si un lien est cliqué dans UILabels dans Swift 4?

Modifier

Voir ma réponse pour une solution de travail complète:

J'ai réussi à résoudre ce problème moi-même en utilisant un UITextView au lieu d'un UILabel. J'ai écrit une classe qui fait que la UITextView se comporte comme une UILabel mais avec une détection de lien parfaitement précise.


J'ai réussi à styliser les liens sans problème en utilisant NSMutableAttributedString mais je ne parviens pas à détecter avec précision le personnage sur lequel l'utilisateur a cliqué. J'ai essayé toutes les solutions dans cette question (que je pourrais convertir en code Swift 4) mais sans succès.

Le code suivant fonctionne, mais ne détecte pas avec précision le caractère sur lequel l'utilisateur a cliqué et obtient le mauvais emplacement du lien

func didTapAttributedTextInLabel(label: UILabel, inRange targetRange: NSRange) -> Bool {
    // Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
    let layoutManager = NSLayoutManager()
    let textContainer = NSTextContainer(size: CGSize.zero)
    let textStorage = NSTextStorage(attributedString: label.attributedText!)

    // Configure layoutManager and textStorage
    layoutManager.addTextContainer(textContainer)
    textStorage.addLayoutManager(layoutManager)

    // Configure textContainer
    textContainer.lineFragmentPadding = 0.0
    textContainer.lineBreakMode = label.lineBreakMode
    textContainer.maximumNumberOfLines = label.numberOfLines
    let labelSize = label.bounds.size
    textContainer.size = labelSize

    // Find the tapped character location and compare it to the specified range
    let locationOfTouchInLabel = self.location(in: label)
    let textBoundingBox = layoutManager.usedRect(for: textContainer)
    let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.Origin.x, y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.Origin.y)
    let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x, y: locationOfTouchInLabel.y - textContainerOffset.y)
    let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
    print(indexOfCharacter)
    return NSLocationInRange(indexOfCharacter, targetRange)
}
12
Dan Bray

J'ai réussi à résoudre ce problème en utilisant un UITextView au lieu d'un UILabel. Au départ, je ne voulais pas utiliser un UITextView car j'ai besoin que l'élément se comporte comme un UILabel et un UITextView peut entraîner des problèmes de défilement et son utilisation prévue est un texte éditable. La classe suivante que j'ai écrite oblige une UITextView à se comporter comme une UILabel mais avec une détection de clic parfaitement précise et sans problème de défilement:

import UIKit

class ClickableLabelTextView: UITextView {
    var delegate: DelegateForClickEvent?
    var ranges:[(start: Int, end: Int)] = []
    var page: String = ""
    var paragraph: Int?
    var clickedLink: (() -> Void)?
    var pressedTime: Int?
    var startTime: TimeInterval?

    override func awakeFromNib() {
        super.awakeFromNib()
        self.textContainerInset = UIEdgeInsets.zero
        self.textContainer.lineFragmentPadding = 0
        self.delaysContentTouches = true
        self.isEditable = false
        self.isUserInteractionEnabled = true
        self.isSelectable = false
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        startTime = Date().timeIntervalSinceReferenceDate
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        if let clickedLink = clickedLink {
            if let startTime = startTime {
                self.startTime = nil
                if (Date().timeIntervalSinceReferenceDate - startTime <= 0.2) {
                    clickedLink()
                }
            }
        }
    }

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        var location = point
        location.x -= self.textContainerInset.left
        location.y -= self.textContainerInset.top
        if location.x > 0 && location.y > 0 {
            let index = self.layoutManager.characterIndex(for: location, in: self.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
            var count = 0
            for range in ranges {
                if index >= range.start && index < range.end {
                    clickedLink = {
                        self.delegate?.clickedLink(page: self.page, paragraph: self.paragraph, linkNo: count)
                    }
                    return self
                }
                count += 1
            }
        }
        clickedLink = nil
        return nil
    }
}

La fonction hitTest get est appelée plusieurs fois, mais cela ne pose jamais de problème, car clickedLink() ne sera appelé qu'une fois par clic. J'ai essayé de désactiver isUserInteractionEnabled pour différents affichages, mais cela ne m'a pas aidé et était inutile.

Pour utiliser la classe, ajoutez-la simplement à votre UITextView. Si vous utilisez autoLayout dans l'éditeur Xcode, désactivez Scrolling Enabled pour la UITextView dans l'éditeur afin d'éviter les avertissements de présentation.

Dans le fichier Swift qui contient le code associé à votre fichier xib (dans mon cas, une classe pour une UITableViewCell, vous devez définir les variables suivantes pour votre textView cliquable:

  • ranges - les index de début et de fin de chaque lien cliquable avec UITextView
  • page - une String pour identifier la page ou la vue contenant la UITextView
  • paragraph - Si vous avez plusieurs UITextView cliquables, attribuez-leur un numéro
  • delegate - pour déléguer les événements de clic à n'importe quel endroit où vous pouvez les traiter.

Vous devez ensuite créer un protocole pour votre delegate:

protocol DelegateName {
    func clickedLink(page: String, paragraph: Int?, linkNo: Int?)
}

Les variables passées dans clickedLink vous donnent toutes les informations nécessaires pour savoir quel lien a été cliqué.

4
Dan Bray

Si cela ne vous dérange pas de réécrire votre code, vous devriez utiliser UITextView au lieu de UILabel.

Vous pouvez facilement détecter le lien en définissant la variable UITextView de dataDetectorTypes et en implémentant la fonction de délégué pour récupérer les URL sur lesquelles vous avez cliqué.

func textView(_ textView: UITextView, shouldInteractWith URL: URL, 
    in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool

https://developer.Apple.com/documentation/uikit/uitextviewdelegate/1649337-textview

9
user9749232

Vous pouvez utiliser la bibliothèque MLLabel. MLLabel est une sous-classe de UIlabel. La bibliothèque a une classe MLLinkLabel qui est une sous-classe de MLLabel. Cela signifie que vous pouvez l'utiliser à la place de UIlabel (même dans le générateur d'interface, faites simplement glisser un UILabel et changez sa classe en MLLinkLabel)

MLLinkLabel peut faire le tour pour vous et c'est très facile. Voici un exemple:

    label.didClickLinkBlock = {(link, linkText, label) -> Void in

        //Here you can check the type of the link and do whatever you want.
        switch link!.linkType {
        case .email:
            break
        case .none:
             break
        case .URL:
             break
        case .phoneNumber:
             break
        case .userHandle:
             break
        case .hashtag:
             break
        case .other:
             break
        }

    }

vous pouvez vérifier la bibliothèque dans GitHub https://github.com/molon/MLLabel

Voici une capture d'écran de l'une de mes applications dans laquelle j'ai utilisé MLLabel.

 enter image description here

3
Developer84ios

Je voulais éviter de poster une réponse puisqu'il s'agit davantage d'un commentaire sur la propre réponse de Dan Bray (je ne peux pas commenter en raison de l'absence de représentant). Cependant, je pense toujours que cela vaut la peine d'être partagé.


J'ai apporté quelques petites améliorations (ce que je pense être) à la réponse de Dan Bray pour plus de commodité:

  • Je trouvais un peu difficile de configurer textView avec les plages et les éléments J'ai donc remplacé cette partie par un dict textLink qui stocke les chaîneslink et leurs cibles respectives. ViewController implémentant n'a besoin que de définir ceci pour initialiser le textView.
  • J'ai ajouté le style de soulignement aux liens (en conservant la police, etc. du constructeur d'interface). N'hésitez pas à ajouter vos propres styles ici (comme la couleur de la police bleue, etc.).
  • J'ai retravaillé la signature du rappel pour le rendre plus facile à traiter.
  • Notez que je devais aussi renommer delegate en linkDelegate car UITextViews a déjà un délégué.

Le TextView:

import UIKit

class LinkTextView: UITextView {
  private var callback: (() -> Void)?
  private var pressedTime: Int?
  private var startTime: TimeInterval?
  private var initialized = false
  var linkDelegate: LinkTextViewDelegate?
  var textLinks: [String : String] = Dictionary() {
    didSet {
        initialized = false
        styleTextLinks()
    }
  }

  override func awakeFromNib() {
    super.awakeFromNib()
    self.textContainerInset = UIEdgeInsets.zero
    self.textContainer.lineFragmentPadding = 0
    self.delaysContentTouches = true
    self.isEditable = false
    self.isUserInteractionEnabled = true
    self.isSelectable = false
    styleTextLinks()
  }

  private func styleTextLinks() {
    guard !initialized && !textLinks.isEmpty else {
        return
    }
    initialized = true

    let alignmentStyle = NSMutableParagraphStyle()
    alignmentStyle.alignment = self.textAlignment        

    let input = self.text ?? ""
    let attributes: [NSAttributedStringKey : Any] = [
        NSAttributedStringKey.foregroundColor : self.textColor!,
        NSAttributedStringKey.font : self.font!,
        .paragraphStyle : alignmentStyle
    ]
    let attributedString = NSMutableAttributedString(string: input, attributes: attributes)

    for textLink in textLinks {
        let range = (input as NSString).range(of: textLink.0)
        if range.lowerBound != NSNotFound {
            attributedString.addAttribute(.underlineStyle, value: NSUnderlineStyle.styleSingle.rawValue, range: range)
        }
    }

    attributedText = attributedString
  }

  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    startTime = Date().timeIntervalSinceReferenceDate
  }

  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let callback = callback {
        if let startTime = startTime {
            self.startTime = nil
            if (Date().timeIntervalSinceReferenceDate - startTime <= 0.2) {
                callback()
            }
        }
    }
  }

  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    var location = point
    location.x -= self.textContainerInset.left
    location.y -= self.textContainerInset.top
    if location.x > 0 && location.y > 0 {
        let index = self.layoutManager.characterIndex(for: location, in: self.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
        for textLink in textLinks {
            let range = ((text ?? "") as NSString).range(of: textLink.0)
            if NSLocationInRange(index, range) {
                callback = {
                    self.linkDelegate?.didTap(text: textLink.0, withLink: textLink.1, inTextView: self)
                }
                return self
            }
        }
    }
    callback = nil
    return nil
  }
}

Le délégué:

import Foundation

protocol LinkTextViewDelegate {
  func didTap(text: String, withLink link: String, inTextView textView: LinkTextView)
}

Le viewController implémenté:

override func viewDidLoad() {
  super.viewDidLoad()
  myLinkTextView.linkDelegate = self
  myLinkTextView.textLinks = [
    "click here" : "https://wwww.google.com",
    "or here" : "#myOwnAppHook"
  ]
}

Et enfin, un grand merci à Dan Bray, qui est la solution après tout!

3
Pvt. Joker

Si vous avez besoin d'une sous-classe de Label, la solution peut ressembler à celle préparée dans un terrain de jeu (certains points doivent être optimisés car il ne s'agit que d'une ébauche):

//: A UIKit based Playground for presenting user interface

import UIKit
import PlaygroundSupport

extension String {
    // MARK: - String+RangeDetection

    func rangesOfPattern(patternString: String) -> [Range<Index>] {
        var ranges : [Range<Index>] = []

        let patternCharactersCount = patternString.count
        let strCharactersCount = self.count
        if  strCharactersCount >= patternCharactersCount {

            for i in 0...(strCharactersCount - patternCharactersCount) {
                let from:Index = self.index(self.startIndex, offsetBy:i)
                if let to:Index = self.index(from, offsetBy:patternCharactersCount, limitedBy: self.endIndex) {

                    if patternString == self[from..<to] {
                        ranges.append(from..<to)
                    }
                }
            }
        }

        return ranges
    }

    func nsRange(from range: Range<String.Index>) -> NSRange? {
        let utf16view = self.utf16
        if let from = range.lowerBound.samePosition(in: utf16view),
            let to = range.upperBound.samePosition(in: utf16view) {
            return NSMakeRange(utf16view.distance(from: utf16view.startIndex, to: from),
                               utf16view.distance(from: from, to: to))
        }
        return nil
    }

    func range(from nsRange: NSRange) -> Range<String.Index>? {
        guard
            let from16 = utf16.index(utf16.startIndex, offsetBy: nsRange.location, limitedBy: utf16.endIndex),
            let to16 = utf16.index(from16, offsetBy: nsRange.length, limitedBy: utf16.endIndex),
            let from = String.Index(from16, within: self),
            let to = String.Index(to16, within: self)
            else { return nil }
        return from ..< to
    }
}

final class TappableLabel: UILabel {

    private struct Const {
        static let DetectableAttributeName = "DetectableAttributeName"
    }

    var detectableText: String?
    var displayableContentText: String?

    var mainTextAttributes:[NSAttributedStringKey : AnyObject] = [:]
    var tappableTextAttributes:[NSAttributedStringKey : AnyObject] = [:]

    var didDetectTapOnText:((_:String, NSRange) -> ())?

    private var tapGesture:UITapGestureRecognizer?

    // MARK: - Public

    func performPreparation() {
        DispatchQueue.main.async {
            self.prepareDetection()
        }
    }

    // MARK: - Private

    private func prepareDetection() {

        guard let searchableString = self.displayableContentText else { return }
        let attributtedString = NSMutableAttributedString(string: searchableString, attributes: mainTextAttributes)

        if let detectionText = detectableText {

            var attributesForDetection:[NSAttributedStringKey : AnyObject] = [
                NSAttributedStringKey(rawValue: Const.DetectableAttributeName) : "UserAction" as AnyObject
            ]
            tappableTextAttributes.forEach {
                attributesForDetection.updateValue($1, forKey: $0)
            }

            for (_ ,range) in searchableString.rangesOfPattern(patternString: detectionText).enumerated() {
                let tappableRange = searchableString.nsRange(from: range)
                attributtedString.addAttributes(attributesForDetection, range: tappableRange!)
            }

            if self.tapGesture == nil {
                setupTouch()
            }
        }

        text = nil
        attributedText = attributtedString
    }

    private func setupTouch() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(TappableLabel.detectTouch(_:)))
        addGestureRecognizer(tapGesture)
        self.tapGesture = tapGesture
    }

    @objc private func detectTouch(_ gesture: UITapGestureRecognizer) {
        guard let attributedText = attributedText, gesture.state == .ended else {
            return
        }

        let textContainer = NSTextContainer(size: bounds.size)
        textContainer.lineFragmentPadding = 0.0
        textContainer.lineBreakMode = lineBreakMode
        textContainer.maximumNumberOfLines = numberOfLines

        let layoutManager = NSLayoutManager()
        layoutManager.addTextContainer(textContainer)

        let textStorage = NSTextStorage(attributedString: attributedText)
        textStorage.addAttribute(NSAttributedStringKey.font, value: font, range: NSMakeRange(0, attributedText.length))
        textStorage.addLayoutManager(layoutManager)

        let locationOfTouchInLabel = gesture.location(in: gesture.view)

        let textBoundingBox = layoutManager.usedRect(for: textContainer)
        var alignmentOffset: CGFloat!
        switch textAlignment {
        case .left, .natural, .justified:
            alignmentOffset = 0.0
        case .center:
            alignmentOffset = 0.5
        case .right:
            alignmentOffset = 1.0
        }
        let xOffset = ((bounds.size.width - textBoundingBox.size.width) * alignmentOffset) - textBoundingBox.Origin.x
        let yOffset = ((bounds.size.height - textBoundingBox.size.height) * alignmentOffset) - textBoundingBox.Origin.y
        let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - xOffset, y: locationOfTouchInLabel.y - yOffset)

        let characterIndex = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

        if characterIndex < textStorage.length {
            let tapRange = NSRange(location: characterIndex, length: 1)
            let substring = (self.attributedText?.string as? NSString)?.substring(with: tapRange)

            let attributeName = Const.DetectableAttributeName
            let attributeValue = self.attributedText?.attribute(NSAttributedStringKey(rawValue: attributeName), at: characterIndex, effectiveRange: nil) as? String
            if let _ = attributeValue,
                let substring = substring {
                DispatchQueue.main.async {
                    self.didDetectTapOnText?(substring, tapRange)
                }
            }
        }

    }
}


class MyViewController : UIViewController {
    override func loadView() {
        let view = UIView()
        view.backgroundColor = .white

        let label = TappableLabel()
        label.frame = CGRect(x: 150, y: 200, width: 200, height: 20)
        label.displayableContentText = "Hello World! stackoverflow"
        label.textColor = .black
        label.isUserInteractionEnabled = true

        label.detectableText = "World!"
        label.didDetectTapOnText = { (value1, value2) in
            print("\(value1) - \(value2)\n")
        }
        label.performPreparation()

        view.addSubview(label)
        self.view = view
    }
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

démo:

 enter image description here

0
gbk