SwiftUI TextField corrupts selection when inserting utf16

The example code below shows what I am trying to achieve: When the user types a '*', it should be replaced with a '×'.

It looks like it works, but the cursor position is corrupted, even though it looks OK, and the diagnostics that is printed below shows a valid index. If you type "12*34" you get "12×43" because the cursor is inserting before the shown cursor instead of after.

How can I fix this?

struct ContentView: View {
  @State private var input: String = ""
  @State private var selection: TextSelection? = nil
  
  var body: some View {
    VStack {
      TextField("Type 12*34", text: $input, selection: $selection)
        .onKeyPress(action: {keyPress in
          handleKeyPress(keyPress)
        })
      
      Text("Selection: \(selectionAsString())")
    }.padding()
  }
  
  func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
    if (keyPress.key.character == "*") {
      insertAtCursor(text: "×")
      moveCursor(offset: 1)
      return KeyPress.Result.handled
    }
    
    return KeyPress.Result.ignored
  }
  
  func moveCursor(offset: Int) {
    guard let selection else { return }
    
    if case let .selection(range) = selection.indices {
      print("Moving cursor from \(range.lowerBound)")
      let newIndex = input.index(range.lowerBound, offsetBy: offset, limitedBy: input.endIndex)!
      
      let newSelection : TextSelection.Indices = .selection(newIndex..<newIndex)
      if case let .selection(range) = newSelection {
        print("Moved to \(range.lowerBound)")
      }
      self.selection!.indices = newSelection
    }
  }
  
  func insertAtCursor(text: String) {
    guard let selection else { return }
    
    if case let .selection(range) = selection.indices {
      input.insert(contentsOf: text, at: range.lowerBound)
    }
  }
  
  func selectionAsString() -> String {
    guard let selection else { return "None" }
    
    switch selection.indices {
    case .selection(let range):
      if (range.lowerBound == range.upperBound) {
        return ("No selection, cursor at \(range.lowerBound)")
      }
      let lower = range.lowerBound.utf16Offset(in: input)
      let upper = range.upperBound.utf16Offset(in: input)
      return "\(lower) - \(upper)"
      
    case .multiSelection(let rangeSet):
      return "Multi selection \(rangeSet)"
      
    @unknown default:
      fatalError("Unknown selection")
    }
  }
}

I cannot reproduce your issue.

In simulator, when I type

12*,

  * is immediately converted to x

Then I end typing 34 and get 12x34.

Here are the logs:

Moving cursor from 2[any]
Moved to 4[utf8]
Moving cursor from 2[utf16]
Moved to 4[utf8]

Thank you for the reply.

That is strange. I consistently get the error. I am running macOS 15.4.1 (24E263). I've updated the body variable in my original example to print out all the changes (see further below, from line 7). With this change, I get the following output:

input: changed from '' to '1'
input: changed from '1' to '12'
Moving cursor from 2[any]
Moved to 4[utf8]
input: changed from '12' to '12×'
input: changed from '12×' to '12×3'
input: changed from '12×3' to '12×43'

Note line 3&4 where it replaced '*' with 'x'. When I then continue to type '3' and '4' you can see the output changes to "43" instead of "34".

Your log showed the cursor moving twice. Did you use the arrow keys for this? I only have the cursor moving once for the one replacement.

var body: some View {
    VStack {
      TextField("Type 12*34", text: $input, selection: $selection)
        .onKeyPress(action: {keyPress in
          handleKeyPress(keyPress)
        })
        .onChange(of: input) { oldValue, newValue in
          print("input: changed from '\(oldValue)' to '\(newValue)'")
        }
      
      Text("Selection: \(selectionAsString())")
    }.padding()
  }

Hi Claude31,

Have you been able to reproduce my problem with more info given below? Is it possible that the error is only in a certain version of macOS, or am I doing something else wrong?

I just tested with your new code and do not get the error.

Log shows:

input: changed from '' to '1'
input: changed from '1' to '12'
Moving cursor from 2[any]
Moved to 4[utf8]
input: changed from '12' to '12×'
input: changed from '12×' to '12×3'
input: changed from '12×3' to '12×34'

I run Xcode 16.4 on MacOS 15.5 (24F74) and iOS 18.4 simulator. Same with 18.5 simulator. Use hardware keyboard.

