Um den Cursor beim Bearbeiten einer SwiftUI-Liste an derselben Stelle zu halten, gib jedem Element eine unveränderliche ID, binde das Textfeld direkt an seinen Text und behalte die Position des Editors in der View-Hierarchie bei. Unter macOS 15 oder neuer kannst du eine Selection-Bindung verwenden, wenn deine App auch die Einfügemarke steuern muss.

In dieser Anleitung erstellen wir eine kleine Mac-App zum Bearbeiten synthetischer Beschriftungen wie Blue tile. Dabei soll die Eingabe im selben Element fortgesetzt werden, während sich dessen Text und die umgebende Liste aktualisieren. Außerdem wird erklärt, wann das Wiederherstellen der Selection zusätzlichen Zustand oder einen AppKit-Editor erfordert.

Warum springt der Cursor, wenn sich ein Listenelement ändert?

Prüfe zunächst, ob sich die Identität des Elements mit seinem Text ändert. SwiftUI verwendet Identität, um aufeinanderfolgende View-Werte miteinander zu verknüpfen; dauerhafter View-Zustand gehört zu dieser Identität. Die body einer Ansicht neu zu bewerten, ist normal; ihre Identität zu ersetzen, beendet ihre Lebensdauer. Apple erläutert diesen Zusammenhang in Demystify SwiftUI.

Verwende bei einer bearbeitbaren Sammlung nicht die bearbeitbare Beschriftung als Identität einer Zeile. Bei id: \.self ändert sich die Identität eines Strings, wenn sich der String ändert. Dasselbe Problem kann bei einem Modell auftreten, dessen Hashwert die Beschriftung enthält. Wenn du verschiebbare Elemente anhand ihrer Position im Array identifizierst, ordnest du die Identität ebenfalls einem Platz statt dem logischen Element zu.

Verwende stattdessen eine gespeicherte Kennung, die bei Änderungen erhalten bleibt. Apples Dokumentation zu ForEach unterstützt Sammlungen identifizierbarer Elemente oder einen expliziten Key-Pfad für die Identifizierung. Für diesen Editor ist eine gespeicherte UUID sinnvoll, die zum Element gehört.

Entferne außerdem Modifikatoren wie .id(item.text) oder .id(UUID()) vom Editor und seinen übergeordneten Views. Apple dokumentiert ausdrücklich, dass eine Änderung des an id(_:) übergebenen Werts die View-Identität zurücksetzt, einschließlich ihres Zustands. Eine stabile ID für die Zeile kann eine übergeordnete View nicht ausgleichen, deren Identität sich bei jeder Änderung verändert.

Identität, Fokus und Selection getrennt behandeln

Diese drei Zustandswerte beantworten unterschiedliche Fragen. Die Identität bestimmt, welche logische Zeile über Aktualisierungen hinweg bestehen bleibt. Der Fokus bestimmt, welches Steuerelement Tastatureingaben erhält. Die Selection bestimmt die Einfügemarke oder den ausgewählten Text innerhalb dieses Steuerelements.

FocusState ermöglicht einer App, den Fokus zu beobachten und zu ändern. Gib jedem Textfeld einen eigenen Fokuswert, beispielsweise die ID seines Elements. Wenn jede Zeile an denselben Boolean gebunden ist, lässt sich nicht bestimmen, welches einzelne Element auf eine programmatische Fokusanforderung reagieren soll.

TextSelection bildet unter macOS eine Einfügemarke, einen ausgewählten Bereich oder mehrere Selections ab. Der TextField-Initialisierer mit einer Selection-Bindung ist ab macOS 15 verfügbar.

Die folgende Implementierung hält sich an diese dokumentierten Rollen: Das Modell verwaltet Identität und Text, die Liste verwaltet die ID des fokussierten Elements und jede aktive Zeile ihre Selection. Dieses Design ermöglicht unterbrechungsfreies Bearbeiten, solange die Zeile vorhanden bleibt. Es verspricht nicht, alle Bearbeitungsdetails wiederherzustellen, nachdem eine Zeile entfernt und neu erstellt wurde.

Eine Liste mit stabiler Elementidentität erstellen

Erstelle in Xcode eine macOS-SwiftUI-App und setze das Deployment Target auf macOS 15 oder neuer. Verwende ein SDK, das den Initialisierer mit Selection-Bindung enthält. Ersetze die generierten Deklarationen für App und Content View durch dieses vollständige Beispiel. Im Target darf es nur eine @main-Deklaration geben.

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)
    }
}

