Zeigen Sie bei einer Form, die in Ihrer Mac-App gezogen wird, während der Geste die Bewegung als Vorschau an und übernehmen Sie die endgültige Position in einer kurzen Undo-Gruppe. Halten Sie Modell und Undo-Manager unter einem gemeinsamen Eigentümer, gleichen Sie jede Gruppenöffnung mit einem Schließen aus und registrieren Sie die inverse Bewegung, damit auch Redo funktioniert.

In diesem Tutorial erstellen Sie eine kleine SwiftUI-Zeichenfläche mit einem synthetischen Kreis im Arbeitsspeicher. Die konkrete Aufgabe besteht darin, dafür zu sorgen, dass für einen abgeschlossenen Ziehvorgang genau ein Undo erforderlich ist – unabhängig davon, wie viele Bewegungsaktualisierungen eintreffen. Außerdem wird erklärt, wie Sie dieselben Grenzen in einer AppKit-Ansicht einhalten.

Warum eine Undo-Gruppe während des Ziehens nicht offen bleiben sollte

Ein Ziehvorgang ist eine Interaktion, die sich über einen Zeitraum erstreckt. Eine Undo-Gruppe sammelt rückgängig machbare Vorgänge. Diese Grenzen müssen nicht dieselbe Lebensdauer haben. Apple dokumentiert, dass groupsByEvent standardmäßig auf true gesetzt ist und Vorgänge um Durchläufe der Runloop gruppiert; dieses Verhalten definiert keine vollständige Ziehtransaktion. Explizite Gruppen können außerdem innerhalb bestehender Gruppen verschachtelt werden. Siehe Apples Dokumentation zur automatischen Ereignisgruppierung und zum Beginn von Undo-Gruppen.

Wenn Sie in der ersten SwiftUI-Bewegungs-Callback eine Gruppe öffnen und sie in onEnded schließen, entsteht eine Lücke, in der eine Abbruchbehandlung fehlt. Apple weist ausdrücklich darauf hin, dass onEnded nur ausgeführt wird, wenn die Geste erfolgreich endet. Im Gegensatz dazu wird ein über updating verwalteter Zustand zurückgesetzt, wenn die Geste endet oder abgebrochen wird. Ein nicht passendes endUndoGrouping() löst eine Ausnahme aus. Daher ist es ebenfalls ungeeignet, zu erraten, ob ein Callback eine Gruppe geöffnet hat. Siehe onEnded(_:), updating(_:body:) und endUndoGrouping().

Verwenden Sie für diese Zeichenfläche das folgende Design. Es ist eine Implementierungsentscheidung, die sich aus den dokumentierten Grenzen ableitet:

Interaktionsphase Anzeigestatus Übernommenes Modell Undo-Gruppierung
Vor dem Ziehen Gespeicherter Mittelpunkt Ursprünglicher Mittelpunkt Geschlossen
Während der Bewegungsaktualisierungen Ursprünglicher Mittelpunkt plus vorübergehende Verschiebung Unverändert Geschlossen
Erfolgreicher Abschluss Endgültiger Mittelpunkt Einmal aktualisiert Synchron geöffnet und geschlossen
Abbruch Vorschau wird zurückgesetzt Unverändert Nie geöffnet
Undo oder Redo Wiederhergestellter Mittelpunkt Über den umkehrbaren Setter aktualisiert Von UndoManager verwaltet

Mit diesem Ansatz kann sich der Kreis kontinuierlich bewegen, ohne jedes Vorschau-Frame als dauerhafte Bearbeitung zu behandeln. Ein abgeschlossener Ziehvorgang wird zu einer einzelnen benannten Modelloperation.

Entscheiden, wem der Undo-Verlauf gehört

Das Beispiel gibt jeder Zeichenfläche ein eigenes Modell und einen privaten Undo-Manager. Da dieser Eigentümer jede Registrierung kontrolliert, setzt er groupsByEvent = false einmal während der Initialisierung und gruppiert jede übernommene Bewegung explizit. Apple verlangt, Gruppen vor dem Undo explizit zu schließen, wenn die automatische Gruppierung deaktiviert ist. Das Beispiel schließt die Gruppe, bevor die Commit-Methode zurückkehrt. Siehe groupsByEvent.

