To keep the caret steady while editing a SwiftUI list, give each item an immutable ID, bind directly to its text, and preserve the editor’s place in the view hierarchy. On macOS 15 or later, use a selection binding when your app also needs to control the insertion point.

This walkthrough builds a small Mac app for editing synthetic labels such as Blue tile. The task is to continue typing within the same item while its text and the surrounding list update. It also explains when restoring selection requires additional state or an AppKit editor.

Why does the caret move when a list item changes?

Start by checking whether the item’s identity changes with its text. SwiftUI uses identity to connect successive view values, and persistent view state belongs to that identity. Re-evaluating a view’s body is normal; replacing its identity ends its lifetime. Apple explains this relationship in Demystify SwiftUI.

For an editable collection, avoid identifying a row by its editable label. With id: \.self, a string’s identity changes when the string changes. With a model whose hash includes its label, the same problem can occur. Identifying movable items by array position also associates identity with a slot rather than the logical item.

Use a stored identifier that survives edits instead. Apple’s ForEach documentation supports collections of identifiable elements or an explicit identifier key path. For this editor, the practical choice is a stored UUID that belongs to the item.

Also remove modifiers such as .id(item.text) or .id(UUID()) from the editor and its ancestors. Apple explicitly documents that changing the value passed to id(_:) resets view identity, including its state. Adding a stable row ID cannot compensate for an ancestor whose identity changes on every edit.

Keep identity, focus, and selection separate

These three pieces of state answer different questions. Identity determines which logical row continues across updates. Focus determines which control receives keyboard input. Selection determines the insertion point or selected text inside that control.

FocusState lets an app observe and change focus. Give each field a distinct focus value, such as its item ID. Binding every row to the same Boolean cannot identify which individual item should receive a programmatic focus request.

TextSelection represents an insertion point, a selected range, or multiple selections on macOS. The TextField initializer with a selection binding is available starting in macOS 15.

The implementation below follows those documented roles: the model owns identity and text, the list owns the focused item ID, and each live row owns its selection. This is a design for uninterrupted editing while the row remains present, rather than a promise to restore every editing detail after removing and recreating a row.

Build a list with stable item identity

Create a macOS SwiftUI app in Xcode and set its deployment target to macOS 15 or later. Use an SDK that includes the selection-binding initializer. Replace the generated app and content-view declarations with this complete example, keeping only one @main declaration in the target.

import Foundation
import SwiftUI

struct LabelItem: Identifiable {
    let id: UUID
    var text: String

    init(id: UUID = UUID(), text: String) {
        self.id = id
        self.text = text
    }
}

@main
struct CaretListApp: App {
    var body: some Scene {
        WindowGroup {
            LabelListView()
        }
    }
}

struct LabelListView: View {
    @State private var items = [
        LabelItem(text: "Blue tile"),
        LabelItem(text: "Round pebble"),
        LabelItem(text: "Paper square")
    ]
    @FocusState private var focusedID: UUID?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Button("Add label") {
                    items.append(LabelItem(text: "New label"))
                }
                .keyboardShortcut("n", modifiers: [.command, .shift])

                Button("Reverse rows") {
                    items.reverse()
                }
                .keyboardShortcut("r", modifiers: [.command, .shift])
            }

            List {
                ForEach($items) { $item in
                    LabelRow(item: $item, focus: $focusedID)
                }
            }
        }
        .padding()
        .frame(minWidth: 420, minHeight: 260)
    }
}

struct LabelRow: View {
    @Binding var item: LabelItem
    let focus: FocusState<UUID?>.Binding
    @State private var selection: TextSelection? = nil

    var body: some View {
        TextField("Label", text: $item.text, selection: $selection)
            .textFieldStyle(.roundedBorder)
            .focused(focus, equals: item.id)
    }
}

The stored id is created when an item is created. Changing text or reversing the existing array preserves that ID. The binding-based ForEach supplies each row with its editable model value, and the field writes directly to that item’s text.

A string-bound TextField updates its binding continuously during editing. Consequently, this example does not need an additional change handler to copy every keystroke into a second array or reconstruct the list.

The row has one consistently positioned editor. There is no text-dependent identity modifier, automatic selection reset, or focus request during each update. The selection binding allows the field to communicate its current selection without application code repeatedly overwriting it.

The example has been inspected against the retrieved API declarations. It has not been compiled or run here because this environment has no Swift compiler or macOS runtime.

Preserve IDs when refreshing the model

A refresh must retain the identifiers of items that still represent the same labels. Creating a fresh LabelItem(text:) for every existing item generates new IDs, even if all visible text remains unchanged.

When rebuilding a collection from your app’s own stored records, carry each record’s existing ID into the new model value. Generate a new ID only when creating a genuinely new item. If the records have no persistent identifier, add one at creation time rather than deriving it from editable text.

Avoid sorting by the label after every keystroke unless immediate movement is intentional. Stable identity tells SwiftUI which item moved, but a row that changes position while someone types still changes the editing experience. An explicit sort action gives the interface a clearer transition.

Diagnose a remaining caret jump in a useful order

The following decision aid is derived from the documented separation between identity, focus, and selection. It helps narrow the problem before adding restoration code.

