How to use NSDataDetector to detect links, email addresses, phone numbers and more
And also a look on creating prettier API with the help of extensions and enums.
Published: Jan. 31, 2021 Sponsored App StoreI am using UITextView
to display OCR'd text in my app Scan it and I was pretty impressed by the text view's ability to detect data like phone numbers, website links or physical addresses. Because the user can then long press on them and quickly access appropriate action (like creating contact for the number, see Safari preview of the website or open Maps with the address).
Turns out this functionality is also available to developers thanks to the NSDataDetector
(docs). This exists since iOS 4 which also means the API is not very modern. For starters, the init
is quite complicated and can also throw an exception.
NSDataDetector basics
Apple mentions in the docs that this data detector should only be used on natural language text. So if you have JSON or XML, you should first parse this with the appropriate parser and then feed the text to the NSDataDetector
.
The general workflow consists of initializing instance of NSDataDetector
to detect specific types of data. We can then use the matches
method to get all the matches. There is also enumerateMatches
method which will call a closure for each match.
Example usage
Here is how to instantiate the NSDataDetector
to match links:
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
Because the rawValue
is of type UInt64
I suspect this is one of the reasons for the throwing init
since you can pass any number you want.
If we want to check for multiple types, we need to use the bitwise OR operation on these raw values:
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.phoneNumber.rawValue)
And on and on for the other types. The CheckingType
actually contains other values, that are not related to data detection which does not help things.
Now that we have basic NSDataDetector
ready, we can use it.
let exampleText = """
https://nemecek.be and my phone is +1-202-555-0149
"""
let matches = detector.matches(in: exampleText, options: [], range: NSRange(location: 0, length: exampleText.utf16.count))
This will detect the URL for my site and the fake phone number. When creating the NSRange
we need to use the utf16
view for the character count because otherwise there could be issues regarding the "correct" length.
With the matches
method we get back and array of NSTextCheckingResult
. This is all encompassing object and we need to check its resultType
property to find out what it hides.
For example something like this:
for match in matches {
if match.resultType == .link {
guard let url = match.url else { continue }
print(url)
}
}
Detecting email address
The detecting of email addresses is a funny thing, because NSDataDetector
will report them as links and the URL will be in this format: mailto:filip@example.com
. Which is nice if you want to open it using system but not for display.
The phone number works a bit different (even though it can also be represented as tel://
URL, the NSTextCheckingResult
has property phoneNumber
that will contain the number if the result is of the correct type.
We can also use the enumerateMatches
method which is not very pleasant either, as you can see from the signature.
detector.enumerateMatches(in: String, options: NSRegularExpression.MatchingOptions, range: NSRange, using: (NSTextCheckingResult?, NSRegularExpression.MatchingFlags, UnsafeMutablePointer<ObjCBool>) -> Void)
Creating nicer API with extensions and enums
As a fun activity I decided to try and wrap this powerful functionality into a more Swifty API via the extension
route. On GitHub you can find this extension along with additional types which allows for much nicer working with the NSDataDetector
.
For example the construction:
let detector = NSDataDetector(dataTypes: DataDetectorType.allCases)
Here is an example of using my enumerateMatches
method. first the signature:
detector.enumerateMatches(in: String, block: (Range<String.Index>, DataDetectorResult.ResultType) -> ())
And then the example implementation with other enum cases omitted:
detector.enumerateMatches(in: exampleText) { (range, match) in
switch match {
case .email(let email, let mailtoURL):
print("Found email address: \(email)")
case .phoneNumber(let number):
print("Found phone number: \(number)")
default:
break
}
}
Because the match is an enum it is clear what data it contains and there is no optionality. This method also returns the Swift Range
if you wanted to highlight the data for example.
There are no doubt edge cases I haven't covered and I omitted the option of matching transit information. I won't paste entire code there because it would be too long, but below is the method that transforms the [NSTextCheckingResult]
into enum cases:
private func processMatch(_ match: NSTextCheckingResult, range: Range<String.Index>) -> DataDetectorResult? {
if match.resultType == .link {
guard let url = match.url else { return nil }
if url.absoluteString.hasPrefix("mailto:") {
let email = url.absoluteString.replacingOccurrences(of: "mailto:", with: "")
return DataDetectorResult(range: range, type: .email(email: email, url: url))
} else {
return DataDetectorResult(range: range, type: .url(url))
}
} else if match.resultType == .phoneNumber {
guard let number = match.phoneNumber else { return nil }
return DataDetectorResult(range: range, type: .phoneNumber(number))
} else if match.resultType == .address {
guard let components = match.addressComponents else { return nil }
return DataDetectorResult(range: range, type: .address(components: components))
} else if match.resultType == .date {
guard let date = match.date else { return nil }
return DataDetectorResult(range: range, type: .date(date))
}
return nil
}
Uses: Xcode 12 & Swift 5.3