Ein vorhandener Dokumenteditor verfügt möglicherweise bereits über einen Undo-Manager für das Dokument. SwiftUI stellt den kontextbezogenen Manager über @Environment(\.undoManager) bereit. Apple dokumentiert, dass der Wert nil sein kann, wenn der Kontext Undo nicht unterstützt. Verwenden Sie in dieser Architektur weiterhin den Manager des Dokuments und dessen etablierte Gruppierungsrichtlinie. Übernehmen Sie die Konfiguration des privaten Managers aus diesem Beispiel nicht für einen gemeinsam genutzten Manager. Siehe EnvironmentValues.undoManager.

Bei einem gemeinsam genutzten Manager mit aktivierter automatischer Gruppierung verhindert die Registrierung einer inversen Bewegung beim Abschluss weiterhin, dass für jede einzelne Bewegung ein Verlaufseintrag entsteht. Eine explizite Untergruppe kann die Bewegung bündeln, macht sie aber nicht unabhängig von einer übergeordneten Gruppe. Wenn andere Bearbeitungen Teil dieser übergeordneten Gruppe sind, können sie gemeinsam rückgängig gemacht werden. Eine unabhängige Transaktion auf oberster Ebene lässt sich nur durch Abstimmung mit dem Eigentümer des gemeinsam genutzten Verlaufs garantieren, nicht indem eine untergeordnete Ansicht Gruppen schließt.

Das vollständige SwiftUI-Beispiel erstellen

Erstellen Sie ein macOS-SwiftUI-App-Projekt mit einem Bereitstellungsziel ab macOS 14. Ersetzen Sie die generierte App-Deklaration und die Inhaltsansicht durch die beiden folgenden Dateien. Behalten Sie nur eine @main-Deklaration bei. Das Beispiel verwendet lokale Schaltflächen für Undo und Redo, damit die Zuständigkeit deutlich wird. Die Integration in App-Menüs und die Responder-Kette ist ein separater Integrationsschritt.

Das Modell verwendet einen umkehrbaren Setter: Erfasst wird die aktuelle Position, dann wird eine Closure registriert, die sie wiederherstellt, und anschließend wird der Ersatzwert übernommen. Wird diese Closure während des Undo ausgeführt, registriert derselbe Setter die inverse Operation als Redo. Apple zeigt dieses Muster der wechselseitigen Registrierung unter Registering Undo Operations. Die aktuelle Dokumentation kennzeichnet außerdem UndoManager und dessen Registrierungs-Handler als auf dem Main Actor isoliert; deshalb verwendet das Beispielmodell @MainActor. Siehe UndoManager.

Legen Sie das folgende vollständige Modell in ShapeModel.swift ab:

import Foundation
import Combine

@MainActor
final class ShapeModel: ObservableObject {
    @Published private(set) var center = CGPoint(x: 120, y: 100)
    @Published private(set) var canUndo = false
    @Published private(set) var canRedo = false

    private let history: UndoManager

    init() {
        let manager = UndoManager()
        manager.groupsByEvent = false
        history = manager
    }

    func commitDrag(translation: CGSize) {
        let destination = CGPoint(
            x: center.x + translation.width,
            y: center.y + translation.height
        )
        guard destination != center else { return }

        // This owner expects no caller-owned group.
        precondition(history.groupingLevel == 0)
        history.beginUndoGrouping()
        defer {
            history.endUndoGrouping()
            refreshAvailability()
        }

        replaceCenter(with: destination)
    }

    private func replaceCenter(with destination: CGPoint) {
        let previous = center
        guard previous != destination else { return }

        history.registerUndo(withTarget: self) { target in
            target.replaceCenter(with: previous)
        }
        history.setActionName("Move Shape")
        center = destination
    }