Die gespeicherte id wird erstellt, wenn ein Element angelegt wird. Änderungen an text oder das Umkehren des vorhandenen Arrays erhalten diese ID. Das bindungsbasierte ForEach stellt jeder Zeile ihren bearbeitbaren Modellwert bereit, und das Textfeld schreibt direkt in den Text dieses Elements.

Ein an einen String gebundenes TextField aktualisiert seine Bindung während der Eingabe fortlaufend. Dieses Beispiel benötigt daher keinen zusätzlichen Änderungs-Handler, der bei jedem Tastendruck den Text in ein zweites Array kopiert oder die Liste neu erstellt.

Die Zeile enthält einen Editor, dessen Position konstant bleibt. Es gibt keinen von Text abhängigen Identitätsmodifikator, kein automatisches Zurücksetzen der Selection und keine Fokusanforderung bei jeder Aktualisierung. Über die Selection-Bindung kann das Feld seine aktuelle Selection übermitteln, ohne dass Anwendungscode sie wiederholt überschreibt.

Das Beispiel wurde anhand der abgerufenen API-Deklarationen geprüft. Es wurde hier nicht kompiliert oder ausgeführt, da diese Umgebung weder einen Swift-Compiler noch eine macOS-Laufzeitumgebung hat.

IDs beim Aktualisieren des Modells beibehalten

Bei einer Aktualisierung müssen die IDs der Elemente erhalten bleiben, die weiterhin dieselben Beschriftungen repräsentieren. Wenn du für jedes bestehende Element eine neue LabelItem(text:) erstellst, entstehen neue IDs, selbst wenn der sichtbare Text unverändert bleibt.

Wenn du eine Sammlung aus den gespeicherten Datensätzen deiner App neu aufbaust, übernimm die vorhandene ID jedes Datensatzes in den neuen Modellwert. Erzeuge nur dann eine neue ID, wenn tatsächlich ein neues Element angelegt wird. Haben die Datensätze keine dauerhafte Kennung, füge sie beim Erstellen hinzu, statt sie aus dem bearbeitbaren Text abzuleiten.

Vermeide es, nach jedem Tastendruck anhand der Beschriftung zu sortieren, sofern die sofortige Verschiebung nicht beabsichtigt ist. Eine stabile Identität sagt SwiftUI, welches Element verschoben wurde. Wenn sich eine Zeile während der Eingabe bewegt, verändert das dennoch das Bearbeitungserlebnis. Eine explizite Sortieraktion sorgt für einen klareren Übergang.

Einen verbleibenden Cursor-Sprung systematisch untersuchen

Die folgende Entscheidungshilfe leitet sich aus der dokumentierten Trennung von Identität, Fokus und Selection ab. Sie hilft, das Problem einzugrenzen, bevor du Wiederherstellungscode hinzufügst.

Beobachtetes Verhalten Erste Prüfung Geeigneter nächster Schritt
Der Editor wird nach jedem Zeichen zurückgesetzt IDs aus Text abgeleitet; sich ändernde Werte von .id(...) Die Element-ID und die Identitäten übergeordneter Views stabil halten
Tastatureingaben erreichen das Feld nicht mehr Fokusänderung oder Entfernen des Editors focusedID und bedingte View-Zweige untersuchen
Der Fokus bleibt im Feld, aber der Cursor springt Anwendung schreibt in Text oder Selection Unbedingte Neuzuweisungen und Selection-Zuweisungen entfernen
Der Bearbeitungszustand folgt nach dem Sortieren einer Position Identität anhand des Index Das logische Element anhand seiner gespeicherten ID identifizieren
Die Selection verschwindet, nachdem eine Zeile herausgefiltert und wieder eingeblendet wurde Lebensdauer des zeilenlokalen Zustands Wiederherstellungsinformationen oberhalb der entfernbaren Zeile speichern

Reproduziere das Problem zunächst ohne Sortierung, Filtern, Formatierung oder Modellaktualisierungen. Bearbeite die Mitte von Blue tile. Wenn das funktioniert, füge die Vorgänge einzeln wieder hinzu. So lässt sich die Änderung ermitteln, die mit dem Sprung zusammenhängt.

