NSRange from Swift Range?
Asked Answered
B

13

219

Problem: NSAttributedString takes an NSRange while I'm using a Swift String that uses Range

let text = "Long paragraph saying something goes here!"
let textRange = text.startIndex..<text.endIndex
let attributedString = NSMutableAttributedString(string: text)

text.enumerateSubstringsInRange(textRange, options: NSStringEnumerationOptions.ByWords, { (substring, substringRange, enclosingRange, stop) -> () in

    if (substring == "saying") {
        attributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.redColor(), range: substringRange)
    }
})

Produces the following error:

error: 'Range' is not convertible to 'NSRange' attributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.redColor(), range: substringRange)

Busboy answered 20/11, 2014 at 13:33 Comment(2)
Possible duplicate of NSRange to Range<String.Index> – Ornithomancy
@Ornithomancy that's going the other way around. – Outdoors
O
304

Swift String ranges and NSString ranges are not "compatible". For example, an emoji like πŸ˜„ counts as one Swift character, but as two NSString characters (a so-called UTF-16 surrogate pair).

Therefore your suggested solution will produce unexpected results if the string contains such characters. Example:

let text = "πŸ˜„πŸ˜„πŸ˜„Long paragraph saying!"
let textRange = text.startIndex..<text.endIndex
let attributedString = NSMutableAttributedString(string: text)

text.enumerateSubstringsInRange(textRange, options: NSStringEnumerationOptions.ByWords, { (substring, substringRange, enclosingRange, stop) -> () in
    let start = distance(text.startIndex, substringRange.startIndex)
    let length = distance(substringRange.startIndex, substringRange.endIndex)
    let range = NSMakeRange(start, length)

    if (substring == "saying") {
        attributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.redColor(), range: range)
    }
})
println(attributedString)

Output:

πŸ˜„πŸ˜„πŸ˜„Long paragra{
}ph say{
    NSColor = "NSCalibratedRGBColorSpace 1 0 0 1";
}ing!{
}

As you see, "ph say" has been marked with the attribute, not "saying".

Since NS(Mutable)AttributedString ultimately requires an NSString and an NSRange, it is actually better to convert the given string to NSString first. Then the substringRange is an NSRange and you don't have to convert the ranges anymore:

let text = "πŸ˜„πŸ˜„πŸ˜„Long paragraph saying!"
let nsText = text as NSString
let textRange = NSMakeRange(0, nsText.length)
let attributedString = NSMutableAttributedString(string: nsText)

nsText.enumerateSubstringsInRange(textRange, options: NSStringEnumerationOptions.ByWords, { (substring, substringRange, enclosingRange, stop) -> () in

    if (substring == "saying") {
        attributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.redColor(), range: substringRange)
    }
})
println(attributedString)

Output:

πŸ˜„πŸ˜„πŸ˜„Long paragraph {
}saying{
    NSColor = "NSCalibratedRGBColorSpace 1 0 0 1";
}!{
}

Update for Swift 2:

let text = "πŸ˜„πŸ˜„πŸ˜„Long paragraph saying!"
let nsText = text as NSString
let textRange = NSMakeRange(0, nsText.length)
let attributedString = NSMutableAttributedString(string: text)

nsText.enumerateSubstringsInRange(textRange, options: .ByWords, usingBlock: {
    (substring, substringRange, _, _) in

    if (substring == "saying") {
        attributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.redColor(), range: substringRange)
    }
})
print(attributedString)

Update for Swift 3:

let text = "πŸ˜„πŸ˜„πŸ˜„Long paragraph saying!"
let nsText = text as NSString
let textRange = NSMakeRange(0, nsText.length)
let attributedString = NSMutableAttributedString(string: text)

nsText.enumerateSubstrings(in: textRange, options: .byWords, using: {
    (substring, substringRange, _, _) in

    if (substring == "saying") {
        attributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.red, range: substringRange)
    }
})
print(attributedString)

Update for Swift 4:

As of Swift 4 (Xcode 9), the Swift standard library provides method to convert between Range<String.Index> and NSRange. Converting to NSString is no longer necessary:

let text = "πŸ˜„πŸ˜„πŸ˜„Long paragraph saying!"
let attributedString = NSMutableAttributedString(string: text)

text.enumerateSubstrings(in: text.startIndex..<text.endIndex, options: .byWords) {
    (substring, substringRange, _, _) in
    if substring == "saying" {
        attributedString.addAttribute(.foregroundColor, value: NSColor.red,
                                      range: NSRange(substringRange, in: text))
    }
}
print(attributedString)