    func undo() {
        precondition(history.groupingLevel == 0)
        guard history.canUndo else { return }
        history.undo()
        refreshAvailability()
    }

    func redo() {
        precondition(history.groupingLevel == 0)
        guard history.canRedo else { return }
        history.redo()
        refreshAvailability()
    }

    private func refreshAvailability() {
        canUndo = history.canUndo
        canRedo = history.canRedo
    }
}

defer befindet sich bewusst im selben synchronen Gültigkeitsbereich wie beginUndoGrouping(). Swift führt zurückgestellte Aktionen aus, wenn dieser Bereich verlassen wird, auch bei einer vorzeitigen Rückgabe oder einem ausgelösten Fehler. Die Aktion kann nicht über separat aufgerufene Gesten-Callbacks hinweg bestehen bleiben. Durch diese Paarung bleibt die Gruppe des Modells bei regulären Kontrollflussenden ausgeglichen. Siehe Kontrollfluss – Zurückgestellte Aktionen.

Die Undo-Closure arbeitet mit ihrem Parameter target, anstatt self zu erfassen. Apple dokumentiert, dass der Manager eine nicht besitzende Referenz auf das Ziel hält, und empfiehlt, den bereitgestellten Parameter zu verwenden, um Aufbewahrungszyklen zu vermeiden. Die Zeichenfläche hält das Modell so lange, wie es verwendet wird, und das Modell besitzt seinen Verlauf. Siehe registerUndo(withTarget:handler:).

Legen Sie die App und die Zeichenfläche in ShapeUndoApp.swift ab:

import SwiftUI

@main
@MainActor
struct ShapeUndoApp: App {
    var body: some Scene {
        WindowGroup {
            ShapeCanvas()
        }
    }
}

@MainActor
struct ShapeCanvas: View {
    @StateObject private var model = ShapeModel()
    @GestureState private var previewTranslation: CGSize? = nil

    private var drag: some Gesture {
        DragGesture(
            minimumDistance: 1,
            coordinateSpace: .named("board")
        )
        .updating($previewTranslation) { value, state, _ in
            state = value.translation
        }
        .onEnded { value in
            model.commitDrag(translation: value.translation)
        }
    }

    var body: some View {
        let translation = previewTranslation ?? .zero
        let isDragging = previewTranslation != nil

        VStack(spacing: 16) {
            ZStack(alignment: .topLeading) {
                Color.clear

                Circle()
                    .fill(Color.blue)
                    .frame(width: 44, height: 44)
                    .position(
                        x: model.center.x + translation.width,
                        y: model.center.y + translation.height
                    )
                    .gesture(drag)
            }
            .frame(width: 420, height: 300)
            .background(Color.gray.opacity(0.12))
            .coordinateSpace(name: "board")
            .clipped()

            HStack {
                Button("Undo") {
                    guard previewTranslation == nil else { return }
                    model.undo()
                }
                .disabled(isDragging || !model.canUndo)

                Button("Redo") {
                    guard previewTranslation == nil else { return }
                    model.redo()
                }
                .disabled(isDragging || !model.canRedo)
            }
        }
        .padding(20)
    }
}

Die Geste verwendet einen benannten Koordinatenraum der Zeichenfläche, sodass die Verschiebung relativ zur Zeichenfläche und nicht zum bewegten Kreis gemessen wird. Apple definiert translation als die gesamte Verschiebung ab dem Start der Geste. Weisen Sie den Wert der Vorschau zu; addieren Sie ihn während der Bewegung nicht wiederholt zum Modell. Siehe DragGesture und translation.

Der optionale Gestenzustand zeigt außerdem an, ob eine Vorschau aktiv ist. Beide lokalen Verlaufs-Schaltflächen sind während dieser Vorschau deaktiviert. Bei erfolgreichem Abschluss liefert der endgültige Gestenwert die zu übernehmende Verschiebung. Bei einem Abbruch wird der Gestenzustand zurückgesetzt und der gespeicherte Mittelpunkt bleibt unverändert. Es gibt keine offene Gruppe, die bereinigt werden müsste.