Untersuche als Nächstes strukturelle Änderungen. Wenn zwischen zwei bedingten Zweigen mit jeweils eigenen Editoren gewechselt wird, kann sich die Identität des Editors ändern. Apples Erläuterung zu Identität und Lebensdauer spricht dafür, einen einzelnen Editor beizubehalten, wenn sich nur sein Aussehen ändern muss.

Untersuche zuletzt Schreibvorgänge in die Textbindung. Ein Handler, der den String während der Eingabe kürzt, großschreibt oder anderweitig ersetzt, führt einen zweiten Bearbeitungsvorgang ein. Für die Bereinigung von Beschriftungen ist es sinnvoll, den Entwurf während der Eingabe unverändert zu lassen und die Umwandlung erst beim Absenden anzuwenden.

Festlegen, was das Entfernen einer Zeile überdauern soll

Eine zeilenlokale Selection eignet sich für eine Zeile, die bestehen bleibt. Als einzige Quelle für die Wiederherstellung reicht sie nicht aus, wenn die Oberfläche diese Zeile entfernt. In Apples Versionshinweisen zu macOS 15 steht außerdem, dass ForEach dauerhaften Zustand ungenutzter untergeordneter Views freigeben kann.

Wenn beim Zurückkehren zu einem Element die Einfügeposition wiederhergestellt werden soll, speichere die Wiederherstellungsinformationen oberhalb der entfernbaren Zeilen und ordne sie anhand der Element-ID zu. Verknüpfe sie mit der Textversion, auf die sie sich beziehen. Entferne diese Informationen, wenn das Element endgültig gelöscht wird.

Fokus und Selection wiederherzustellen, sind getrennte Vorgänge. Wenn das Element nicht mehr existiert, leite seinen gespeicherten Cursor nicht zu dem Element um, das nun denselben Platz im Array einnimmt.

Programmatische Textänderungen mit einer klaren Cursorregel behandeln

Eine stabile Identität legt nicht fest, wohin der Cursor springen soll, nachdem deine App den Text ersetzt hat. Dafür brauchst du eine Regel, die zur Aktion passt: die logische Position beibehalten, den Ersatztext auswählen oder ans Ende springen.

SwiftUIs TextSelection-Initialisierer akzeptieren eine Einfügemarke oder Bereiche auf Basis von String.Index. Erstelle eine neue Selection anhand des resultierenden Strings, wenn deine Anwendung diesen String ändert. Behandle eine alte Selection nicht als dauerhaftes numerisches Lesezeichen.

Swift-Strings verwenden String.Index, und ihre Zeichenanzahl kann von der UTF-16-Länge abweichen, die NSString verwendet. Diese Unterschiede sind unter Strings and Characters dokumentiert. Sie sind wichtig, wenn du zwischen SwiftUI-Selections und Cocoa-Textbereichen umrechnest.

Angenommen, der Text lautet Blue tile und der Cursor steht direkt hinter Blue. Wird am Anfang Soft eingefügt, sollte sich der Cursor um fünf Positionen nach vorne bewegen, wenn seine Position relativ zum ursprünglichen Text erhalten bleiben soll. Bleibt der numerische Offset unverändert, läge er in einem anderen Teil der Beschriftung.

Dieses Beispiel verwendet einfachen ASCII-Text. Lege bei beliebigen Strings die Offset-Einheit fest und prüfe die resultierenden Grenzen. Überschneidet ein Ersatz den ausgewählten Text, braucht auch das Ergebnis eine klare Regel. Einen Offset einfach auf die neue Länge zu begrenzen, legt die beabsichtigte Selection nicht fest.

Wann solltest du AppKit oder TextKit verwenden?

Bei älteren macOS-Deployment-Targets ist der SwiftUI-Initialisierer mit Selection-Bindung aus macOS 15 nicht verfügbar. Instabile Identität kannst du auch bei einem einfachen SwiftUI-Textfeld korrigieren. Benötigt die App zusätzlich eine explizite Steuerung der Selection, ist ein AppKit-Editor, eingebunden über NSViewRepresentable, ein unterstützter Weg.

Apple beschreibt NSTextView als Editor-View, die TextKit-Funktionen bereitstellt. Du musst keine eigene Layout-Engine entwickeln, nur um einen Cursor beizubehalten. Wähle eine eigene Text-View, wenn du ihren Bearbeitungszustand direkt steuern musst.

