swiftnsstringnsrange

How to find Multiple NSRange for a string from full string iOS swift


let fullString = "Hello world, there are \(string(07)) continents and \(string(195)) countries."
let range = [NSMakeRange(24,2), NSMakeRange(40,3)]

Need to find the NSRange for numbers in the entire full string and there is a possibility that both numbers can be same. Currently hard coding like shown above, the message can be dynamic where hard coding values will be problematic.

I have split the strings and try to fetch NSRange since there is a possibility of same value. like stringOne and stringTwo.

func findNSMakeRange(initialString:String, fromString: String) {
        let fullStringRange = fromString.startIndex..<fromString.endIndex
        fromString.enumerateSubstrings(in: fullStringRange, options: NSString.EnumerationOptions.byWords) { (substring, substringRange, enclosingRange, stop) -> () in
            let start = distance(fromString.startIndex, substringRange.startIndex)
            let length = distance(substringRange.startIndex, substringRange.endIndex)
            let range = NSMakeRange(start, length)

            if (substring == initialString) {
                print(substring, range)
            }
        })
    }

Receiving errors like Cannot invoke distance with an argument list of type (String.Index, String.Index)

Anyone have any better solution ?


Solution

  • You say that you want to iterate through NSRange matches in a string so that you can apply a bold attribute to the relevant substrings.

    In Swift 5.7 and later, you can use the new Regex:

    string.ranges(of: /\d+/)
        .map { NSRange($0, in: string) }
        .forEach {
            attributedString.setAttributes(attributes, range: $0)
        }
    

    Or if you find the traditional regular expressions too cryptic, you can import RegexBuilder, and you can use the new regex DSL:

    string.ranges(of: Regex { OneOrMore(.digit) })
        .map { NSRange($0, in: string) }
        .forEach {
            attributedString.setAttributes(attributes, range: $0)
        }
    

    Now, this pattern of mapping the Range<String.Index> to a NSRange is only necessary if you really need an NSRange, which is rarely needed nowadays. E.g., if you just wanted to print the subranges, you might just do:

    string.ranges(of: /\d+/)
        .forEach { print(string[$0]) }
    

    Or, if you needed to update a mutable AttributedString on the basis of finding ranges within the original String, you might map to a Range<AttributedString.Index>:

    string.ranges(of: /\d+/)
        .compactMap { Range($0, in: attributedString) }
        .forEach { attributedString[$0].foregroundColor = .blue }
    

    But the key takeaway is that we can now use native Regex literals surrounded by / characters, like shown above.


    In Swift versions prior to 5.7, one would use NSRegularExpression. E.g.:

    let range = NSRange(location: 0, length: string.count)
    try! NSRegularExpression(pattern: "\\d+").enumerateMatches(in: string, range: range) { result, _, _ in
        guard let range = result?.range else { return }
        attributedString.setAttributes(attributes, range: range)
    }
    

    Because the regex backslash was inside a string, that has to be escaped by yet another backslash (which is why this has two backslashes in it). Or you can use extended string delimiters, replacing "\\d+" with #"\d+"#.


    Personally, before Swift 5.7, I found it useful to have a method to return an array of Swift ranges, i.e. [Range<String.Index>]:

    extension StringProtocol {
        func ranges<T: StringProtocol>(of string: T, options: String.CompareOptions = []) -> [Range<Index>] {
            var ranges: [Range<Index>] = []
            var start: Index = startIndex
            
            while let range = range(of: string, options: options, range: start ..< endIndex) {
                ranges.append(range)
                
                if !range.isEmpty {
                    start = range.upperBound               // if not empty, resume search at upper bound
                } else if range.lowerBound < endIndex {
                    start = index(after: range.lowerBound) // if empty and not at end, resume search at next character
                } else {
                    break                                  // if empty and at end, then quit
                }
            }
            
            return ranges
        }
    }
    

    Then you can use it like so:

    let string = "Hello world, there are 09 continents and 195 countries."
    let ranges = string.ranges(of: "[0-9]+", options: .regularExpression)
    

    And then you can map the Range to NSRange. Going back to the original example, if you wanted to make these numbers bold in some attributed string:

    string.ranges(of: "[0-9]+", options: .regularExpression)
        .map { NSRange($0, in: string) }
        .forEach { attributedString.setAttributes(boldAttributes, range: $0) }
    

    Resources: