Rejestruj operacje cofania w długowiecznym magazynie modelu, a widokom pozwól zgłaszać zmiany za pomocą metod magazynu. W edytorze kształtów na Macu przechowuj podgląd przeciągania lokalnie, zatwierdzaj zmianę raz po zwolnieniu przycisku myszy i rejestruj w magazynie operację odwrotną, która umożliwia także ponowienie.

W tym samouczku zbudujemy niewielką aplikację SwiftUI na macOS, która zawiera jeden przykładowy prostokąt. Można go przesuwać, ukrywać jego edytor, cofać przesunięcie i ponownie wyświetlać edytor. Celem jest zachowanie możliwości cofania, gdy widok edycji znika, a jego model pozostaje aktywny. Wszystkie dane pozostają w pamięci.

Dlaczego magazyn powinien otrzymać operację cofania?

Oparta na domknięciu metoda rejestracji Apple wymaga obiektu typu referencyjnego. Apple dokumentuje też, że menedżer cofania przechowuje niewłasny odnośnik do tego obiektu. Rejestracja działania nie wydłuża więc czasu życia obiektu docelowego. Apple zaleca używanie parametru target procedury obsługi zamiast przechwytywania innego odnośnika do tego samego obiektu. Dokumentacja Apple dotycząca rejestrowania operacji cofania.

Magazyn, który przechowuje edytowany stan, jest odpowiednim obiektem docelowym. Stabilny właściciel utrzymuje magazyn przy życiu, a tymczasowe widoki go wyświetlają i zgłaszają zmiany. W tym przykładzie widok główny okna przechowuje magazyn za pomocą @StateObject, a usuwalny obszar roboczy go obserwuje. SwiftUI zachowuje obiekt stanu przez cały czas życia kontenera, który go deklaruje, ale zmiana tożsamości kontenera może utworzyć nową instancję. Dokumentacja Apple dotycząca StateObject.

Określenie „operacje odwrotne należące do magazynu” opisuje miejsce, w którym znajduje się logika odwracania zmian. Menedżer cofania nadal zapisuje i planuje wywołania zwrotne. Magazyn dostarcza poprzednią wartość i metodę, która ją przywraca. Taki podział jest zgodny z udokumentowaną przez Apple architekturą, w której odbiorcą cofania może być zmieniony obiekt albo jego właściciel. Przewodnik Apple po menedżerze cofania.

Odpowiedzialność Właściciel w tym przykładzie
Zatwierdzona pozycja prostokąta Magazyn modelu
Rejestracja operacji odwrotnej i zmiana stanu Metoda magazynu modelu
Czas życia magazynu Stabilny widok główny okna
Tymczasowe przesunięcie podczas przeciągania Stan gestu obszaru roboczego
Historia cofania i wykonywanie operacji Menedżer cofania z otoczenia
Wybór właściwego menedżera Widok na granicy interakcji

Zaimplementuj odwracalną zmianę w magazynie

Utwórz aplikację SwiftUI na macOS z minimalną wersją systemu macOS 13 lub nowszą i aktualnym SDK Apple. Poniższe trzy pliki tworzą kompletny przykład; zastąp wygenerowany punkt wejścia aplikacji i widok zawartości zamiast pozostawiać drugą deklarację @main. Kompilator Swift i frameworki macOS nie są dostępne w tym środowisku badawczym, więc przykład został przejrzany, ale nie został tu skompilowany.

Utwórz plik ShapeStore.swift. Magazyn przechowuje jedną zatwierdzoną pozycję i udostępnia jedną metodę zmiany. Zachowanie poprzedniej pozycji przed przypisaniem daje metodzie informacje potrzebne do odwrócenia zmiany.

import Foundation
import Combine

@MainActor
final class ShapeStore: ObservableObject {
    @Published private(set) var position = CGPoint(x: 120, y: 100)