Dieses Beispiel hat bewusst nur einen Schreiber. Während eines Ziehvorgangs verändert kein anderer Befehl den Kreis. Wenn Sie Tastaturbewegung, Löschen oder weitere Bearbeitungsbefehle hinzufügen, legen Sie fest, ob jeder Befehl während des Ziehens nicht verfügbar ist oder zuerst die Vorschau abbricht. Führen Sie alle Befehle durch diese Richtlinie. Nur diese beiden Schaltflächen zu deaktivieren, steuert keine zusätzlichen Befehlspfade.

Einen Ziehvorgang, ein Undo und ein funktionierendes Redo überprüfen

Verwenden Sie zur Überprüfung der Berechnung beispielhafte Koordinaten. Ausgehend von (120, 100) ergeben die kumulativen Verschiebungen (10, 5), (25, 12) und (40, 20) Vorschau-Mittelpunkte von (130, 105), (145, 112) und (160, 120). Der übernommene Mittelpunkt bleibt bis zum Abschluss bei (120, 100) und ändert sich dann einmalig zu (160, 120).

Undo ruft den registrierten Setter mit (120, 100) auf. Vor der Wiederherstellung erfasst der Setter (160, 120) und registriert diese Position für Redo. Unterdrücken Sie die Registrierung nicht innerhalb des Undo-Callbacks: Diese wechselseitige Registrierung erhält die Redo-Funktion. Apple beschreibt dieses Verhalten unter Registering Undo Operations.

Der Code erstellt innerhalb des umkehrbaren Setters keine zusätzliche explizite Gruppe. UndoManager steuert die Gruppierung während der Wiedergabe des Verlaufs. Laut Apples Dokumentation zeichnet redo() erneut ausgeführte Vorgänge als einzelne Gruppe im Undo-Stapel auf. Der Aktionsname wird über setActionName mit der synthetischen Bezeichnung Move Shape festgelegt. Siehe redo() und setActionName(_:).

Überprüfen Sie in der Mac-App Folgendes:

  1. Schließen Sie einen Ziehvorgang mit mehreren Richtungswechseln ab. Ein Undo sollte die Ausgangsposition wiederherstellen.
  2. Wählen Sie Redo. Die Form sollte wieder an die endgültige Position des abgeschlossenen Ziehvorgangs springen.
  3. Schließen Sie zwei getrennte Ziehvorgänge ab. Zwei Undo-Aktionen sollten die beiden vorherigen Positionen in umgekehrter Reihenfolge wiederherstellen.
  4. Beenden Sie eine erkannte Ziehgeste an der Ausgangsposition. Die Prüfung auf unveränderte Werte sollte keinen Verlaufseintrag erzeugen.
  5. Testen Sie im umgebenden App-Kontext einen Abbruch oder das Entfernen der Ansicht. Die Vorschau sollte verschwinden, ohne dass eine Bewegung übernommen wird.
  6. Machen Sie eine Bewegung rückgängig und schließen Sie dann eine andere Bewegung ab. Der vorherige Redo-Zweig sollte verschwinden. Apple dokumentiert, dass eine neue Undo-Registrierung den Redo-Stapel leert.

Das letzte Verhalten ergibt sich aus Apples Undo-Manager-Architektur. Die obige Koordinatenberechnung wurde mit einer kleinen deterministischen Python-Berechnung geprüft. Der Swift-Quelltext wurde überprüft, aber hier nicht kompiliert oder ausgeführt, da in dieser Umgebung kein Swift-Compiler und kein macOS-SDK verfügbar sind.

Dieselben Grenzen auf eine AppKit-Zeichenfläche übertragen

Behalten Sie bei einer vorhandenen NSView die Ereignisfolge von AppKit bei. Apples aktuelle Referenz definiert mouseDragged(with:) als Bewegung bei gedrückter linker Maustaste und mouseUp(with:) als Loslassen dieser Taste. Der archivierte Ereignisleitfaden beschreibt die drei Methoden umfassende Abfolge und die Koordinatenumrechnung, die eine benutzerdefinierte Ansicht benötigt. Siehe mouseDragged(with:), mouseUp(with:) und Handling Mouse Events.

