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
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
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 } }
RichLabelComponentView
This is the view responsible for displaying the formatted label, and handles the tapGesture.
protocol RichLabelComponentViewDelegate: class { func richLabelComponentView(_ richLabelComponentView: RichLabelComponentView, didTapButtonFor 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, didTapButtonFor: 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 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) } }
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?