import UIKit // Requirements: // • Links should show up properly and be tappable. // • Text should * not * be selectable. // • The long press interaction that shows the context menu should still work. // See https://stackoverflow.com/questions/47983838/how-can-you-change-the-color-of-links-in-a-uilabel public protocol TappableLabelDelegate: AnyObject { func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) } public class TappableLabel: UILabel { public private(set) var links: [String: NSRange] = [:] private lazy var highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView(targetLabel: self) private(set) var layoutManager = NSLayoutManager() private(set) var textContainer = NSTextContainer(size: CGSize.zero) private(set) var textStorage = NSTextStorage() { didSet { textStorage.addLayoutManager(layoutManager) } } public weak var delegate: TappableLabelDelegate? public override var attributedText: NSAttributedString? { didSet { guard let attributedText: NSAttributedString = attributedText else { textStorage = NSTextStorage() links = [:] return } textStorage = NSTextStorage(attributedString: attributedText) findLinksAndRange(attributeString: attributedText) highlightedMentionBackgroundView.maxPadding = highlightedMentionBackgroundView .calculateMaxPadding(for: attributedText) highlightedMentionBackgroundView.frame = self.bounds.insetBy( dx: -highlightedMentionBackgroundView.maxPadding, dy: -highlightedMentionBackgroundView.maxPadding ) } } public override var lineBreakMode: NSLineBreakMode { didSet { textContainer.lineBreakMode = lineBreakMode } } public override var numberOfLines: Int { didSet { textContainer.maximumNumberOfLines = numberOfLines } } public var containsLinks: Bool { return !links.isEmpty } // MARK: - Initialization public override init(frame: CGRect) { super.init(frame: frame) setup() } public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setup() } private func setup() { isUserInteractionEnabled = true layoutManager.addTextContainer(textContainer) textContainer.lineFragmentPadding = 0 textContainer.lineBreakMode = lineBreakMode textContainer.maximumNumberOfLines = numberOfLines numberOfLines = 0 } // MARK: - Layout public override func didMoveToSuperview() { super.didMoveToSuperview() // Note: Because we want the 'highlight' content to appear behind the label we need // to add the 'highlightedMentionBackgroundView' below it in the view hierarchy // // In order to try and avoid adding even more complexity to UI components which use // this 'TappableLabel' we are going some view hierarchy manipulation and forcing // these elements to maintain the same superview highlightedMentionBackgroundView.removeFromSuperview() superview?.insertSubview(highlightedMentionBackgroundView, belowSubview: self) } public override func layoutSubviews() { super.layoutSubviews() textContainer.size = bounds.size highlightedMentionBackgroundView.frame = self.frame.insetBy( dx: -highlightedMentionBackgroundView.maxPadding, dy: -highlightedMentionBackgroundView.maxPadding ) } // MARK: - Functions private func findLinksAndRange(attributeString: NSAttributedString) { links = [:] let enumerationBlock: (Any?, NSRange, UnsafeMutablePointer) -> Void = { [weak self] value, range, isStop in guard let strongSelf = self else { return } if let value = value { let stringValue = "\(value)" strongSelf.links[stringValue] = range } } attributeString.enumerateAttribute(.link, in: NSRange(0.., with event: UIEvent?) { guard let locationOfTouch = touches.first?.location(in: self) else { return } handleTouch(at: locationOfTouch) } public func handleTouch(at point: CGPoint) { textContainer.size = bounds.size let indexOfCharacter = layoutManager.glyphIndex(for: point, in: textContainer) for (urlString, range) in links where NSLocationInRange(indexOfCharacter, range) { delegate?.tapableLabel(self, didTapUrl: urlString, atRange: range) return } } }