|
|
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
|
|
|
|
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<ObjCBool>) -> 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..<attributeString.length), options: [.longestEffectiveRangeNotRequired], using: enumerationBlock)
|
|
|
|
attributeString.enumerateAttribute(.attachment, in: NSRange(0..<attributeString.length), options: [.longestEffectiveRangeNotRequired], using: enumerationBlock)
|
|
|
|
}
|
|
|
|
|
|
|
|
public override func touchesBegan(_ touches: Set<UITouch>, 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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|