Übertragen Sie dasselbe Design auf diese Methoden:

  • Führen Sie in mouseDown(with:) einen Treffertest für die synthetische Form durch und speichern Sie ihren Ausgangsmittelpunkt sowie die in Zeichenflächenkoordinaten umgerechnete Position des Zeigers.
  • Berechnen Sie in mouseDragged(with:) die Verschiebung relativ zur gespeicherten Zeigerposition und zeichnen Sie eine vorübergehende Vorschau neu.
  • Berechnen Sie in mouseUp(with:) die endgültige Verschiebung, löschen Sie den Nachverfolgungszustand und rufen Sie die synchrone Commit-Methode des Modells genau einmal auf.
  • Verwerfen Sie im Abbruch- oder Abbaupfad des Zeichenflächeneigentümers die Vorschau und löschen Sie den Nachverfolgungszustand, ohne die Änderung zu übernehmen.

Verwenden Sie für die Interaktion einen einzigen Ereigniseigentümer. Wenn SwiftUI die AppKit-Zeichenfläche einbettet, wählen Sie entweder die SwiftUI-Geste oder die AppKit-Maus-Handler für die Bewegungs- und Commit-Steuerung. Wenn beide dieselbe Bewegung registrieren, entstehen doppelte Vorgänge. Halten Sie die Zeichenkoordinaten konsistent und berücksichtigen Sie dabei etwaige gespiegelte Koordinatensysteme oder Transformationen der Zeichenfläche.

Apples archivierter Leitfaden erklärt außerdem, dass Tastaturbefehle während der Nachverfolgung mit den drei Methoden ausgelöst werden können. Jede Menüintegration muss deshalb dieselbe Richtlinie für aktive Ziehvorgänge wie die lokalen Schaltflächen berücksichtigen. Der Verlauf des Dokuments sollte beim Eigentümer des Dokuments bleiben; stellen Sie außerdem sicher, dass Menüaktionen den Manager dieses Eigentümers ansprechen.

Gruppierungsfehler untersuchen, ohne die Gruppe eines anderen zu schließen

Wenn ein Ziehvorgang viele Undo-Schritte erzeugt, prüfen Sie, ob Modell-Setter während der Bewegungsaktualisierungen Registrierungen vornehmen. Verschieben Sie diese Registrierungen zum endgültigen Commit. Wenn mehrere Ziehvorgänge gemeinsam rückgängig gemacht werden, prüfen Sie die automatische Gruppierungsrichtlinie des Managers und mögliche übergeordnete Gruppen, die vom Eigentümer erstellt wurden. Wenn Redo nach dem Undo nicht verfügbar ist, prüfen Sie, ob der Wiederherstellungspfad den inversen Vorgang registriert hat.

Wenn die Gruppierung eine Ausnahme auslöst, überprüfen Sie die Zuständigkeit, bevor Sie die Anzahl der Aufrufe zum Schließen ändern. Apple dokumentiert, dass groupingLevel == 0 bedeutet, dass keine Gruppe offen ist, und dass undo() eine Ausnahme auslöst, wenn mehr als eine Gruppe offen bleibt. Eine Schleife, die Gruppen schließt, bis die Ebene null erreicht, kann Gruppen schließen, die einem anderen Aufrufer gehören. Verwenden Sie die Ebene als Diagnosewert und halten Sie den Vertrag des Eigentümers ein. Siehe groupingLevel und undo().

Für diese Zeichenfläche im Arbeitsspeicher ist die übernommene Bewegung die entscheidende Grenze: Vorschau frei, einmal übernehmen, eine umkehrbare Änderung registrieren und die Gruppe vor der Rückgabe schließen. So erhält jeder abgeschlossene Ziehvorgang einen Undo-Schritt, während Abbruch, Ereignisverarbeitung und Zuständigkeit für den Verlauf klar geregelt bleiben.

Quellen