Search code examples
iosxcodeuilabelswift4nsattributedstring

How can I accurately detect if a link is clicked inside UILabels in Swift 4?


I have managed to style the links without problem using NSMutableAttributedString but I am unable to accurately detect which character has been clicked. I have tried all the solutions in this question (that I could convert to Swift 4 code) but with no luck.

The following code works but fails to accurately detect which character has been clicked and gets the wrong location of the link:

func didTapAttributedTextInLabel(label: UILabel, inRange targetRange: NSRange) -> Bool {
    // Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
    let layoutManager = NSLayoutManager()
    let textContainer = NSTextContainer(size: CGSize.zero)
    let textStorage = NSTextStorage(attributedString: label.attributedText!)

    // Configure layoutManager and textStorage
    layoutManager.addTextContainer(textContainer)
    textStorage.addLayoutManager(layoutManager)

    // Configure textContainer
    textContainer.lineFragmentPadding = 0.0
    textContainer.lineBreakMode = label.lineBreakMode
    textContainer.maximumNumberOfLines = label.numberOfLines
    let labelSize = label.bounds.size
    textContainer.size = labelSize

    // Find the tapped character location and compare it to the specified range
    let locationOfTouchInLabel = self.location(in: label)
    let textBoundingBox = layoutManager.usedRect(for: textContainer)
    let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x, y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y)
    let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x, y: locationOfTouchInLabel.y - textContainerOffset.y)
    let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
    print(indexOfCharacter)
    return NSLocationInRange(indexOfCharacter, targetRange)
}

Solution

  • I managed to solve this by using a UITextView instead of a UILabel. I originally, didn't want to use a UITextView because I need the element to behave like a UILabel and a UITextView can cause issues with scrolling and it's intended use, is to be editable text. The following class I wrote makes a UITextView behave like a UILabel but with fully accurate click detection and no scrolling issues:

    import UIKit
    
    class ClickableLabelTextView: UITextView {
        var delegate: DelegateForClickEvent?
        var ranges:[(start: Int, end: Int)] = []
        var page: String = ""
        var paragraph: Int?
        var clickedLink: (() -> Void)?
        var pressedTime: Int?
        var startTime: TimeInterval?
    
        override func awakeFromNib() {
            super.awakeFromNib()
            self.textContainerInset = UIEdgeInsets.zero
            self.textContainer.lineFragmentPadding = 0
            self.delaysContentTouches = true
            self.isEditable = false
            self.isUserInteractionEnabled = true
            self.isSelectable = false
        }
    
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            startTime = Date().timeIntervalSinceReferenceDate
        }
    
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            if let clickedLink = clickedLink {
                if let startTime = startTime {
                    self.startTime = nil
                    if (Date().timeIntervalSinceReferenceDate - startTime <= 0.2) {
                        clickedLink()
                    }
                }
            }
        }
    
        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            var location = point
            location.x -= self.textContainerInset.left
            location.y -= self.textContainerInset.top
            if location.x > 0 && location.y > 0 {
                let index = self.layoutManager.characterIndex(for: location, in: self.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
                var count = 0
                for range in ranges {
                    if index >= range.start && index < range.end {
                        clickedLink = {
                            self.delegate?.clickedLink(page: self.page, paragraph: self.paragraph, linkNo: count)
                        }
                        return self
                    }
                    count += 1
                }
            }
            clickedLink = nil
            return nil
        }
    }
    

    The function hitTest get's called multiple times but that never causes a problem, as clickedLink() will only ever get called once per click. I tried disabling isUserInteractionEnabled for different views but didn't that didn't help and was unnecessary.

    To use the class, simply add it to your UITextView. If you're using autoLayout in the Xcode editor, then disable Scrolling Enabled for the UITextView in the editor to avoid layout warnings.

    In the Swift file that contains the code to go with your xib file (in my case a class for a UITableViewCell, you need to set the following variables for your clickable textView:

    • ranges - the start and end index of every clickable link with the UITextView
    • page - a String to identify the page or view that contains the the UITextView
    • paragraph - If you have multiple clickable UITextView, assign each one with an number
    • delegate - to delegate the click events to where ever you are able to process them.

    You then need to create a protocol for your delegate:

    protocol DelegateName {
        func clickedLink(page: String, paragraph: Int?, linkNo: Int?)
    }
    

    The variables passed into clickedLink give you all the information you need to know which link has been clicked.