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
- looking if the match is
.link
- making sure that matched term is actually a valid URL
- checking if the person has tapped on the term itself
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.