Why do ranges break when you move between String, AttributedString and NSAttributedString?
Predict first
What does this print?
let t = "👍 hi"
let r = t.range(of: "hi")!
print(NSRange(r, in: t))
- {2, 2}
- {3, 2}
- {4, 2}
Predict first
What does this print?
let t = "👍 hi"
print(t.count, t.utf16.count)
- 4 4
- 4 5
- 5 5
Mental model. Two people counting the same sentence, one by letters and one by keystrokes. They agree until an emoji shows up.
Swift’s String and AttributedString use Range<String.Index> over characters. NSAttributedString, NSString and the text controls use NSRange over UTF-16 code units. An emoji is one Character but two UTF-16 units, so the offsets disagree as soon as the text contains one.
Convert with NSRange(range, in: string) and Range(nsRange, in: string), never by reusing a number. For a full-length range use utf16.count (or NSString.length), not count. When you bridge styled text, include the attribute scope: AttributedString(ns, including: \.uiKit), otherwise UIKit attributes such as font are dropped.
The emoji is two UTF-16 units and the space one, so "hi" starts at 3.
Four characters, but five UTF-16 units. A length computed from count is one short.
Common traps
NSRange(location: 0, length: s.count). Too short for any string containing an emoji or accent made of several scalars.- Dropping the scope when bridging. The converted string silently loses its fonts and colours.
They might follow up with
- Which APIs use
NSRange?UITextView,NSRegularExpression,NSAttributedString, and most older Foundation text APIs. - Is the Regex type affected? No, it works in Swift ranges.
Related questions
- 2.8.1 What is AttributedString, and how is it different from NSAttributedString?
- 1.9.2 Character vs Unicode.Scalar vs UTF-8 code unit?
- 2.9.3 What does bridging cost, and where does it show up in a profile?
← What is AttributedString, and how is it different from… When do you use Scanner, and how does it compare with Regex and split? →