The thing is, there are different coordinate systems at play here, and you mustn't mix them up in the wrong way.
If you say selectedRange
, you are in the world of Objective-C Cocoa. You are using an NSRange, which is measured entirely in UTF16 codepoints — and an emoji consists of multiple UTF16 codepoints. For example, the length
of "This is a test "
is 19, because each smiley consist of two UTF16 codepoints. So if that is the text view's text and the whole text is selected, the selectedRange
is (0,19)
.
If you say selectedTextRange
, you are in the world of the UITextInput protocol, which measures in UITextPosition units. These are not integers, but they are also measured in codepoints! So, again, if the text view consists of the text "This is a test "
and all of it is selected, the offset
from the start of the selection to the end of the selection is 19.
If you say tv.text
, you now have a String — a Swift String. It thinks in terms of what you would most naturally call "characters". If the text view consists of the text "This is a test "
, the count
of that text is 17, not 19 — because we are now literally counting characters. The String is measured in String.Index values; again, these are not integers, but the distance
from its startIndex
to its endIndex
is 17.
Okay! So now we understand what your original code was doing wrong:
if let newPosition = self.position(from: cursorRange.start, offset: -1) {
By blithlely hard-coding -1
into that code, you are assuming that the preceding "character" is just one position wide. But if it is an emoji, that's false! You need to work out what actually constitutes a character here.
One obvious way to do that is to shift into the Swift String world. But how? We now know that we have different coordinate systems, and you cannot blithely lift a numeric value from one and apply it into another. So how do you "cross the bridge" between the systems?
This used to be an extraordinarily difficult problem, but in recent years a solution has been provided.
To go from a Swift string range to an NSRange, use the NSRange initializer NSRange(_:in:)
. For example, start again with our "This is a test "
:
let s = "This is a test "
let swiftRange = s.startIndex..<s.endIndex
let nsRange = NSRange(swiftRange, in: s) // (0,19)
To go from an NSRange to a Swift string range, use the Range initializer Range(_:in:)
. Once again, start with our "This is a test "
and the nsRange
whose value is (0,19)
:
let s = "This is a test "
let rang = Range(nsRange, in: s)!
let dist = s.distance(from: rang.lowerBound, to: rang.upperBound) // 17
At long last we are ready to solve the original problem! We have a text view whose text is s
, and the user has put the cursor at the end of it. Our goal is to discover and select the character before the cursor:
let r = tv.selectedRange
if let s = tv.text, let rang = Range(r, in: s) {
let end = rang.lowerBound
let start = s.index(end, offsetBy: -1)
let rang2 = start..<end
print(s[rang2]) // , just what you were looking for
let r2 = NSRange(rang2, in: s)
tv.selectedRange = r2
}
Note that I have not put in any safety checks! For that, see Leo Dabus's more rigorous answer. I'm just explaining what the issue is and what the basic techniques are for dealing with it.