iosdev

Implement data detectors inside UILabel

How to recognize links and other stuff (normally available only on UITextView) and make them interact-able.

UILabel has ability to render rather complex text through .attributtedText but is not user-interactive by default. Thus even though particular text can contain URLs or phone numbers, there is no native capability to tap on said URL and open a web view.

Curiously, most open-source components that aim to enable this are years-old, written in Objective-C. Here’s how you can re-implement this feature today.

First, we need an extension over String which will detect all the CheckingTypes we care about:

extension String {
	func detectCheckingTypes(_ types: NSTextCheckingResult.CheckingType = [.link]) -> [NSTextCheckingResult] {
		guard let detector = try? NSDataDetector.init(types: types.rawValue) else {
			return []
		}
		let s = self
		let matches = detector.matches(in: s, options: [], range: NSRange(location: 0, length: s.utf16.count))
		return matches
	}
}

There are about dozen types of data you can detect with this but I’ll focus on .link to keep things simple.

Now, when we have UILabel instance inside some UIView, we can check if there’s anything detectable. If yes, then populate .attributedText after highlighting the found term (using AccentColor is good choice) and attaching UITapGestureRecognizer to the label.

label.text = string

let detectedLinks = string.detectCheckingTypes([.link])
if detectedLinks.count > 0 {
	let attrString = NSMutableAttributedString(string: string)
	
	for match in detectedLinks {
		let matchRange = match.range
		guard let range = Range(matchRange, in: string) else { continue }
		let term = String(string[range])
		
		//	highlight link
		attrString.colorize(term: term, usingColor: .accentColor)
		
		//	handle tap on link
		label.handleInteractionWithCheckingType(match, inside: self.view)
	}
	label.attributedText = attrString
}

Let’s now implement handleInteractionWithCheckingType method on UILabel. First, some sanity checking — if label has no text/attributedText, there’s nothing to do here.

extension UILabel {
	func handleInteractionWithCheckingType(_ match: NSTextCheckingResult, inside view: UIView) {
		guard 
			let string = self.text, string.count > 0,
			let attrString = self.attributedText
		else {
			return
		}
		
		--[CONTINUES BELOW]--
	}
}

Now, re-run the matching and — very important — allow user-interaction on the label instance.

let matchRange = match.range
guard let range = Range(matchRange, in: string) else { return }
let term = String(string[range])

isUserInteractionEnabled = true

If we have a match, switch-over the CheckingType value and implement behavior you need. Here I’m

Only then we can present the URL in some appropriate way for the given app.

switch match.resultType {
	case .link:
		//	handle tap on links
		let tapGR = UITapGestureRecognizer {
			[weak view, weak self] gr in

			guard 
			  let view, let self, 
			  let url = URL(string: term) 
		  else { return }
			
			if self.hasInteractedWith(gr, over: attrString, atRange: matchRange) {
				// Show WKWebView with this URL
			}
		}
		addGestureRecognizer(tapGR)
		
	default:
		break
}

Finally, let’s see how to detect if the tap happened over the link and not some other text — let’s implement hasInteractedWith method.

extension UILabel {
	func hasInteractedWith(_ gr: UIGestureRecognizer, 
	    over attrString: NSAttributedString, 
    	atRange matchRange: NSRange) -> Bool 
	{

	--[CONTINUES BELOW]--

	}
}

UITapGestureRecognizer’s location(in:) method gives us CGPoint inside the label’s bounds. TextKit framework has all the tools we need to match that point with actual character in the label’s text string.

First, instantiate required TextKit objects and copy-over UILabel properties that influence its rendered size to the NSTextContainer:

let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: .zero)
let textStorage = NSTextStorage.init(attributedString: attrString)

layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)

// configure textContainer for the label
textContainer.lineFragmentPadding = 0.0
textContainer.lineBreakMode = self.lineBreakMode
textContainer.maximumNumberOfLines = self.numberOfLines
let labelSize = self.bounds.size
textContainer.size = labelSize

Now, figure out the character index in the string that corresponds to the touch point:

let loc = gr.location(in: self)

let textBoundingBox = layoutManager.usedRect(for: textContainer)

let textContainerOffset = CGPointMake(
	(labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,
	(labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y
)

let locationOfTouchInTextContainer = CGPointMake(
	loc.x - textContainerOffset.x,
	loc.y - textContainerOffset.y
)

let indexOfCharacter = layoutManager.characterIndex(
  for: locationOfTouchInTextContainer, 
  in: textContainer, 
  fractionOfDistanceBetweenInsertionPoints: nil
)

Finally, return true if the indexOfCharacter falls into matched term’s range:

return matchRange.contains(indexOfCharacter)

The main fact here is that calculations above are done in the gesture-recogniser’s tap handler. That means all of UIKit’s rendering is complete and there can be no errors in pinpointing where the interaction happened.

Add these 3 methods into your project and you’ll have the ability to trigger detection of any CheckingType available in iOS SDK.