Last active
November 16, 2022 11:30
-
-
Save PimCoumans/3881f90759f727e3cb8f45738d892ecb to your computer and use it in GitHub Desktop.
Revisions
-
PimCoumans revised this gist
Nov 16, 2022 . 2 changed files with 63 additions and 12 deletions.There are no files selected for viewing
This file contains hidden or 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 charactersOriginal file line number Diff line number Diff line change @@ -13,7 +13,7 @@ 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 @@ -62,16 +62,7 @@ class TappableLabel: UILabel { override var attributedText: NSAttributedString? { didSet { updateTextStorage() } } @@ -87,6 +78,12 @@ class TappableLabel: UILabel { } } override var textAlignment: NSTextAlignment { didSet { updateTextStorage() } } override func layoutSubviews() { super.layoutSubviews() textContainer.size = CGSize(width: bounds.width.rounded(.up), @@ -176,6 +173,34 @@ class TappableLabel: UILabel { } 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)? @@ -197,6 +222,7 @@ private extension TappableLabel { 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! @@ -232,4 +258,4 @@ private extension TappableLabel { handler(rects, valueRange, value, attributeStop) } } } This file contains hidden or 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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,25 @@ 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 -
PimCoumans created this gist
Nov 16, 2022 .There are no files selected for viewing
This file contains hidden or 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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,235 @@ 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 = .placeholderText // 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 { 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") } textStorage = NSTextStorage(attributedString: attributedString) textStorage?.addLayoutManager(layoutManager) } } override var lineBreakMode: NSLineBreakMode { didSet { textContainer.lineBreakMode = lineBreakMode } } override var numberOfLines: Int { didSet { textContainer.maximumNumberOfLines = numberOfLines } } 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 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) } } }