Observed behavior First check Appropriate next step
The editor resets after every character IDs derived from text; changing .id(...) values Keep the item ID and ancestor identities stable
Keyboard input stops reaching the field Focus changes or editor removal Inspect focusedID and conditional view branches
Focus stays in the field, but the caret moves Application writes to text or selection Remove unconditional rewrites and selection assignments
Editing state follows a position after reordering Index-based identity Identify the logical item with its stored ID
Selection disappears after filtering a row out and back in Row-local state lifetime Store restoration information above the removable row

First reproduce the issue without sorting, filtering, formatting, or model refreshes. Edit the middle of Blue tile. If that works, reintroduce one operation at a time. This isolates the change associated with the jump.

Next inspect structural changes. Switching between two conditional branches containing separate editors can replace the editor’s identity. Apple’s identity and lifetime explanation supports keeping a single editor present when only its appearance needs to change.

Finally inspect writes to the text binding. A handler that trims, capitalizes, or otherwise replaces the string while it is being edited introduces a second editing operation. For label cleanup, a practical policy is to keep the draft unchanged during typing and apply the transformation on submission.

Decide what should survive row removal

Row-local selection state is suitable for a row that remains alive. It is insufficient as the sole restoration store when the interface removes that row. Apple’s macOS 15 release notes also state that ForEach can reclaim persistent state belonging to unused child views.

If returning to an item must restore its editing position, keep restoration information in an owner above the removable rows, keyed by item ID. Associate it with the text version it describes. Remove that information when the item is permanently deleted.

Restoring focus and restoring selection remain separate operations. If the item no longer exists, do not redirect its saved caret to whichever item now occupies the same array position.

Handle programmatic text changes with an explicit caret policy

Stable identity cannot decide where a caret should move after your app replaces the text. That requires a rule appropriate to the action: preserve its logical position, select the replacement, or move to the end.

SwiftUI’s TextSelection initializers accept an insertion point or ranges based on String.Index. Construct a new selection from the resulting string when your application changes that string. Do not treat an old selection as a permanent numeric bookmark.

Swift strings use String.Index, and their character count can differ from the UTF-16 length used by NSString. These distinctions are documented in Strings and Characters. They matter when translating between SwiftUI selections and Cocoa text ranges.

For an illustrative insertion rule, suppose the text is Blue tile and the caret is immediately after Blue. Inserting Soft at the beginning should move the caret five positions forward if the goal is to preserve its location relative to the original text. Leaving its numeric offset unchanged would place it inside a different part of the label.

That illustration uses simple ASCII text. For general strings, define the offset unit and validate the resulting boundaries. A replacement that overlaps the selected text also needs a deliberate result; simply clamping an offset to the new length does not establish the intended selection.

When should you use AppKit or TextKit?

For earlier macOS deployment targets, the macOS 15 SwiftUI selection-binding initializer is unavailable. You can still correct unstable identity in a plain SwiftUI field. If the app also needs explicit selection control, a supported integration path is an AppKit editor wrapped in NSViewRepresentable.

Apple documents NSTextView as an editing view that packages TextKit capabilities. You do not need to build a custom layout engine merely to retain a caret. Choose a dedicated text view when you need direct control of its editing state.

A wrapper should follow this update sequence:

  1. Create the AppKit editor in makeNSView(context:) and connect its delegate through a coordinator.
  2. Forward user edits to the bound model and selection changes to state associated with the item ID.
  3. In updateNSView, compare the model text with the editor’s current text before assigning anything.
  4. For an actual programmatic replacement, calculate the replacement selection against the resulting text and apply it once.
  5. Suppress delegate feedback caused by that application update, and handle marked-text composition before applying deferred transformations.

These are implementation rules for the bridge, rather than a second complete code example. Apple provides selectedRanges for reading and writing text-view selections and textViewDidChangeSelection(_:) for receiving selection changes. Preserve multiple ranges when the editor supports them.

For composition-aware updates, hasMarkedText() reports whether marked text is present. A practical policy is to defer nonessential application transformations during composition, then apply them after it ends.

An AppKit NSTextField has another lifecycle consideration: Apple documents that simple controls share their window’s field editor. Associate saved selection with the item and active editing session rather than treating that shared editor as a permanent per-row object.

Keep the wrapper within the documented integration boundary. SwiftUI controls the represented view’s layout, and Apple cautions against directly assigning its managed frame or bounds. Also avoid assumptions about SwiftUI’s internal list implementation: the macOS 15 release notes state that non-outline lists no longer use NSTableView.

Verify the editing outcome in your own window

Use the sample labels to check each behavior independently. The following are verification steps, not reported test results.

  1. Place the caret between Blue and the following space. Type small. Confirm that typing continues at the expected insertion point.
  2. Select tile and replace it with square. Confirm that the replacement affects only the selected text.
  3. While editing, invoke Add label with Command-Shift-N. Check the active item, focus, and caret separately.
  4. Invoke Reverse rows with Command-Shift-R. Confirm that the edited value remains attached to its original item ID, then check focus and selection.
  5. In your actual interface, test row removal, reappearance, and programmatic text replacement against the restoration policy you chose.

Clicking a button can change focus, so distinguish a focus transition caused by the interaction from a selection reset caused by a model update. Test the exact controls and keyboard commands your app presents.

Begin with stable IDs and a direct text binding. Add explicit restoration only for transitions that require it, and give programmatic replacements a defined selection policy. This keeps ordinary label editing straightforward while making the limits around row removal and text replacement clear.

Sources