1

My code now is:

extension UITextView {
    func characterBeforeCursor() -> String? {

        // get the cursor position
        if let cursorRange = self.selectedTextRange {

            // get the position one character before the cursor start position
            if let newPosition = self.position(from: cursorRange.start, offset: -1) {

                let range = self.textRange(from: newPosition, to: cursorRange.start)
                return self.text(in: range!)
            }
        }
        return nil
    }
}

this code works well if the character count is 1, but if it is more than one like the emojis, we can't get it. so how can we get the character if it is emoji or a simple character?

Hassan Taleb
  • 2,350
  • 2
  • 19
  • 24

2 Answers2

3

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.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • @LeoDabus And _my_ point is that that doesn't affect my answer. I'm just talking generally about the issues with 16-bit codepoints, characters, and Swift Strings that were elicited by the problem the OP experienced. My hope is that this little essay will be useful to future readers who don't know about this. That isn't you. (I suppose I could have talked about what we used to do about this problem before there _were_ Swift Strings, but I never bother about that any more.) – matt Mar 15 '21 at 00:38
1

You can simply get the text from the beginning of the document up to the start of the cursor range and get the last character:

extension UITextView {
    var characterBeforeCaret: Character? {
        if let selectedTextRange = self.selectedTextRange,
           let range = textRange(from: beginningOfDocument, to: selectedTextRange.start) {
            return text(in: range)?.last
        }
        return nil
    }
}

If you would like to get the character before the caret without getting the whole text before it you will need to convert the UITextPosition to a String.Index as you can see in my post here offset the index by minus one character and return the character at that index:

extension UITextView {
    var characterBeforeCaret: Character? {
        if let textRange = self.selectedTextRange,
            let start = Range(.init(location: offset(from: beginningOfDocument, to: textRange.start), length: 0), in: text)?.lowerBound,
            let index = text.index(start, offsetBy: -1, limitedBy: text.startIndex) {
            return text[index]
        }
        return nil
    }
}
Leo Dabus
  • 229,809
  • 59
  • 489
  • 571
  • 1
    Right, this is what I was getting at. What was wrong with the OP's original attempt is that it effectively mixed two different coordinate systems. :) – matt Mar 14 '21 at 20:49