216

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)

Jay
  • 4,480
  • 3
  • 25
  • 22

13 Answers13

301

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)
Cœur
  • 37,241
  • 25
  • 195
  • 267
Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • 89
    For anyone wanting to type emoji characters on OSX - Control-Command-space bar brings up a character picker – Jay Nov 20 '14 at 14:37
  • 2
    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? – simonthumper Dec 14 '15 at 09:58
  • NSMakeRange Changed str.substringWithRange(Range(start: str.startIndex, end: str.endIndex)) //"Hello, playground" this the changes – HariKrishnan.P Jun 30 '16 at 10:20
  • (or) casting the string ---let substring = (string as NSString).substringWithRange(NSMakeRange(start, length)) – HariKrishnan.P Jun 30 '16 at 10:27
  • @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. – Martin R Jun 30 '16 at 11:41
  • 2
    You mention that `Range` 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:)](https://developer.apple.com/reference/foundation/nsregularexpression/1412446-matches) – Senseful Jan 12 '17 at 00:36
  • @Senseful: Same problem there, see for example http://stackoverflow.com/questions/27880650/swift-extract-regex-matches. – Martin R Jan 12 '17 at 07:58
  • 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. – Slipp D. Thompson Mar 15 '17 at 06:36
  • `Cannot convert value of type 'NSRange' (aka '_NSRange') to expected argument type 'Range' (aka 'Range')` – Iulian Onofrei May 08 '17 at 22:30
  • @IulianOnofrei: Strange. I have double-checked that the Swift 3 version still compiles in my Xcode 8.3.2. Which Xcode are you using? – Martin R May 09 '17 at 05:00
  • @MartinR, Same latest version. Is that iOS only? As I'm trying to use that in a server. – Iulian Onofrei May 09 '17 at 08:03
  • @IulianOnofrei: On macOS or on Linux? – Martin R May 09 '17 at 08:12
  • @MartinR, I tried it on macOS and it didn't work, but I'm planning to run it on Linux. – Iulian Onofrei May 09 '17 at 08:13
  • @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 http://stackoverflow.com/a/40093509/1187415 to a similar question might help. – Martin R May 09 '17 at 08:19
  • @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. – Iulian Onofrei May 09 '17 at 08:20
  • @MartinR, in your Swift 4 example, I think `substringRange` is actually a `Range` and is converted to `NSRange` with `NSRange(substringRange, in: text)`. – proxpero Aug 11 '17 at 02:32
  • @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. – Martin R Nov 07 '17 at 15:50
  • What if you want to search by a string that contains more than one word. I am not getting this. @MartinR – iPeter Mar 23 '18 at 10:12
  • @MartinR, seems like "NSRange(substringRange, in: text)" is broken for iOS 16! – Ankit Jayaswal Nov 08 '22 at 18:58
  • @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? – Martin R Nov 08 '22 at 19:03
  • @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. – Ankit Jayaswal Nov 08 '22 at 19:10
  • 1
    @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. – Martin R Nov 08 '22 at 19:23
65

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)
tjpaul
  • 343
  • 3
  • 16
royherma
  • 4,095
  • 1
  • 31
  • 42
  • 11
    attributedString.addAttribute won't work with a swift Range – Paludis Jul 07 '16 at 04:49
  • 8
    @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`. – tjpaul Sep 09 '16 at 21:01
  • 3
    You can also remove the duplicate string in line 2 by replacing lines 2 and 3 with: `let str = attributedString.string as NSString` – Jason Moore Jan 05 '17 at 20:14
  • 3
    This is a localization nightmare. – Sulthan Nov 27 '17 at 22:51
51

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.

George Maisuradze
  • 1,953
  • 19
  • 16
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)
        }
})
Jay
  • 4,480
  • 3
  • 25
  • 22
  • 2
    Note: This can break if using characters like emoji in the string - See Martin's response. – Jay Nov 20 '14 at 14:23
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
Breno Valadão
  • 128
  • 1
  • 4
7

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!

Dhaval H. Nena
  • 3,992
  • 1
  • 37
  • 50
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.

Dmitry A.
  • 588
  • 8
  • 13
  • The use of `encodedOffset` is [considered harmful](https://swift.org/blog/utf8-string/#use-of-stringindexencodedoffset-considered-harmful) and will be [deprecated](https://github.com/apple/swift-evolution/blob/master/proposals/0241-string-index-explicit-encoding-offset.md). – Martin R Mar 21 '19 at 07:56
  • I have fixed encodedOffset issue, check [https://stackoverflow.com/a/68468499/2740218] – Dhaval H. Nena Jul 21 '21 at 11:27
  • 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(_ region: R, in target: S) where R : RangeExpression, S : StringProtocol, R.Bound == String.Index, S.Index == String.Index – Dmitry A. Aug 06 '21 at 19:13
4

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

Den
  • 3,179
  • 29
  • 26
  • The use of `encodedOffset` is [considered harmful](https://swift.org/blog/utf8-string/#use-of-stringindexencodedoffset-considered-harmful) and will be [deprecated](https://github.com/apple/swift-evolution/blob/master/proposals/0241-string-index-explicit-encoding-offset.md). – Martin R Mar 21 '19 at 07:55
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
  }
}
jriskin
  • 491
  • 2
  • 14
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
}
orkoden
  • 18,946
  • 4
  • 59
  • 50
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
Brandon A
  • 8,153
  • 3
  • 42
  • 77
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)
    }

}
James Wolfe
  • 340
  • 5
  • 18
-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)
Paulo Galdo Sandoval
  • 2,173
  • 6
  • 27
  • 44
jonas
  • 1
  • 6
    How about explaining your answer a bit, and preferably formatting the code properly? – SamB Aug 22 '16 at 01:52