Компонент системы SWIFT/iOS для этикеток с кликабельными текстовые кнопки


Таким образом, я создаю компонент на основе UILabel, который я звоню RichLabel. Основной целью является добавление поддержки для ссылок (несколько). Следует также можно различать типы связей, поэтому я могу обрабатывать их по-разному. Например, одна ссылка должна открыться ModalWindowA и еще ModalWindowB.

У меня есть то, что работает, но не очень твердое решение, и хотел бы некоторые предложения по дизайну.

RichButton

RichButton есть различные типы кнопки/ссылки я в настоящее время поддерживают в форматированный текст.

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

RichLabelComponent держит данные для RichLabelComponentView и форматирует текст и выделяет кликабельный текст.

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

RichLabelComponentView

Это представление отвечает за отображение форматированного метки, и обрабатывает 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)
    self.addGestureRecognizer(tapGestureRecognizer)
    self.addSubviewsAndConstraints()
  }

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

  private func addSubviewsAndConstraints() {
    addSubview(label)

    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 {
      return
    }

    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)
      }
    }
  }
}

Это осуществление, которое признает, если один из текстовых ссылок/кнопок, где прослушивается:

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

    layoutManager.addTextContainer(textContainer)
    textStorage.addLayoutManager(layoutManager)

    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)
  }
}

Как использовать компонент

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

Я могу потом отправить buttonи на этот делегат метод, который я могу потом switch на button.self и проверить case is ProfileRichButton например, так я могу знать, какого типа ссылка была нажата.

Проблемы

Что мне не нравится это решение, например, нужно использовать NSString.range(of: "") чтобы задать свойства. Если вдруг есть две ссылки с одинаковым именем, это не сработает.

Любые идеи, как улучшить или перестроить его в более гибкий и надежный способ?



Комментарии