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.

I 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

Filip Němeček profile photo

WRITTEN BY

Filip Němeček @nemecek_f

iOS blogger and developer with interest in Python/Django. Telling other devs' stories with iOS Chat.