Here substringRange is a Range<String.Index>, and that is converted to the corresponding NSRange with

NSRange(substringRange, in: text)
Onionskin answered 20/11, 2014 at 13:58 Comment(22)
For anyone wanting to type emoji characters on OSX - Control-Command-space bar brings up a character picker – Busboy
This doesn't work if I'm matching more than one word, and I'm not sure what the entire string to match is. Say I'm getting a string back from an API and using it within another string, and I want the string from the API to be underlined, I cannot guarantee that the substrings won't be in both the string from the API and the other string! Any ideas? – Peltast
NSMakeRange Changed str.substringWithRange(Range<String.Index>(start: str.startIndex, end: str.endIndex)) //"Hello, playground" this the changes – Gunyah
(or) casting the string ---let substring = (string as NSString).substringWithRange(NSMakeRange(start, length)) – Gunyah
@HariKrishnan.P: Sorry, I don't get what you are saying. There is no "substringWithRange" in the question or answer, and I have double-checked again that the suggested Swift 2 and Swift 3 code compiles and works as expected. – Onionskin
You mention that Range<String.Index> and NSString are not compatible. Are their counterparts also incompatible? I.e. are NSRange and String incompatible? Cause one of Apple's API specifically combines the two: matches(in:options:range:) – Pagurian
@Senseful: Same problem there, see for example #27881150. – Onionskin
If you're dealing with content that's strictly 8-bit-chararacter encoded, your first example (using NSMakeRange()) will indeed work reliably. I.E, If the string in question was created with String(contentsOf: filepath, encoding: .ascii) then the above warnings do not apply. – Trickish
Cannot convert value of type 'NSRange' (aka '_NSRange') to expected argument type 'Range<String.Index>' (aka 'Range<String.CharacterView.Index>') – Churchgoer
@IulianOnofrei: Strange. I have double-checked that the Swift 3 version still compiles in my Xcode 8.3.2. Which Xcode are you using? – Onionskin
@MartinR, Same latest version. Is that iOS only? As I'm trying to use that in a server. – Churchgoer
@IulianOnofrei: On macOS or on Linux? – Onionskin
@MartinR, I tried it on macOS and it didn't work, but I'm planning to run it on Linux. – Churchgoer
@IulianOnofrei: There was a small error in the Swift 3 code (NSColor.red() should be NSColor.red), but apart from that it compiles without problems in a command-line macOS application. Did you import Foundation? For NSColor you also have to import AppKit. – There may be problems on Linux, this answer https://mcmap.net/q/23817/-swift-extract-regex-matches to a similar question might help. – Onionskin
@MartinR, I did import Foundation but didn't use the NSColor code part, just the range one. I will test again and let you know. – Churchgoer
@MartinR, in your Swift 4 example, I think substringRange is actually a Range<String.Index> and is converted to NSRange with NSRange(substringRange, in: text). – Lozada
@jregnauld: I have rejected your edit suggestion because it completely broke the formatting. Why do you think that the Swift 3 version is wrong? Please let me know if there are problems with this answer. – Onionskin
What if you want to search by a string that contains more than one word. I am not getting this. @MartinR – Customs
@MartinR, seems like "NSRange(substringRange, in: text)" is broken for iOS 16! – Gradygrae
@AnkitJayaswal: I have just tested the Swift 4 version of my answer in the iOS 16 simulator, and it works as expected. What do you think is broken? – Onionskin
@MartinR I am testing with xcode 14.0.1 with swift 5 and for iOS 16 device with following condition. let string = "Get the perfect solution\nfor your needs" let subString = β€œfor your needs" let subTextRange = string.range(of: subString) let range = NSRange(subTextRange, in: subString) Error in last line: Swift/StringUTF16View.swift:249: Fatal error: String index is out of bounds. It is working till iOS 15.5 devices. – Gradygrae
@AnkitJayaswal: subTextRange is a range in string, not in subString, therefore you must only call let range = NSRange(subTextRange, in: string). It may be that NSRange(subTextRange, in: subString) worked in earlier iOS versions by chance, but it was always undefined behaviour to use ranges/indices with anything but the original collection from which they are created. – Onionskin
S
64

For cases like the one you described, I found this to work. It's relatively short and sweet:

 let attributedString = NSMutableAttributedString(string: "follow the yellow brick road") //can essentially come from a textField.text as well (will need to unwrap though)
 let text = "follow the yellow brick road"
 let str = NSString(string: text) 
 let theRange = str.rangeOfString("yellow")
 attributedString.addAttribute(NSForegroundColorAttributeName, value: UIColor.yellowColor(), range: theRange)
