So I'm creating a component based on UILabel, which I'm calling RichLabel. The main goal is to add support for clickable links (multiple). It should also be possible to to differentiate between types of links, so I can handle them differently. For instance one link should open ModalWindowA and another ModalWindowB.

I have something that works, but it's not a very solid solution, and would love some input on the design.


RichButton is the different types of button/links I currently support in the RichText.

protocol RichButton {
  var id: String { get }
  var buttonTitle: String { get }

struct ProfileRichButton: RichButton {

  let id: String
  let buttonTitle: String

  init(id: String = UUID().uuidString, buttonTitle: String) {
    self.id = id
    self.buttonTitle = buttonTitle


struct AttendeesRichButton: RichButton {

  let id: String
  let buttonTitle: String

  init(id: String = UUID().uuidString, buttonTitle: String) {
    self.id = id
    self.buttonTitle = buttonTitle


struct MeetingRichButton: RichButton {

  let id: String
  let buttonTitle: String

  init(id: String = UUID().uuidString, buttonTitle: String) {
    self.id = id
    self.buttonTitle = buttonTitle



RichLabelComponent is holding the data for the RichLabelComponentView and formats the text and highlights the clickable text.

protocol Component {
  var id: String { get }

struct RichLabelComponent: Component {

  let id: String
  let text: String
  let buttons: [RichButton]

  var formattedText: String {
    let buttonTitles = buttons.map { $0.buttonTitle }
    let formattedString = String(format: text, arguments: buttonTitles)
    return formattedString

  var attributedText: NSAttributedString? {
    let attributedText = NSMutableAttributedString(string: formattedText)
    let nsFormattedText = NSString(string: formattedText)
    attributedText.setAttributes([.font: Theme.regular(size: .large)], range: formattedText.whole)
    for button in buttons {
      let range = nsFormattedText.range(of: button.buttonTitle)
      let attributedButtonTitle = NSAttributedString(string: button.buttonTitle, attributes: [.foregroundColor: Theme.secondaryOrangeColor, .font: Theme.regular(size: .large)])
      attributedText.replaceCharacters(in: range, with: attributedButtonTitle)
    return attributedText

  init(id: String = UUID().uuidString, text: String) {
    self.id = id
    self.text = text
    self.buttons = []

  init(id: String = UUID().uuidString, text: String, buttons: RichButton...) {
    self.id = id
    self.text = text
    self.buttons = buttons


This is the view responsible for displaying the formatted label, and handles the tapGesture.

protocol RichLabelComponentViewDelegate: class {
  func richLabelComponentView(_ richLabelComponentView:   RichLabelComponentView, didTapButton button: RichButton, forComponent  component: RichLabelComponent)

class RichLabelComponentView: UIView {

  // MARK: - Internal properties

  private lazy var label: UILabel = {
    let label = UILabel()
    label.numberOfLines = 0
    label.lineBreakMode = .byWordWrapping
    label.textAlignment = .left
    label.isUserInteractionEnabled = true
    label.translatesAutoresizingMaskIntoConstraints = false
    return label

  private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
    let tapGestureRecognizer = UITapGestureRecognizer()
    tapGestureRecognizer.addTarget(self, action: #selector(tapHandler(gesture:)))
    return tapGestureRecognizer

  // MARK: - External properties

  weak var delegate: RichLabelComponentViewDelegate?

  var component: RichLabelComponent? {
    didSet {
      label.attributedText = component?.attributedText

  // MARK: - Setup

  override init(frame: CGRect) {
    super.init(frame: frame)

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")

  private func addSubviewsAndConstraints() {

    label.leftAnchor.constraint(equalTo: self.leftAnchor).isActive = true
    label.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
    label.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
    label.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true

  // MARK: - Actions

  @objc func tapHandler(gesture: UITapGestureRecognizer) {
    guard let component = component, let delegate = delegate else {

    for button in component.buttons {
      let nsString = component.formattedText as NSString
      let range = nsString.range(of: button.buttonTitle)
      if tapGestureRecognizer.didTapAttributedText(label: label, inRange: range) {
        delegate.richLabelComponentView(self, didTapButton: button, forComponent: component)

This is the implementation which recognises if one of the text links/buttons where tapped:

extension UIGestureRecognizer {

   Returns `true` if the tap gesture was within the specified range of the attributed text of the label.

   - Parameter label:   The label to match tap gestures in.
   - Parameter targetRange: The range for the substring we want to match tap against.

   - Returns: A boolean value indication wether substring where tapped or not.
  func didTapAttributedText(label: UILabel, inRange targetRange: NSRange) -> Bool {
    guard let attributedString = label.attributedText else { fatalError("Not able to fetch attributed string.") }

    var offsetXDivisor: CGFloat
    switch label.textAlignment {
    case .center: offsetXDivisor = 0.5
    case .right: offsetXDivisor = 1.0
    default: offsetXDivisor = 0.0

    let layoutManager = NSLayoutManager()
    let textContainer = NSTextContainer(size: .zero)
    let textStorage = NSTextStorage(attributedString: attributedString)
    let labelSize = label.bounds.size


    textContainer.lineFragmentPadding = 0.0
    textContainer.lineBreakMode = label.lineBreakMode
    textContainer.maximumNumberOfLines = label.numberOfLines
    textContainer.size = labelSize

    let locationOfTouchInLabel = self.location(in: label)
    let textBoundingBox = layoutManager.usedRect(for: textContainer)

    let offsetX = (labelSize.width - textBoundingBox.size.width) * offsetXDivisor - textBoundingBox.origin.x
    let offsetY = (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y
    let textContainerOffset = CGPoint(x: offsetX, y: offsetY)

    let locationTouchX = locationOfTouchInLabel.x - textContainerOffset.x
    let locationTouchY = locationOfTouchInLabel.y - textContainerOffset.y
    let locationOfTouch = CGPoint(x: locationTouchX, y: locationTouchY)

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

    return NSLocationInRange(indexOfCharacter, targetRange)

How to use the component

let component = RichLabelComponent(text: "Hello %@. You are meeting with %@.", buttons: ProfileRichButton(buttonTitle: "Your Name"), AttendeesRichButton(buttonTitle: "Eve and Bob"))
let view = RichLabelComponentView()
view.component = component
view.delegate = self

I can then send button, and on the delegate method I can then switch on button.self and check for case is ProfileRichButton for instance, so I can know which type of link was clicked.

The problems

What I don't like about this solution is for instance the need to use NSString.range(of: "") to set properties. If suddenly there are two links with same name, this won't work.

Any ideas how to improve this or restructure it in a more flexible and solid way?

  • \$\begingroup\$ What is Theme ? \$\endgroup\$ – Martin R Jan 26 '18 at 18:26
  • \$\begingroup\$ I have problems to make it work. indexOfCharacter is always zero. \$\endgroup\$ – Martin R Jan 26 '18 at 19:22
  • \$\begingroup\$ Theme just defines The colors and font used. Not important. But you should degine font size on attributedText for IT to work. \$\endgroup\$ – Eivind Lindbråten Jan 27 '18 at 9:24
  • \$\begingroup\$ OK, works now (after defining whole :) – I just wonder: How does the delegate callback which button was tapped? \$\endgroup\$ – Martin R Jan 27 '18 at 10:22
  • \$\begingroup\$ Oh, sorry forgot about that String extension. I updated my answer. I'm thinking maybe to just also send the RichButton with the delegate. ` func richLabelComponentView(_ richLabelComponentView: RichLabelComponentView, didTapButton button: RichButton, forComponent component: RichLabelComponent)` \$\endgroup\$ – Eivind Lindbråten Jan 27 '18 at 11:28

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.