    func move(to newPosition: CGPoint, undoManager: UndoManager?) {
        let previousPosition = position
        guard newPosition != previousPosition else { return }

        if let undoManager {
            undoManager.registerUndo(withTarget: self) {
                [weak undoManager] store in
                store.move(
                    to: previousPosition,
                    undoManager: undoManager
                )
            }
            undoManager.setActionName("Move Rectangle")
        }

        position = newPosition
    }
}

Operacja odwrotna wywołuje tę samą metodę. Załóżmy, że pozycja zmienia się z (120, 100) na (160, 100). Zarejestrowane wywołanie zwrotne żąda pozycji (120, 100). Gdy cofanie wykona to wywołanie, metoda zobaczy (160, 100) jako poprzednią wartość i zarejestruje drogę powrotną. Apple dokumentuje, że rejestracje wykonane podczas cofania stają się operacjami ponawiania. Pominięcie rejestracji zawsze, gdy isUndoing ma wartość true, przerwałoby ten mechanizm. Przewodnik Apple po menedżerze cofania.

Domknięcie działa na parametrze store i słabo przechwytuje menedżera. Dzięki temu przechowywane w menedżerze domknięcie nie tworzy do niego silnego odnośnika. Dokumentacja zliczania odwołań w języku Swift wyjaśnia, że słabe odnośniki nie utrzymują wskazanej instancji przy życiu i stają się równe nil, gdy zostanie ona zwolniona. Język programowania Swift: automatyczne zliczanie odwołań.

Magazyn i jego metoda zmiany są izolowane aktorem głównym. Jest to zgodne z aktualną dokumentacją Apple: UndoManager i procedura obsługi rejestracji są izolowane aktorem głównym. Metoda synchronicznie rejestruje operację i zmienia stan, utrzymując tę niewielką zmianę w obrębie wywołującej ją interakcji. Dokumentacja Apple dotycząca UndoManager.

Umieść magazyn nad usuwalnym edytorem

Utwórz plik StoreUndoDemoApp.swift. Widok główny przechowuje magazyn niezależnie od tego, czy obszar roboczy jest widoczny. Przyciski odczytują bieżący menedżer z otoczenia w chwili wykonania działania. Obszar roboczy otrzymuje ten sam menedżer dla swoich zmian.

import SwiftUI

@main
struct StoreUndoDemoApp: App {
    var body: some Scene {
        WindowGroup {
            EditorRoot()
        }
    }
}

@MainActor
struct EditorRoot: View {
    @StateObject private var store = ShapeStore()
    @State private var showsEditor = true
    @Environment(\.undoManager) private var undoManager

    var body: some View {
        VStack(spacing: 16) {
            HStack {
                Button("Move Right") {
                    store.move(
                        to: CGPoint(
                            x: store.position.x + 20,
                            y: store.position.y
                        ),
                        undoManager: undoManager
                    )
                }

                Button("Undo") {
                    if let undoManager, undoManager.canUndo {
                        undoManager.undo()
                    }
                }

                Button("Redo") {
                    if let undoManager, undoManager.canRedo {
                        undoManager.redo()
                    }
                }

                Toggle("Show Editor", isOn: $showsEditor)
            }

            Text(
                "Position: \(Int(store.position.x)), " +
                "\(Int(store.position.y))"
            )

            if showsEditor {
                ShapeCanvas(store: store, undoManager: undoManager)
            } else {
                Text("Editor hidden")
                    .frame(width: 400, height: 260)
            }
        }
        .padding(20)
        .frame(width: 620, height: 380)
    }
}

Menedżer cofania w otoczeniu SwiftUI jest opcjonalny. Apple wyraźnie dopuszcza pominięcie rejestracji, gdy otoczenie udostępnia wartość nil. W takim przypadku ten przykład nadal przesuwa prostokąt, ale nie tworzy wpisu cofania. Jeśli oczekiwana operacja cofania się nie pojawia, sprawdź, czy interakcja otrzymała menedżer, zanim zmienisz logikę operacji odwrotnej. Dokumentacja Apple dotycząca undoManager w EnvironmentValues.