Saltwort answered 2/2, 2016 at 5:9 Comment(4)
attributedString.addAttribute won't work with a swift Range – Semination
@Paludis, you are correct but this solution is not attempting to use a Swift range. It is using an NSRange. str is an NSString and therefore str.RangeOfString() returns an NSRange. – Solarium
You can also remove the duplicate string in line 2 by replacing lines 2 and 3 with: let str = attributedString.string as NSString – Swanskin
This is a localization nightmare. – Shelia
B
53

The answers are fine, but with Swift 4 you could simplify your code a bit:

let text = "Test string"
let substring = "string"

let substringRange = text.range(of: substring)!
let nsRange = NSRange(substringRange, in: text)

Be cautious, as the result of range function has to be unwrapped.

Alternative solution:

let text = "Test string"
let substring = "string"

let range = text.range(of: substring).map { NSRange($0, in: text) } ?? NSRange()

Which would either return a valid range or an empty range. Otherwise, you'd need to handle the optionals.

Berkly answered 25/9, 2017 at 12:19 Comment(0)
B
13

Possible Solution

Swift provides distance() which measures the distance between start and end that can be used to create an NSRange:

let text = "Long paragraph saying something goes here!"
let textRange = text.startIndex..<text.endIndex
let attributedString = NSMutableAttributedString(string: text)

text.enumerateSubstringsInRange(textRange, options: NSStringEnumerationOptions.ByWords, { (substring, substringRange, enclosingRange, stop) -> () in
    let start = distance(text.startIndex, substringRange.startIndex)
    let length = distance(substringRange.startIndex, substringRange.endIndex)
    let range = NSMakeRange(start, length)

//    println("word: \(substring) - \(d1) to \(d2)")

        if (substring == "saying") {
            attributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.redColor(), range: range)
        }
})
Busboy answered 20/11, 2014 at 13:33 Comment(1)
Note: This can break if using characters like emoji in the string - See Martin's response. – Busboy
E
9

For me this works perfectly:

let font = UIFont.systemFont(ofSize: 12, weight: .medium)
let text = "text"
let attString = NSMutableAttributedString(string: "exemple text :)")

attString.addAttributes([.font: font], range:(attString.string as NSString).range(of: text))

label.attributedText = attString
Elwira answered 13/12, 2017 at 12:51 Comment(0)
S
8

Swift 5 Solution

Converting Range into NSRange

As the 'encodedOffset' is deprecated, so now in order to convert String.Index to Int we need the reference of original string from which Range<String.Index> was derived.

A convenient detailed extension for NSRange could be as below:

extension NSRange {

    public init(range: Range<String.Index>, 
                originalText: String) {

        let range_LowerBound_INDEX = range.lowerBound
        let range_UpperBound_INDEX = range.upperBound

        let range_LowerBound_INT = range_LowerBound_INDEX.utf16Offset(in: originalText)
        let range_UpperBound_INT = range_UpperBound_INDEX.utf16Offset(in: originalText)

        let locationTemp = range_LowerBound_INT
        let lengthTemp = range_UpperBound_INT - range_LowerBound_INT

        self.init(location: locationTemp,
                  length: lengthTemp)
    }
}

While the shorthand extension is as below

extension NSRange {

    public init(range: Range<String.Index>, 
                originalText: String) {

        self.init(location: range.lowerBound.utf16Offset(in: originalText),
                  length: range.upperBound.utf16Offset(in: originalText) - range.lowerBound.utf16Offset(in: originalText))
    }
}

Now we can use any Range to convert it into NSRange as below, sharing my own requirement which led me to write above extensions

I was using below String extension for finding all the ranges of specific word from the String

extension String {
        
    func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range<Index>] {
        var ranges: [Range<Index>] = []
        while let range = range(of: substring, options: options, range: (ranges.last?.upperBound ?? self.startIndex)..<self.endIndex, locale: locale) {
            ranges.append(range)
        }
        return ranges
    }
}

My requirement was to change the colour of specific words in a String, so for that I wrote this extension which does the job

extension NSAttributedString {

    static func colored(originalText:String,
                        wordToColor:String,
                        currentColor:UIColor,
                        differentColor:UIColor) -> NSAttributedString {
        
        let attr = NSMutableAttributedString(string: originalText)
        
        attr.beginEditing()
        
        attr.addAttribute(NSAttributedString.Key.foregroundColor,
                          value: currentColor,
                          range: NSRange(location: 0, length: originalText.count))
        
        // FOR COVERING ALL THE OCCURENCES
        for eachRange in originalText.ranges(of: wordToColor) {
            attr.addAttribute(NSAttributedString.Key.foregroundColor,
                              value: differentColor,
                              range: NSRange(range: eachRange, originalText: originalText))
        }
        
        attr.endEditing()
        
        return attr
    }

}

