How can I accurately detect if a link is clicked inside UILabels in Swift 4? How can I accurately detect if a link is clicked inside UILabels in Swift 4? xcode xcode

How can I accurately detect if a link is clicked inside UILabels in Swift 4?


If you don't mind rewriting you code, you should use UITextView instead of UILabel.

You can easily detect the link by setting UITextView's dataDetectorTypesand implement the delegate function to retrieve your clicked urls.

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

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


I managed to solve this by using a UITextView instead of a UILabel. I originally, didn't want to use a UITextView because I need the element to behave like a UILabel and a UITextView can cause issues with scrolling and it's intended use, is to be editable text. The following class I wrote makes a UITextView behave like a UILabel but with fully accurate click detection and no scrolling issues:

import UIKitclass 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    }}

The function hitTest get's called multiple times but that never causes a problem, as clickedLink() will only ever get called once per click. I tried disabling isUserInteractionEnabled for different views but didn't that didn't help and was unnecessary.

To use the class, simply add it to your UITextView. If you're using autoLayout in the Xcode editor, then disable Scrolling Enabled for the UITextView in the editor to avoid layout warnings.

In the Swift file that contains the code to go with your xib file (in my case a class for a UITableViewCell, you need to set the following variables for your clickable textView:

  • ranges - the start and end index of every clickable link with the UITextView
  • page - a String to identify the page or view that contains the the UITextView
  • paragraph - If you have multiple clickable UITextView, assign each one with an number
  • delegate - to delegate the click events to where ever you are able to process them.

You then need to create a protocol for your delegate:

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

The variables passed into clickedLink give you all the information you need to know which link has been clicked.


I wanted to avoid posting an answer since it's more a comment on Dan Bray's own answer (can't comment due to lack of rep). However, I still think it's worth sharing.


I made some small (what I think are) improvements to Dan Bray's answer for convenience:

  • I found it a bit awkward to setup the textView with the ranges andstuff so I replaced that part with a textLink dict which stores thelink strings and their respective targets. The implementing viewController only needs to set this to initialize the textView.
  • I added the underline style to the links (keeping the font etc. from interface builder). Feel free to add your own styles here (like blue font color etc.).
  • I reworked the callback's signature to make it more easy to be processed.
  • Note that I also had to rename the delegate to linkDelegate since UITextViews do have a delegate already.

The TextView:

import UIKitclass 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  }}

The delegate:

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

The implementing viewController:

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

And last but not least a big thank you to Dan Bray, who's solution this is after all!