Creating an interactive label

:  ~ 5 min read

I would like to write about how you can create a label that detects and responds to various UIDataDetectorTypes. Well, we won't really make use of UIDataDetectorTypes, but NSTextCheckingResult, since we'll be using an NSDataDetector. We already have TTTAttributedLabel available, but if you need something much more lightweight, like I did, follow along.

We'll see how we can interact with URLs, but the class should be easily extendable after. Why not use a UITextView, those can do this already?, you might ask; and you'd be right. But UITextViews are expensive to create, and if you need them inside a cell, the scrolling performance will be poor – at the very least until the table/collection stops initializing new cells and starts reusing old ones. It can get worse: for me, the table was always creating a new cell until the max number of required cells was created; only then it started to reuse them and I still haven't figured out why.

Without further ado, let's start with our subclass, InteractiveLabel:

final class InteractiveLabel: UILabel {
  // 1
  private let tapGesture = UITapGestureRecognizer()
  // 2
  private let layoutManager = NSLayoutManager()
  // 3
  private let textContainer = NSTextContainer(size: .zero)
  // 4
  private var textStorage = NSTextStorage()
  private let linkColor: UIColor
  
  @objc
  private func tapped(_ gesture: UITapGestureRecognizer) { // We'll see this later }
  
  // 5
  init(linkColor: UIColor = .red) {
    self.linkColor = linkColor
	
    super.init(frame: .zero)
    
    backgroundColor = .white
    textColor = .black
    // 6
    isUserInteractionEnabled = true
    numberOfLines = 0
    
    // 7
    textContainer.lineFragmentPadding = 0
    textContainer.lineBreakMode = lineBreakMode
    textContainer.maximumNumberOfLines = numberOfLines
    
    // 8
    layoutManager.addTextContainer(textContainer)
    
    // 9
    textStorage.addLayoutManager(layoutManager)
    
    // 10
    tapGesture.addTarget(self, action: #selector(tapped))
    addGestureRecognizer(tapGesture)
  }
    
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

}

We will need to detect taps, but also the character under the tap, to check if it's contained by a URL. For this we'll need a tap gesture (1), an NSLayoutManager (2), an NSTextContainer (3) for our layoutManager to use and an NSTextStorage to keep our layoutManager. We'll see in a bit how all these fit together.

For this to work, we first need to set isUserInteractionEnabled to true (6), since UILabels default to false. Next, we configure the textContainer based on the label's properties (7), we add it to our layoutManager (8), which in turn is added to our textStorage (9). Lastly, we add a target to our gesture recognizer and add it to self (10). We might also want to customize the link's color, so we pass it as an init parameter (5).

Next, we need to make sure to update our textContainer's properties if any label's property changes. First two are straightforward:

override var lineBreakMode: NSLineBreakMode {
    didSet {
      textContainer.lineBreakMode = lineBreakMode
    }
  }
  override var numberOfLines: Int {
    didSet {
      textContainer.maximumNumberOfLines = numberOfLines
    }
  }

The next are very slightly more complex, so we'll have to introduce a couple of properties first:

// 1
private let dataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
private var detectedResults: [NSTextCheckingResult] {
  // 2
  guard let text = self.attributedText?.string else { return [] }
  
  // 3
  return dataDetector?.matches(in: text, range: NSRange(location: 0, length: text.count)) ?? []
}

The powerhouse of our label is the NSDataDetector (1): we use it to detect an array of results that match the link type. If the label has no text (2), we'll return early an empty array; if there is text, we let our detector do its magic and return the results (3).

Now, back to updating the textContainer. We want a seamless experience when using it, and we want it to work for both text and attributedText, so we need to have some logic for them:

override var text: String? {
  didSet {
    // 1
    guard !detectedResults.isEmpty else { return }
    // 2
    guard let text = self.text else { return }
    
    // 3
    attributedText = NSAttributedString(string: text,
    											attributes: [.foregroundColor: textColor, .font: font])
  }
}
override var attributedText: NSAttributedString? {
  didSet {
    // 4
    guard !detectedResults.isEmpty else { return }
    // 5
    guard let oldText = attributedText, !oldText.string.isEmpty else {
      textStorage.setAttributedString(NSAttributedString())
      return
    }
    
    // 6
    let mutableText = NSMutableAttributedString(attributedString: oldText)
    
    // 7
    detectedResults.forEach {
      mutableText.addAttribute(.foregroundColor, value: linkColor, range: $0.range)
    }
    
    // 8
    guard oldText != mutableText else {
      // 9
      textStorage.setAttributedString(mutableText)
      return
    }
    
    // 10
    attributedText = mutableText
  }
}

// 11
override func layoutSubviews() {
  super.layoutSubviews()
  textContainer.size = bounds.size
}

When we're assigning text, we need to transform it into an attributedText (3), but only if we have any results (1), or if the text is not empty (2); otherwise there's no need to. We will add the required attributes for the URLs down below.

Because attributedText's didSet will fire both when assigning it directly and via text's didSet, we need the same checks: have URLs (4) and a non-empty text (5). Next, we create a mutable version out of it (6) so we can add a different color to all results' ranges (7). Then we check if the newly created attributed string equals our existing one (8), in which case we assign it to our textStorage (9), otherwise we assign it to self.attributedText (10) – this will fire the didSet again, but this time we'll reach (9). Lastly, we need to keep our textContainer's size in sync with our label, so we'll do that in layoutSubviews (11).

Next, we'll need some callbacks for taps on links:

private var linkTapped: (_ url: URL) -> Void = { _ in }


// MARK: - Callbacks

func onLinkTap(execute work: @escaping (URL) -> Void) {
  linkTapped = work
}

private func didTapLink(_ url: URL) {
  linkTapped(url)
}

And, lastly, the bread and butter:

@objc
private func tapped(_ gesture: UITapGestureRecognizer) {
  // 1
  gesture.cancelsTouchesInView = false
  
  // 2
  let results = detectedResults
  
  // 3
  guard !results.isEmpty else { return }
  // 4
  guard gesture.state == .ended else { return }
  
  // 5
  let touchLocation = gesture.location(in: gesture.view)
  // 6
  let indexOfCharacter = layoutManager.characterIndex(for: touchLocation,
    												  in: textContainer,
  													  fractionOfDistanceBetweenInsertionPoints: nil)
  
  // 7
  for result in results {
    // 8
    guard result.range.contains(indexOfCharacter) else { continue }
    // 9
    guard let url = result.url else { continue }
    
    // 10
    gesture.cancelsTouchesInView = true
    
    // 11
    didTapLink(url)
    
    // 12
    return
  }
}

Usually when a gesture recognizer receives a touch, those touches will not be delivered to the view, but since we don't know if we're tapping a URL or not, we might want to pass that touch further. So we start by disabling cancelsTouchesInView (1), so that if there is no URL under our touch, it will pass that further to the responder chain. Since detectedResults is a computed property, we might as well store it for the purposes of this method (2).

We then check if we have URLs (3) and if the gesture has ended (4). We extract the gesture's location in its view (5), and use our layoutManager to compute the index of the character found at that location (6) (this is why we need to keep our textContainer in sync with our label).

We'll iterate our results (7) and for each one we'll make sure it contains the character's index (8), if it contains an URL (9). If we found a URL, we enable cancelsTouchesInView again (10) – we will handle the touch and we don't want it to be passed to the responder chain, call our callback with the URL we found (11) and exit the gesture handling method (12).

Here's a quick example on how to use it:

let label = InteractiveLabel(linkColor: .blue)

label.font = .systemFont(ofSize: 14)
label.text = "My blog is located at https://rolandleth.com.\nThis is where I write from time to time."
label.numberOfLines = 2
label.onLinkTap { url in 
  DispatchQueue.main.async {
    someController.present(SFSafariViewController(url: url), animated: true)
  }
}

There are quite a few other attributed labels out there, some with data detection, others without. The purpose of this post was to demonstrate how to add data detection and interaction to a UILabel. You can find the class here with a "full" version (that can detect any type) and, as always, I'd love to hear your feedback @rolandleth.