Finally I was using it from my main code as below

let text = "Collected".localized() + "  +  " + "Cancelled".localized() + "  +  " + "Pending".localized()
myLabel.attributedText = NSAttributedString.colored(originalText: text,
                                                    wordToColor: "+",
                                                    currentColor: UIColor.purple,
                                                    differentColor: UIColor.blue)

And the result is as below, having the colour of + sign changed as blue from the main text colour which is purple.

enter image description here

Hope this helps someone in need. Thanks!

Syllabize answered 21/7, 2021 at 11:13 Comment(2)
This extension was exactly what I needed; it's basically equivalent to Kotlin String extension offsetByCodePoints – Berserk
@BryanW.Wagner glad to know that you find it useful. – Syllabize
E
6

Swift 4:

Sure, I know that Swift 4 has an extension for NSRange already

public init<R, S>(_ region: R, in target: S) where R : RangeExpression,
    S : StringProtocol, 
    R.Bound == String.Index, S.Index == String.Index

I know in most cases this init is enough. See its usage:

let string = "Many animals here: πŸΆπŸ¦‡πŸ± !!!"

if let range = string.range(of: "πŸΆπŸ¦‡πŸ±"){
     print((string as NSString).substring(with: NSRange(range, in: string))) //  "πŸΆπŸ¦‡πŸ±"
 }

But conversion can be done directly from Range< String.Index > to NSRange without Swift's String instance.

Instead of generic init usage which requires from you the target parameter as String and if you don't have target string at hand you can create conversion directly

extension NSRange {
    public init(_ range:Range<String.Index>) {
        self.init(location: range.lowerBound.encodedOffset,
              length: range.upperBound.encodedOffset -
                      range.lowerBound.encodedOffset) }
    }

or you can create the specialized extension for Range itself

extension Range where Bound == String.Index {
    var nsRange:NSRange {
    return NSRange(location: self.lowerBound.encodedOffset,
                     length: self.upperBound.encodedOffset -
                             self.lowerBound.encodedOffset)
    }
}

Usage:

let string = "Many animals here: πŸΆπŸ¦‡πŸ± !!!"
if let range = string.range(of: "πŸΆπŸ¦‡πŸ±"){
    print((string as NSString).substring(with: NSRange(range))) //  "πŸΆπŸ¦‡πŸ±"
}

or

if let nsrange = string.range(of: "πŸΆπŸ¦‡πŸ±")?.nsRange{
    print((string as NSString).substring(with: nsrange)) //  "πŸΆπŸ¦‡πŸ±"
}

Swift 5:

Due to the migration of Swift strings to UTF-8 encoding by default, the usage of encodedOffset is considered as deprecated and Range cannot be converted to NSRange without an instance of String itself, because in order to calculate the offset we need the source string which is encoded in UTF-8 and it should be converted to UTF-16 before calculating offset. So best approach, for now, is to use generic init.