With simulator virtual keyboard (on device simulator), log is different:

input: changed from '' to '1'
input: changed from '1' to '12'
input: changed from '12' to '12*'
input: changed from '12*' to '12*3'
input: changed from '12*3' to '12*34'

No more move log, and no change from * to x.

So I added a print:

  func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
        print(keyPress.key.character.unicodeScalars)
        if (keyPress.key.character == "*") {
            insertAtCursor(text: "×")
            moveCursor(offset: 1)
            return KeyPress.Result.handled
        }
        
        return KeyPress.Result.ignored
    }

handleKeyPress is not called with virtual keyboard on simulator.

But it is with the hardware keyboard:

1
input: changed from '' to '1'
2
input: changed from '1' to '12'
*
Moving cursor from 2[any]
Moved to 4[utf8]
input: changed from '12' to '12×'
3
input: changed from '12×' to '12×3'
4
input: changed from '12×3' to '12×34'

So I added another log:

    var body: some View {
        VStack {
          TextField("Type 12*34", text: $input, selection: $selection)
            .onKeyPress(action: {keyPress in
                print("onKeyPress", keyPress.key.character.unicodeScalars)
                return handleKeyPress(keyPress)
            })
            .onChange(of: input) { oldValue, newValue in
              print("input: changed from '\(oldValue)' to '\(newValue)'")
            }
          
          Text("Selection: \(selectionAsString())")
        }.padding()
      }

    func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
        print("handleKeyPress", keyPress.key.character.unicodeScalars)
        if (keyPress.key.character == "*") {
            insertAtCursor(text: "×")
            moveCursor(offset: 1)
            return KeyPress.Result.handled
        }
        
        return KeyPress.Result.ignored
    }

With hardware keyboard, log is:

onKeyPress 1
handleKeyPress 1
onKeyPress 2
handleKeyPress 2
input: changed from '' to '1'
input: changed from '1' to '12'
onKeyPress *
handleKeyPress *
Moving cursor from 2[any]
Moved to 4[utf8]
input: changed from '12' to '12×'
onKeyPress 3
handleKeyPress 3
input: changed from '12×' to '12×3'
onKeyPress 4
handleKeyPress 4
input: changed from '12×3' to '12×34'

with simulator virtual keyboard, log is:

input: changed from '' to '1'
input: changed from '1' to '12'
input: changed from '12' to '12*'
input: changed from '12*' to '12*3'
input: changed from '12*3' to '12*34'

Which shows onKeyPress is not called either.

Surprising. But confirmed here: https://stackoverflow.com/questions/79198074/onkeypress-doesnt-work-on-textfield-foreach-array

I tried my code in MacOS 26.0 Beta (25A5279m). The problem is not there, so it seems to be a bug in 15.5.

Although this specific issue is fixed in 26.0, it is introducing new ones. The index in the string is clearly being corrupted. I suspect it still has to do between the switch of index between utf8 and utf16.

When I type 1234, then press cursor back once, so that curser is between 3 and 4, and type '' three times then I suddenly get '12×3×ח4'.

I can see from my log that the input is completely corrupted:

input: changed from '' to '1'
input: changed from '1' to '12'
Moving cursor from 2[any]
Moved to 4[utf8]
input: changed from '12' to '12×'
input: changed from '12×' to '12×3'
input: changed from '12×3' to '12×34'
Moving cursor from 4[utf16]
Moved to 7[utf8]
input: changed from '12×34' to '12×3×4'
Moving cursor from 7[utf8]
Moved to 8[utf8]
input: changed from '12×3×4' to '12×3××4'
Moving cursor from 8[utf8]
Moved to 9[utf8]
input: changed from '12×3××4' to '12×3×\303×\2274'
code-block

It seems that the text selection in TextField is not great yet.

I wanted to raise a developer support but it said that apple engineers will prioritise forums during this time. Hopefully someone can take a look at this.

I also tried changing from String to AttributedString to test the new TextField with formatting but this does not seem to be available yet.

I did my tests on MacOS 15.5 with Xcode 16.4 and simulator 18.4. Could not see the issue there.

So bug report is best way to go on now.

SwiftUI TextField corrupts selection when inserting utf16
 
 
Q