Skip to content

Instantly share code, notes, and snippets.

@PimCoumans
Last active November 16, 2022 11:30
Show Gist options
  • Select an option

  • Save PimCoumans/3881f90759f727e3cb8f45738d892ecb to your computer and use it in GitHub Desktop.

Select an option

Save PimCoumans/3881f90759f727e3cb8f45738d892ecb to your computer and use it in GitHub Desktop.

Revisions

  1. PimCoumans revised this gist Nov 16, 2022. 2 changed files with 63 additions and 12 deletions.
    50 changes: 38 additions & 12 deletions TappableLabel.swift
    Original 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 = .placeholderText
    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 {
    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)
    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)
    }
    }
    }
    }
    25 changes: 25 additions & 0 deletions Usage.swift
    Original 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
  2. PimCoumans created this gist Nov 16, 2022.
    235 changes: 235 additions & 0 deletions TappableLabel.swift
    Original 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)
    }
    }
    }