Przekazywanie menedżera do każdej zmiany pozwala też jawnie określić kontekst okna. Magazyn nie zachowuje menedżera okna, który pojawił się jako pierwszy. W tym przykładzie każde okno ma własny magazyn. Jeśli w przyszłości kilka okien będzie edytować jeden współdzielony model, przed ponownym użyciem tego układu w różnych oknach zdecyduj, która historia ma przechowywać te zmiany.

Zatwierdź przeciągnięcie raz, bez rozciągania grupy cofania na wiele zdarzeń

Utwórz plik ShapeCanvas.swift. Podczas przeciągania wyświetlana pozycja prostokąta uwzględnia tymczasowe przesunięcie. Zatwierdzona pozycja w magazynie zmienia się dopiero po pomyślnym zakończeniu gestu. Zakończ przeciąganie przed wywołaniem kolejnej zmiany lub cofnięcia; ten zwięzły przykład zakłada sekwencyjne interakcje i nie rozstrzyga równoczesnych poleceń podczas przeciągania.

import SwiftUI

@MainActor
struct ShapeCanvas: View {
    @ObservedObject var store: ShapeStore
    let undoManager: UndoManager?

    @GestureState private var translation: CGSize = .zero

    var body: some View {
        ZStack(alignment: .topLeading) {
            Color.gray.opacity(0.12)

            Rectangle()
                .fill(Color.blue)
                .frame(width: 70, height: 50)
                .position(
                    x: store.position.x + translation.width,
                    y: store.position.y + translation.height
                )
                .gesture(
                    DragGesture()
                        .updating($translation) { value, state, _ in
                            state = value.translation
                        }
                        .onEnded { value in
                            store.move(
                                to: CGPoint(
                                    x: store.position.x
                                        + value.translation.width,
                                    y: store.position.y
                                        + value.translation.height
                                ),
                                undoManager: undoManager
                            )
                        }
                )
        }
        .frame(width: 400, height: 260)
        .clipped()
    }
}

Gest wykorzystuje końcowe przesunięcie do zatwierdzenia zmiany. Nie zależy od tego, kiedy resetuje się właściwość podglądu. Apple dokumentuje, że updating(_:body:) resetuje stan gestu po jego zakończeniu lub anulowaniu, a onEnded(_:) uruchamia się tylko po pomyślnym zakończeniu. Dlatego anulowany gest w tym rozwiązaniu nie pozostawia zatwierdzonego przesunięcia ani nowej operacji cofania. Dokumentacja Apple dotycząca aktualizowania gestu, Dokumentacja Apple dotycząca zakończenia gestu.

Pozostają tu trzy odrębne granice: aktualizacja podglądu zmienia tymczasowy stan wyświetlania; pomyślne zakończenie zgłasza jedną zmianę modelu; magazyn rejestruje operację odwrotną do tej zmiany. Wywoływanie zmiany w magazynie przy każdej aktualizacji przeciągania rejestrowałoby kolejne pozycje pośrednie podczas gestu.

Domyślne grupowanie zdarzeń w Apple łączy rejestracje wykonywane w ramach kolejnych przebiegów pętli zdarzeń. Przeciąganie może generować aktualizacje w wielu przebiegach, więc samo grupowanie zdarzeń nie wyraża zamierzonej transakcji obejmującej całe przeciągnięcie. Jednorazowe zatwierdzenie rozwiązuje ten problem bez zmieniania konfiguracji współdzielonego menedżera. Dokumentacja Apple dotycząca groupsByEvent.

W przypadku przyszłego polecenia, które synchronicznie zmienia kilka elementów, można użyć jawnej, poprawnie zamykanej grupy cofania do zebrania operacji odwrotnych. Jej otwarcie i zamknięcie powinny nastąpić w ramach tego polecenia. Jeśli podczas przeciągania konieczna będzie bezpośrednia zmiana modelu, zaprojektuj osobne operacje rozpoczęcia, aktualizacji, zatwierdzenia i anulowania, uwzględniając sposób interakcji innych poleceń z aktywną zmianą. Zastosowane tu podejście z podglądem celowo nie dodaje tej dodatkowej obsługi transakcji do magazynu.

