Last active
November 16, 2022 11:30
-
-
Save PimCoumans/3881f90759f727e3cb8f45738d892ecb to your computer and use it in GitHub Desktop.
UILabel subclass that handles touches on parts of its contents marked with custom attributed string key
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
/// Handles taps on tappable parts in the attributed string marked with the `.tappable` attributed string key | |
extension NSAttributedString.Key { | |
public static let tappable = NSAttributedString.Key("TappableContent") | |
} | |
/// UILabel that allows tapping on parts of its contents marked with the `.tappable` key, with touch highlighting | |
/// while the touch is on the tappable range. | |
/// The ``tapHandler`` closure is called when the text is successfully tapped. The value set on the attributed | |
/// string will be provided through this closure as well. | |
class TappableLabel: UILabel { | |
/// Set this closure to get notified about tap events | |
var tapHandler: ((_ rect: CGRect, _ value: AnyObject) -> Void)? | |
var tappableValueHighlightedTextColor: UIColor = .white | |
// TextKit objects used to mimic UILabel string drawing behavior to get the exact | |
// position of elements in the string | |
private let layoutManager: NSLayoutManager | |
private var textStorage: NSTextStorage? | |
private let textContainer: NSTextContainer | |
private let textContainerHeightAdjustment: CGFloat = 10 | |
private let tapAreaOutset: CGFloat = 4 | |
private var highlightedValue: (rects: [CGRect], value: AnyObject)? | |
private var isHighlightingValue: Bool = false { | |
didSet { | |
guard isHighlightingValue != oldValue else { | |
return | |
} | |
var displayRect = bounds | |
if let rects = highlightedValue?.rects { | |
displayRect = rects.reduce(.null, { $0.union($1) }) | |
} | |
setNeedsDisplay(displayRect) | |
} | |
} | |
override init(frame: CGRect) { | |
layoutManager = NSLayoutManager() | |
textStorage = NSTextStorage(string: "") | |
textStorage?.addLayoutManager(layoutManager) | |
textContainer = NSTextContainer(size: CGSize(width: frame.width.rounded(.up), | |
height: frame.height.rounded(.up) + textContainerHeightAdjustment)) | |
textContainer.lineFragmentPadding = 0 | |
layoutManager.addTextContainer(textContainer) | |
super.init(frame: frame) | |
numberOfLines = 0 | |
lineBreakMode = .byWordWrapping | |
textContainer.lineBreakMode = lineBreakMode | |
textContainer.maximumNumberOfLines = numberOfLines | |
isUserInteractionEnabled = true | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override var attributedText: NSAttributedString? { | |
didSet { | |
updateTextStorage() | |
} | |
} | |
override var lineBreakMode: NSLineBreakMode { | |
didSet { | |
textContainer.lineBreakMode = lineBreakMode | |
} | |
} | |
override var numberOfLines: Int { | |
didSet { | |
textContainer.maximumNumberOfLines = numberOfLines | |
} | |
} | |
override var textAlignment: NSTextAlignment { | |
didSet { | |
updateTextStorage() | |
} | |
} | |
override func layoutSubviews() { | |
super.layoutSubviews() | |
textContainer.size = CGSize(width: bounds.width.rounded(.up), | |
height: bounds.height.rounded(.up) + textContainerHeightAdjustment) | |
} | |
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { | |
guard super.point(inside: point, with: event) else { | |
return false | |
} | |
return tappableValue(at: point) != nil | |
} | |
override func drawText(in rect: CGRect) { | |
super.drawText(in: rect) | |
guard let value = highlightedValue, isHighlightingValue else { | |
return | |
} | |
// draw highlight | |
tappableValueHighlightedTextColor.set() | |
value.rects.forEach { rect in | |
UIRectFillUsingBlendMode(rect, .sourceAtop) | |
} | |
} | |
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { | |
defer { | |
if !isHighlightingValue { | |
// Forward touch events when not highlighting mention | |
super.touchesBegan(touches, with: event) | |
} | |
} | |
guard let location = touches.first?.location(in: self) else { | |
return | |
} | |
highlightedValue = tappableValue(at: location) | |
isHighlightingValue = highlightedValue != nil | |
} | |
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { | |
super.touchesMoved(touches, with: event) | |
guard let location = touches.first?.location(in: self), let value = highlightedValue else { | |
return | |
} | |
var isHighlighting = false | |
// Set `isHighlightingValue` based on touch position in one of the value's rects | |
for rect in value.rects { | |
if rect.insetBy(dx: -tapAreaOutset, dy: -tapAreaOutset).contains(location) { | |
isHighlighting = true | |
break | |
} | |
} | |
isHighlightingValue = isHighlighting | |
} | |
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { | |
super.touchesEnded(touches, with: event) | |
guard let value = highlightedValue else { | |
isHighlightingValue = false | |
return | |
} | |
tapHandler?(value.rects.first!, value.value) | |
highlightedValue = nil | |
DispatchQueue.main.async { | |
if self.highlightedValue == nil { | |
self.isHighlightingValue = false | |
} | |
} | |
} | |
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { | |
super.touchesCancelled(touches, with: event) | |
highlightedValue = nil | |
isHighlightingValue = false | |
} | |
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { | |
guard gestureRecognizer is UITapGestureRecognizer else { | |
return true | |
} | |
let position = gestureRecognizer.location(in: self) | |
return tappableValue(at: position) == nil | |
} | |
} | |
private extension TappableLabel { | |
func updateTextStorage() { | |
guard let attributedString = attributedText, !attributedString.string.isEmpty else { | |
textStorage = nil | |
return | |
} | |
if font != nil && attributedString.attribute(.font, at: 0, longestEffectiveRange: nil, in: NSRange(location: 0, length: 1)) == nil { | |
fatalError("No font set in attributedText, tappable regions won't be calculated correctly") | |
} | |
let textStorage = NSTextStorage(attributedString: attributedString) | |
// Update paragraph style for text alignment | |
let range = NSRange(location: 0, length: attributedString.length) | |
attributedString.enumerateAttribute(.paragraphStyle, in: range) { value, range, stop in | |
guard let paragraphStyle = value as? NSParagraphStyle, | |
paragraphStyle.alignment != textAlignment, | |
let mutableParagraphStyle = paragraphStyle as? NSMutableParagraphStyle ?? paragraphStyle.mutableCopy() as? NSMutableParagraphStyle | |
else { | |
return | |
} | |
mutableParagraphStyle.alignment = textAlignment | |
textStorage.addAttribute(.paragraphStyle, value: mutableParagraphStyle, range: range) | |
} | |
textStorage.addLayoutManager(layoutManager) | |
self.textStorage = textStorage | |
} | |
func tappableValue(at point: CGPoint) -> (rects: [CGRect], value: AnyObject)? { | |
var result: ([CGRect], AnyObject)? | |
// Enumerate all tappable values and set result if a rect contains point | |
enumerateTappableValues { (rects, _, value, stop) in | |
for rect in rects { | |
let outsetRect = rect.insetBy(dx: -self.tapAreaOutset, dy: -self.tapAreaOutset) | |
if outsetRect.contains(point) { | |
result = (rects: rects, value: value) | |
stop.pointee = true | |
break | |
} | |
} | |
} | |
return result | |
} | |
func enumerateTappableValues( handler: @escaping (_ rects: [CGRect], _ range: NSRange, _ value: AnyObject, _ stop: UnsafeMutablePointer<ObjCBool>) -> Void) { | |
guard let attributedString = attributedText else { | |
return | |
} | |
let characterRange = NSRange(location: 0, length: attributedString.length) | |
let font = (attributedText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? self.font! | |
let lineHeight = font.lineHeight | |
var lineRectOffset: CGFloat = 0 | |
let layoutSize = layoutManager.usedRect(for: textContainer).size | |
if layoutSize.height.rounded(.up) < bounds.height.rounded(.up) { | |
lineRectOffset = (bounds.height - layoutSize.height) / 2 | |
} | |
// Iterate through all separate values of the `.tappable` attribute | |
attributedString.enumerateAttribute(.tappable, in: characterRange, options: []) { (value, valueRange, attributeStop) in | |
guard let value = value as AnyObject? else { | |
return | |
} | |
let glyphRange = self.layoutManager.glyphRange(forCharacterRange: valueRange, actualCharacterRange: nil) | |
var rects = [CGRect]() | |
// Iterate through all lines found in the range of the current value | |
self.layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { (lineRect, usedRect, textContainer, effectiveRange, lineStop) in | |
if let actualRange = glyphRange.intersection(effectiveRange) { | |
// Get bounding rect of range where value range and line range intersect | |
var rect = self.layoutManager.boundingRect(forGlyphRange: actualRange, in: textContainer) | |
if rect.height > lineHeight { | |
// Make sure line rect doesn't exceed line height | |
rect.origin.y += rect.height - lineHeight | |
rect.size.height = lineHeight | |
} | |
rects.append(rect.offsetBy(dx: 0, dy: lineRectOffset)) | |
} | |
} | |
// Call handler with all found rects of value | |
handler(rects, valueRange, value, attributeStop) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let label = TappableLabel() | |
let attributedString = NSMutableAttributedString( | |
string: "By continuing, you agree to our Terms & Privacy Policy", | |
attributes: [ | |
.font: UIFont.systemFont(ofSize: 16, weight: .regular), | |
.foregroundColor: UIColor(hex: "#8F9199") | |
] | |
) | |
if let range = attributedString.string.range(of: "Terms & Privacy Policy") { | |
let nsRange = NSRange(range, in: attributedString.string) | |
attributedString.addAttributes( | |
[ | |
.tappable: URL(string: "https://web.site/terms")! | |
], | |
range: nsRange | |
) | |
} | |
label.tapHandler = { _, url in | |
print("You've tapped on: \(url)!") | |
} | |
label.textAlignment = .center | |
label.numberOfLines = 0 | |
label.lineBreakMode = .byWordWrapping |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment