---
title: "Creating an interactive label"
slug: creating-an-interactive-label
section: tech
date: 2018-01-19T18:56:00.000Z
canonical: https://roland.leth.ro/blog/tech/creating-an-interactive-label
---

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][1] 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`:

```swift
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:

```swift
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:

```swift
// 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:

```swift
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:

```swift
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:

```swift
@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:

```swift
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][2] with a "full" version (that can detect any type) and, as always, I'd love to hear your feedback [@roland.leth.ro][3].

[1]:	https://github.com/TTTAttributedLabel/TTTAttributedLabel
[2]:	https://gist.github.com/rolandleth/39161cc2ef03202cf69b74f6c413505b
[3]:	https://bsky.app/profile/roland.leth.ro