Echeverria answered 11/10, 2017 at 12:20 Comment(3)
The use of encodedOffset is considered harmful and will be deprecated. – Onionskin
I have fixed encodedOffset issue, check [https://mcmap.net/q/23791/-nsrange-from-swift-range] – Syllabize
But you have to use original text that looks like existing extention as I wrote in the begining. My idea was in conversion without original text. see: public init<R, S>(_ region: R, in target: S) where R : RangeExpression, S : StringProtocol, R.Bound == String.Index, S.Index == String.Index – Echeverria
I
5

Swift 4

I think, there are two ways.

1. NSRange(range, in: )

2. NSRange(location:, length: )

Sample code:

let attributedString = NSMutableAttributedString(string: "Sample Text 12345", attributes: [.font : UIFont.systemFont(ofSize: 15.0)])

// NSRange(range, in: )
if let range = attributedString.string.range(of: "Sample")  {
    attributedString.addAttribute(.foregroundColor, value: UIColor.orange, range: NSRange(range, in: attributedString.string))
}

// NSRange(location: , length: )
if let range = attributedString.string.range(of: "12345") {
    attributedString.addAttribute(.foregroundColor, value: UIColor.green, range: NSRange(location: range.lowerBound.encodedOffset, length: range.upperBound.encodedOffset - range.lowerBound.encodedOffset))
}

Screen Shot: enter image description here

Isadoraisadore answered 24/10, 2017 at 0:57 Comment(1)
The use of encodedOffset is considered harmful and will be deprecated. – Onionskin
B
1

Swift 3 Extension Variant that preserves existing attributes.

extension UILabel {
  func setLineHeight(lineHeight: CGFloat) {
    guard self.text != nil && self.attributedText != nil else { return }
    var attributedString = NSMutableAttributedString()

    if let attributedText = self.attributedText {
      attributedString = NSMutableAttributedString(attributedString: attributedText)
    } else if let text = self.text {
      attributedString = NSMutableAttributedString(string: text)
    }

    let style = NSMutableParagraphStyle()
    style.lineSpacing = lineHeight
    style.alignment = self.textAlignment
    let str = NSString(string: attributedString.string)

    attributedString.addAttribute(NSParagraphStyleAttributeName,
                                  value: style,
                                  range: str.range(of: str as String))
    self.attributedText = attributedString
  }
}
Burro answered 27/11, 2017 at 22:44 Comment(0)
W
0
func formatAttributedStringWithHighlights(text: String, highlightedSubString: String?, formattingAttributes: [String: AnyObject]) -> NSAttributedString {
    let mutableString = NSMutableAttributedString(string: text)

    let text = text as NSString         // convert to NSString be we need NSRange
    if let highlightedSubString = highlightedSubString {
        let highlightedSubStringRange = text.rangeOfString(highlightedSubString) // find first occurence
        if highlightedSubStringRange.length > 0 {       // check for not found
            mutableString.setAttributes(formattingAttributes, range: highlightedSubStringRange)
        }
    }

    return mutableString
}
Withy answered 29/4, 2016 at 10:16 Comment(0)
S
0

I love the Swift language, but using NSAttributedString with a Swift Range that is not compatible with NSRange has made my head hurt for too long. So to get around all that garbage I devised the following methods to return an NSMutableAttributedString with the highlighted words set with your color.

This does not work for emojis. Modify if you must.

extension String {
    func getRanges(of string: String) -> [NSRange] {
        var ranges:[NSRange] = []
        if contains(string) {
            let words = self.components(separatedBy: " ")
            var position:Int = 0
            for word in words {
                if word.lowercased() == string.lowercased() {
                    let startIndex = position
                    let endIndex = word.characters.count
                    let range = NSMakeRange(startIndex, endIndex)
                    ranges.append(range)
                }
                position += (word.characters.count + 1) // +1 for space
            }
        }
        return ranges
    }
    func highlight(_ words: [String], this color: UIColor) -> NSMutableAttributedString {
        let attributedString = NSMutableAttributedString(string: self)
        for word in words {
            let ranges = getRanges(of: word)
            for range in ranges {
                attributedString.addAttributes([NSForegroundColorAttributeName: color], range: range)
            }
        }
        return attributedString
    }
}

Usage:

// The strings you're interested in
let string = "The dog ran after the cat"
let words = ["the", "ran"]

// Highlight words and get back attributed string
let attributedString = string.highlight(words, this: .yellow)

// Set attributed string
label.attributedText = attributedString
Scirrhus answered 9/8, 2017 at 2:9 Comment(1)
"The ran" "ran after". Array of words in this case failing – Hay
C
0

My solution is a string extension that first gets the swift range then get's the distance from the start of the string to the start and end of the substring.

These values are then used to calculate the start and length of the substring. We can then apply these values to the NSMakeRange constructor.

This solution works with substrings that consist of multiple words, which a lot of the solutions here using enumerateSubstrings let me down on.

extension String {

    func NSRange(of substring: String) -> NSRange? {
        // Get the swift range 
        guard let range = range(of: substring) else { return nil }

        // Get the distance to the start of the substring
        let start = distance(from: startIndex, to: range.lowerBound) as Int
        //Get the distance to the end of the substring
        let end = distance(from: startIndex, to: range.upperBound) as Int

        //length = endOfSubstring - startOfSubstring
        //start = startOfSubstring
        return NSMakeRange(start, end - start)
    }

}
Compossible answered 29/7, 2020 at 15:6 Comment(0)
A
-3
let text:String = "Hello Friend"

let searchRange:NSRange = NSRange(location:0,length: text.characters.count)

let range:Range`<Int`> = Range`<Int`>.init(start: searchRange.location, end: searchRange.length)
Angevin answered 21/8, 2016 at 23:15 Comment(1)
How about explaining your answer a bit, and preferably formatting the code properly? – Ovoviviparous

© 2022 - 2024 β€” McMap. All rights reserved.