Osobno sprawdź własność, ponawianie i routing okien

Wykonaj poniższe kroki w uruchomionej aplikacji. Współrzędne są przykładowymi wartościami oczekiwanymi na podstawie podanego stanu początkowego i przyrostu przycisku; nie są wynikami pomiarów.

Działanie Oczekiwana zatwierdzona pozycja lub wynik
Otwórz nowe okno (120, 100)
Kliknij Move Right (140, 100)
Wyłącz Show Editor Obszar roboczy znika; pozycja pozostaje (140, 100)
Kliknij Undo Pozycja zmienia się na (120, 100), gdy obszar roboczy pozostaje ukryty
Kliknij Redo Pozycja zmienia się na (140, 100)
Włącz Show Editor Prostokąt pojawia się w ponowionej pozycji
Zakończ przeciąganie, a następnie kliknij Undo Prostokąt wraca do pozycji sprzed przeciągnięcia

Sprawdzenie ukrytego edytora odróżnia własność modelu od czasu życia prezentacji. Jeśli cofanie działa tylko wtedy, gdy określony panel istnieje, sprawdź obiekt docelowy zarejestrowany w menedżerze i obiekt, który go utrzymuje. Jeśli cofanie działa, ale ponawianie nie, sprawdź, czy operacja odwrotna rejestruje operację powrotną. Jeśli do cofnięcia jednego przeciągnięcia potrzeba wielu operacji, sprawdź, czy aktualizacje podglądu trafiają do operacji cofania magazynu.

Jawne przyciski ułatwiają prześledzenie ścieżki zmiany. Aby uzyskać standardowe działanie menu na Macu, przetestuj też menu Edit, gdy aktywne jest właściwe okno. Podstawowy obiekt AppKit z łańcucha responderów pobiera menedżera cofania od kolejnego respondera. Gdy okno prosi swojego delegata o menedżera, udokumentowanym punktem dostosowania jest windowWillReturnUndoManager(_:); jeśli ta metoda nie jest zaimplementowana, okno tworzy własnego menedżera. Rejestracja operacji w niezwiązanym menedżerze nie zapewnia zamierzonego routingu przez łańcuch responderów. Dokumentacja Apple dotycząca undoManager w NSResponder, Dokumentacja Apple dotycząca delegata menedżera cofania okna.

Magazyn wymaga też określonej granicy zwalniania. Jeśli zastąpisz magazyn, gdy jego menedżer nadal istnieje, usuń rejestracje tego magazynu przed jego zwolnieniem, używając removeAllActions(withTarget:) na każdym menedżerze, który przechowuje jego działania. Nie czyść historii tylko dlatego, że obszar roboczy znika: celem jest właśnie zachowanie dostępności zmian magazynu podczas jego zniknięcia. Dokumentacja Apple dotycząca usuwania operacji cofania dla konkretnego obiektu docelowego.

Ten przykład zawiera jeden nieruchomy prostokąt i nie ma ścieżki usuwania. Rozszerzając go na kolekcję, przechowuj stabilne identyfikatory elementów i wartości potrzebne do odwrócenia każdej operacji. Operacja odwrotna usunięcia musi umieć przywrócić element i jego zamierzoną kolejność; sam poprzedni indeks tablicy nie wystarczy, jeśli kolekcja może się zmienić.

W ukończonym rozwiązaniu widok główny odpowiada za czas życia, obszar roboczy za tymczasowy stan interakcji, a magazyn za odwracalne zmiany. Ukrycie obszaru roboczego nie odbiera dostępu do tych zmian, ponieważ ich metody odwrotne działają na nadal aktywnym magazynie.

Źródła