Hi!
I am trying to create a simple SwiftUI TextField, with an external button to add text to the field at the current insertion point (the cursor in the TextField).
When I add the text, the cursor (I-Beam) remains at the original insertion point, so I want it to move over to the end of what I added. The trouble is, it sometimes moves further forward or to the end (visibly) but works as if it is still at the point I moved it to. This seems to possibly be due to emojis in the TextField (because, I assume, they are composed of more bytes).
Further, sometimes the addition of the text can cause an emoji to appear unexpectedly, I assume because it is combining the bytes in an odd way. So moving the cursor seems to sometimes introduce weird behaviour.
This comes from a much larger project, but I have distilled this down to the smallest example project I could. And I have a video to show how it behaves.
Here's the main part of the code, and I'll attach an Xcode project:
import SwiftUI
struct ContentView: View {
@State private var text: String = "abcdef🧁🧁🧁🧁ghijkl"
@State private var selectedText: TextSelection?
var body: some View {
VStack {
TextField("", text: $text, selection: $selectedText)
.font(.title)
Button("Add Z at Insertion Point in TextField") {
// Get indices of any selection in the text field
let from: String.Index, to: String.Index
if let selectedText = selectedText {
let indices = selectedText.indices
switch indices {
case .selection(let range):
from = range.lowerBound
to = range.upperBound
case .multiSelection(let rangeSet):
from = rangeSet.ranges.first!.lowerBound
to = rangeSet.ranges.first!.upperBound
default:
from = self.text.endIndex
to = self.text.endIndex
}
} else {
from = self.text.endIndex
to = self.text.endIndex
}
guard from <= to && from <= self.text.endIndex else { return }
// Insert and update the cursor position
self.text.replaceSubrange(from..<to, with: "Z")
// Move cursor after the inserted character
let newIndex = self.text.index(after: from)
selectedText = TextSelection(insertionPoint: newIndex)
}
}
.padding()
}
}
STEPS TO REPRODUCE Run the app. Also view the video as it shows the steps.
- Put insertion point between c and d.
- Press the "Add Z" button. Note that Z is placed between c and d. This is great.
- Put insertion point between h and i.
- Press the "Add Z" button. Note that Z is placed between h and i. BUT, the insertion point I-beam moves to the end of the string.
- Press the "Add Z" button again. Z is added where you would have expected based on where the TextSelection insertion point was put, but the flashing I-Beam is still at the end.
- Press the "Add Z" button again. Same issue. insertion point is being shown at end, but to the button it is between Z and i. OF NOTE, if you use the keyboard and press delete, it deletes from end (where the I-beam is).
- Now put insertion point between the 4 cupcakes.
- Press "Add Z" two times. It behaves correctly.
- Press "Add Z" a third time. It adds a fairy emoji.
So, any idea what I am doing wrong? I thought it might be an issue requiring me to update in a background thread, but I tried that, even delaying the update in the thread, but the issue remains.
Thanks in advance.
Here's a video: https://curmi.name/temp/SimpleTextField%20showing%20issues.mp4
And if it helps, here is the Xcode project: https://curmi.name/temp/SimpleTextfield.zip
Thanks for the focused reproducer and the video — they made this easy to investigate. There are two separate problems in what you're seeing, and the right answer is to switch to a different SwiftUI API for this kind of programmatic text editing.
The bug in your code: String.Index invalidation after replaceSubrange
RangeReplaceableCollection.replaceSubrange(_:with:) invalidates all indices into the collection (per the documentation), not just indices at or after the modified range. In your code:
self.text.replaceSubrange(from..<to, with: "Z")
let newIndex = self.text.index(after: from) // 'from' is no longer valid!
selectedText = TextSelection(insertionPoint: newIndex)
After the mutation, from may not correspond to a grapheme boundary in the post-mutation string. The visible result depends on the underlying byte layout: for ASCII-only positions it often appears to work; near multi-scalar graphemes like 🧁 it can land mid-character, and index(after:) returns a position that doesn't match the new visible character layout. That's why the Z occasionally landed at a byte offset that recombined with adjacent cupcake bytes into a different grapheme cluster (the fairy emoji you saw).
If you stayed with TextField + String + TextSelection, the minimal fix is to capture the position as an integer offset before the mutation, then recompute the index against the new string:
let insertOffset = self.text.distance(from: self.text.startIndex, to: from)
self.text.replaceSubrange(from..<to, with: "Z")
let newFrom = self.text.index(self.text.startIndex, offsetBy: insertOffset)
let newIndex = self.text.index(newFrom, offsetBy: 1)
selectedText = TextSelection(insertionPoint: newIndex)
That eliminates the emoji corruption. But there's a second issue this fix doesn't resolve.
The second issue: the visible cursor doesn't follow your programmatic selection update on TextField + TextSelection
After the index fix, the I-beam still visually moves to the end of the text after the insert, even though selectedText correctly holds the new insertion point (you can verify this — the next "Add Z" press inserts exactly where the cursor logically is, not at the end). I tried Task { @MainActor in ... } to defer the selection update, @FocusState to keep the field focused across the Button press, and forcing TextField re-creation with .id(). None of those reliably repositioned the visible cursor.
This isn't really a "bug" so much as an API mismatch. TextField + TextSelection (introduced in macOS 15) gives you read access to where the user's cursor is — it isn't designed for programmatic text mutation with cursor control. There are no mutating methods on TextSelection, and no atomic "mutate text and update selection together" API on plain String.
The proper fix: TextEditor + AttributedString + AttributedTextSelection
In macOS 26 (which your project targets) Apple shipped a richer API specifically for this case. TextEditor has an AttributedString-based initializer, AttributedTextSelection is a value type that travels with AttributedString, and AttributedString itself has replaceSelection(_:withCharacters:) — which mutates the text and updates the selection together, internally handling all the index migration:
import SwiftUI
struct ContentView: View {
@State private var text: AttributedString = AttributedString("abcdef🧁🧁🧁🧁ghijkl")
@State private var selection: AttributedTextSelection = AttributedTextSelection()
var body: some View {
VStack {
TextEditor(text: $text, selection: $selection)
.font(.title)
.frame(height: 60)
Button("Add Z at Insertion Point") {
text.replaceSelection(&selection, withCharacters: "Z")
}
}
.padding()
}
}
I built this against your repro with the cupcake string and stepped through every case you listed in the original post. It behaves correctly in all of them: Z lands exactly at the cursor, the cursor moves to right after the Z and stays visible there, and there's no emoji recombination near the cupcakes regardless of how many times you tap.
For richer mutations beyond a single replace, there's also AttributedString.transform(updating:body:), which lets you mutate the string inside a closure while the call site keeps the selection in sync.
One caveat
The catch is that TextEditor is multi-line by design, and TextField (the single-line control you're using) doesn't have an AttributedString initializer. If you need the single-line TextField look for this UI, you have three options, in roughly increasing effort:
- Constrain
TextEditorto single-line height and disable scrolling — visually similar in many designs. - Wrap
NSTextField(orUITextField) viaNSViewRepresentable/UIViewRepresentable, which gives you full programmatic control overselectedRange. - Stay on
TextField+TextSelectionwith the index-fix workaround above, and accept that the visible cursor will drift to the end after each programmatic insert.
If you'd find a TextField initializer that takes AttributedString + AttributedTextSelection useful, please file a Feedback Report. The shape of the API already exists for TextEditor; extending it to TextField is a reasonable enhancement, and FBs from real-world use cases like yours raise the priority on those gaps.