Bei einem Wrapper sollte die Aktualisierungsreihenfolge so aussehen:

  1. Erstelle den AppKit-Editor in makeNSView(context:) und verbinde seinen Delegate über einen Coordinator.
  2. Übertrage Benutzereingaben an das gebundene Modell und Selection-Änderungen an einen Zustand, der der Element-ID zugeordnet ist.
  3. Vergleiche in updateNSView den Modelltext mit dem aktuellen Text des Editors, bevor du etwas zuweist.
  4. Berechne bei einem tatsächlichen programmatischen Ersatz die Selection anhand des resultierenden Textes und wende sie einmal an.
  5. Unterdrücke Delegate-Rückmeldungen, die durch diese Aktualisierung entstehen, und behandle markierten Text, bevor du aufgeschobene Umwandlungen anwendest.

Dies sind Implementierungsregeln für die Schnittstelle und kein zweites vollständiges Codebeispiel. Apple stellt selectedRanges zum Lesen und Schreiben von Text-View-Selections sowie textViewDidChangeSelection(_:) zum Empfangen von Selection-Änderungen bereit. Erhalte mehrere Bereiche, wenn der Editor sie unterstützt.

Für Updates, die die Texteingabe berücksichtigen, meldet hasMarkedText(), ob markierter Text vorhanden ist. Sinnvoll ist es, nicht wesentliche Umwandlungen der Anwendung während der Texteingabe aufzuschieben und erst nach deren Ende anzuwenden.

Bei einem AppKit-NSTextField ist auch der Lebenszyklus zu beachten: Apple dokumentiert, dass einfache Steuerelemente den Field Editor ihres Fensters gemeinsam verwenden. Ordne die gespeicherte Selection dem Element und der aktiven Bearbeitungssitzung zu, statt den gemeinsamen Editor als dauerhaftes Objekt pro Zeile zu behandeln.

Halte den Wrapper innerhalb der dokumentierten Integrationsgrenzen. SwiftUI steuert das Layout der dargestellten View, und Apple rät davon ab, ihren verwalteten Frame oder ihre Bounds direkt zuzuweisen. Vermeide auch Annahmen über die interne Implementierung von SwiftUI-Listen: In den Versionshinweisen zu macOS 15 steht, dass Listen ohne Outline keine NSTableView mehr verwenden.

Das Bearbeitungsergebnis in deinem eigenen Fenster prüfen

Verwende die Beispielbeschriftungen, um jedes Verhalten einzeln zu überprüfen. Die folgenden Schritte sind Prüfhinweise und keine berichteten Testergebnisse.

  1. Setze den Cursor zwischen Blue und das folgende Leerzeichen. Tippe small. Prüfe, ob die Eingabe an der erwarteten Einfügeposition fortgesetzt wird.
  2. Wähle tile aus und ersetze es durch square. Prüfe, ob der Ersatz nur den ausgewählten Text betrifft.
  3. Führe während der Bearbeitung Add label mit Command-Shift-N aus. Prüfe aktives Element, Fokus und Cursor getrennt.
  4. Führe Reverse rows mit Command-Shift-R aus. Prüfe, ob der bearbeitete Wert seiner ursprünglichen Element-ID zugeordnet bleibt. Prüfe anschließend Fokus und Selection.
  5. Teste in deiner tatsächlichen Oberfläche das Entfernen und erneute Anzeigen einer Zeile sowie programmatische Textersetzungen anhand deiner festgelegten Wiederherstellungsregel.

Das Anklicken einer Schaltfläche kann den Fokus ändern. Unterscheide deshalb zwischen einem durch die Interaktion verursachten Fokuswechsel und einem durch eine Modellaktualisierung verursachten Zurücksetzen der Selection. Teste genau die Steuerelemente und Tastaturbefehle, die deine App bereitstellt.

Beginne mit stabilen IDs und einer direkten Textbindung. Füge explizite Wiederherstellung nur für Übergänge hinzu, die sie erfordern, und lege für programmatische Ersetzungen eine klare Selection-Regel fest. So bleibt die gewöhnliche Bearbeitung von Beschriftungen unkompliziert, während die Grenzen beim Entfernen von Zeilen und Ersetzen von Text klar bleiben.

Quellen