W przypadku niewielkiego edytora kształtów na Maca, działającego w pamięci, zacznij od migawek stanu opartego na wartościach. Używaj poleceń odwrotnych przy częstych, wąsko zakrojonych edycjach, gdy mniejszy rozmiar danych uzasadnia dodatkową logikę. Oba podejścia wymagają jednego stabilnego właściciela i wyraźnej granicy zatwierdzenia każdej czynności użytkownika.

Ten poradnik dotyczy jednego zadania: implementacji przewidywalnego cofania i ponawiania przesuwania, usuwania i zmiany kolejności syntetycznych kształtów we własnym edytorze Swift. Poniższe ramy decyzyjne to zalecenie inżynierskie opracowane na podstawie interfejsów API Apple; Apple nie zaleca uniwersalnego wyboru między migawkami a poleceniami.

Co powinna zapamiętywać każda operacja cofania?

Migawka zapisuje poprzedni stan edytowalny, na przykład uporządkowaną tablicę kształtów. Cofnięcie przywraca tę tablicę. Polecenie odwrotne zapisuje operację potrzebną do odwrócenia konkretnej edycji, na przykład ustawienie środka jednego kształtu z powrotem na poprzednich współrzędnych.

Obie reprezentacje działają za pośrednictwem UndoManager z Foundation. Rejestruje on operacje dla obiektu lub jego właściciela; nie obserwuje automatycznie dowolnych przypisań do modelu. Metody modyfikujące muszą rejestrować odpowiednie działania przywracające. UndoManager

Zdefiniuj stan edytowalny, zanim wybierzesz jego reprezentację w historii. W tym edytorze obejmuje on identyfikatory kształtów, współrzędne i kolejność w tablicy. Wskaźniki najechania i podglądy przeciągania trzymaj poza nim. Zdecyduj wprost, czy historia ma obejmować zaznaczenie: przywrócenie zaznaczenia może ułatwić zrozumienie usunięcia, ale oznacza też, że cofnięcie zmienia bieżące zaznaczenie.

Dla każdej operacji korzystaj z poniższej tabeli decyzyjnej, zamiast wymagać, by cały edytor używał jednej reprezentacji:

Operacja lub warunek Sugerowana reprezentacja Informacje do zachowania
Małe płótno; kilka właściwości zmienia się jednocześnie Migawka Pełna poprzednia wartość edytowalna
Przesunięcie jednego kształtu Ustawienie odwrotne Stabilny identyfikator kształtu i poprzedni środek
Usunięcie jednego kształtu Początkowo migawka; w razie potrzeby polecenie odwrotne Dla polecenia odwrotnego: cały usunięty kształt i jego pierwotna pozycja
Zmiana kolejności wielu kształtów Migawka, chyba że polecenie odwrotne jest proste Poprzednia kolejność i wszelkie zmienione właściwości
Zmierzony koszt pamięci historii jest nadmierny Ukierunkowane polecenia odwrotne Wszystko, co jest potrzebne do dokładnego przywrócenia

Warto zapytać: czy potrafisz wymienić wszystko, co musi przywrócić polecenie odwrotne? Jeśli usunięcie kształtu zmienia również zaznaczenie i kolejność, zapamiętanie samego identyfikatora usuniętego kształtu nie wystarczy. Migawkę często łatwiej skontrolować, gdy operacja wpływa na kilka powiązanych wartości.

Kiedy migawka jest rzeczywiście niezależna?

Struktury i tablice w Swift mają semantykę wartości. Tablica kształtów zbudowana wyłącznie z typów wartości może więc reprezentować wcześniejszy stan niezależnie od późniejszych modyfikacji. Kopiowanie tablicy instancji klas zachowuje jednak odwołania do tych instancji; zmiana instancji może zmienić również to, co obserwuje domniemana migawka. Opakowanie tych odwołań w strukturę nie usuwa tego problemu. Typy wartości i typy referencyjne w Swift

W małym edytorze używaj do historii zwykłych danych wartościowych: identyfikatorów, współrzędnych, wymiarów i innych atrybutów edytowalnych. Widoki i mutowalne obiekty renderujące trzymaj poza migawką. Jeśli model na żywo korzysta z klas, utwórz osobną reprezentację wartościową zawierającą wszystkie atrybuty potrzebne do jego przywrócenia.

Nie zakładaj stałego kosztu pamięci na podstawie samego słowa „migawka”. Zmierz pamięć zajmowaną przez zachowaną historię i opóźnienia edycji na reprezentatywnych syntetycznych płótnach. Przykładowe wyliczenie rozmiaru danych: 100 wpisów, z których każdy zachowuje 1 000 rekordów kształtów, oznacza 100 000 wystąpień rekordów; 100 wpisów dotyczących odwrotnych operacji na pojedynczym kształcie zachowuje tylko 100 poprzednich środków wraz z identyfikatorami. Te liczby nie określają zmierzonego rozmiaru w bajtach ani nie są twierdzeniem o alokacji pamięci przez Swift.

Ustal świadomie głębokość historii. Apple dokumentuje, że levelsOfUndo ogranicza liczbę grup cofania najwyższego poziomu, a domyślna wartość zero oznacza brak limitu. Limit grup jest przydatny, ale nie nakłada budżetu bajtów: migawka partii i edycja jednej współrzędnej mogą zachowywać bardzo różne ilości danych. levelsOfUndo

Jak migawki i polecenia odwrotne zachowują możliwość ponawiania?

Metoda przywracania powinna zarejestrować odwrotne działanie przywracające przed zmianą modelu. Gdy cofnięcie wywoła tę metodę, jej rejestracja umożliwi ponowienie. W przypadku migawki przed przywróceniem poprzedniego stanu zapisz stan aktualnie zainstalowany. W przypadku polecenia pozycji przed ustawieniem zapamiętanego środka zapisz środek bieżący.

Zarchiwizowany przewodnik Apple po architekturze cofania jasno opisuje ten wzorzec wzajemnej rejestracji: ustawienie wywołane podczas cofania rejestruje wartość potrzebną do ponowienia. Przykłady używają Objective-C; pełny przykład w Swift poniżej korzysta z aktualnego interfejsu API opartego na domknięciach. Rejestrowanie operacji cofania

Nie pomijaj rejestracji tylko dlatego, że isUndoing ma wartość true. W tym projekcie usunęłoby to rejestrację tworzącą możliwość ponowienia. Unikaj też odtwarzania nowego ruchu względnego podczas przywracania pozycji: zapisanie dokładnego poprzedniego środka jasno określa zamierzone przywrócenie.

Pełny przykład na poziomie modelu

Ten samodzielny program dla macOS łączy usuwanie oparte na migawce z ustawieniem pozycji za pomocą polecenia odwrotnego. Korzysta wyłącznie z syntetycznych wartości przechowywanych w pamięci i z Foundation, bez zależności od trwałego przechowywania danych ani interfejsu użytkownika. Zapisz go jako EditorUndo.swift i skompiluj przy użyciu aktualnego toolchaina Swift 6 oraz SDK macOS:

swiftc -parse-as-library EditorUndo.swift -o EditorUndo
./EditorUndo

Program celowo korzysta z dedykowanego menedżera i wyłącza grupowanie zdarzeń, aby jego asercje nie zależały od działającej pętli zdarzeń aplikacji. Każda początkowa edycja otrzymuje jawną grupę. Metody przywracania nie otwierają dodatkowych grup podczas cofania ani ponawiania. Apple wymaga jawnego zamknięcia grup przed cofnięciem, gdy automatyczne grupowanie jest wyłączone. groupsByEvent

import Foundation

struct Center: Equatable {
    var x: Double
    var y: Double
}

struct Item: Equatable {
    let id: Int
    var center: Center
}

@MainActor
final class Editor {
    private(set) var items: [Item]
    let history: UndoManager

    init(items: [Item], history: UndoManager) {
        precondition(Set(items.map(\.id)).count == items.count)
        self.items = items
        self.history = history
    }

    func replaceItems(_ next: [Item], name: String) {
        precondition(Set(next.map(\.id)).count == next.count)
        guard next != items else { return }
        let previous = items
        history.registerUndo(withTarget: self) { target in
            target.replaceItems(previous, name: name)
        }
        items = next
        history.setActionName(name)
    }

    func setCenter(id: Int, to next: Center) {
        guard let index = items.firstIndex(where: { $0.id == id }) else {
            preconditionFailure("History refers to a missing item")
        }
        let previous = items[index].center
        guard previous != next else { return }
        history.registerUndo(withTarget: self) { target in
            target.setCenter(id: id, to: previous)
        }
        items[index].center = next
        history.setActionName("Move Shape")
    }
}

@main
struct Demo {
    @MainActor
    static func main() {
        let history = UndoManager()
        history.groupsByEvent = false
        history.levelsOfUndo = 100

        let original = [
            Item(id: 1, center: Center(x: 20, y: 30)),
            Item(id: 2, center: Center(x: 80, y: 30))
        ]
        let editor = Editor(items: original, history: history)

        func edit(_ operation: () -> Void) {
            history.beginUndoGrouping()
            defer { history.endUndoGrouping() }
            operation()
        }

        edit {
            editor.setCenter(id: 1, to: Center(x: 50, y: 40))
        }
        let moved = editor.items

        edit {
            editor.replaceItems(
                editor.items.filter { $0.id != 2 },
                name: "Delete Shape"
            )
        }
        let deleted = editor.items

        history.undo()
        precondition(editor.items == moved)
        history.undo()
        precondition(editor.items == original)
        history.redo()
        precondition(editor.items == moved)
        history.redo()
        precondition(editor.items == deleted)
        print("Undo and redo checks passed")
    }
}

Oczekiwany wynik to Undo and redo checks passed. Przykład został sprawdzony, ale nie został tutaj uruchomiony, ponieważ w środowisku badawczym nie było dostępnego kompilatora Swift. Pokazuje podstawową obsługę historii; interfejs SwiftUI wymaga również obserwowalnego modelu lub adaptera, aby odświeżać widoki.

Domknięcie korzysta z parametru target, zamiast bezpośrednio przechwytywać edytor. Apple dokumentuje, że cel rejestracji musi być typem referencyjnym, menedżer przechowuje do niego niezachowywane odwołanie, a użycie parametru domknięcia pozwala uniknąć cyklu referencji poprzez przechwycony cel. registerUndo(withTarget:handler:)

Warunki wstępne ujawniają błędne założenia modelu, zamiast po cichu pomijać operację cofania. Granica polecenia w produkcyjnym rozwiązaniu powinna odrzucać nieprawidłową edycję przed rejestracją i zgłaszać odpowiedni błąd na poziomie aplikacji. Brakujące identyfikatory podczas odtwarzania historii wskazują, że coś zmieniło model poza oczekiwaną sekwencją historii.

Jak przeciąganie powinno stać się jedną czynnością możliwą do cofnięcia?

Przeciąganie generuje wiele aktualizacji, a użytkownik oczekuje, że jedno zakończone przesunięcie będzie można cofnąć jako całość. Automatyczne grupowanie Apple podąża za cyklami pętli zdarzeń, więc nie można traktować go jako definicji całego gestu. Jawne grupy mogą łączyć rejestracje, ale ich granice nadal wymagają świadomego zarządzania. groupsByEvent, beginUndoGrouping()

W tym edytorze zastosuj sekwencję podglądu i zatwierdzenia:

  1. Po rozpoczęciu przeciągania zachowaj identyfikator celu i zatwierdzony środek początkowy jako punkt odniesienia dla gestu.
  2. Użyj @GestureState i updating(_:body:) do tymczasowego przesunięcia. Renderuj kształt w pozycji równej punktowi odniesienia plus to przesunięcie, nie zmieniając zatwierdzonych elementów.
  3. Po pomyślnym zakończeniu przez onEnded(_:) oblicz końcowy środek na podstawie końcowej wartości gestu i jednokrotnie wywołaj metodę modelu ustawiającą pozycję z obsługą cofania.
  4. Pomiń rejestrację, jeśli wynikowy środek jest równy zatwierdzonemu środkowi.
  5. W przypadku anulowania odrzuć podgląd i zachowaj zatwierdzony model.

Ta sekwencja odpowiada udokumentowanemu przez SwiftUI rozróżnieniu: stan gestu resetuje się po zakończeniu lub anulowaniu gestu, a onEnded(_:) uruchamia się tylko po pomyślnym zakończeniu. W związku z tym sprzątanie, które musi nastąpić również po anulowaniu, nie może zależeć wyłącznie od onEnded(_:). Dodawanie interaktywności za pomocą gestów

Podczas podglądu utrzymuj stabilny stan zatwierdzony edytora. Jedna prosta zasada to wyłączenie innych modyfikacji płótna i cofania na płótnie, gdy przeciąganie jest aktywne, a następnie ponowne ich włączenie po zakończeniu lub anulowaniu. W przeciwnym razie określ, w jaki sposób edycja wykonana w międzyczasie unieważnia zapisany punkt odniesienia przed zatwierdzeniem przeciągnięcia.

Jeśli inna część edytora musi stale obserwować aktualizowane współrzędne zatwierdzone, renderowanie wyłącznie podglądu może się nie sprawdzić. W takim przypadku zaimplementuj jawną transakcję gestu: zapisz stan początkowy, stosuj aktualizacje pośrednie bez osobnych wpisów historii i zarejestruj jedno przywrócenie po pomyślnym zakończeniu. Anulowanie musi przywrócić stan początkowy. Nie pozostawiaj otwartej współdzielonej grupy cofania między dowolnymi wywołaniami zwrotnymi ani operacjami asynchronicznymi.

Kto powinien być właścicielem modelu i menedżera cofania?

Dla każdego niezależnego edytowalnego płótna zachowaj jednego stabilnego właściciela edytora. Jest on właścicielem zatwierdzonych elementów i celem operacji historii. Zarówno modyfikacje modelu, jak i interakcje z menedżerem cofania wykonuj na aktorze głównym; aktualna dokumentacja Apple oznacza UndoManager i jego obsługę domknięcia jako izolowane na aktorze głównym. UndoManager

W SwiftUI pobierz menedżer widoku za pomocą @Environment(\.undoManager). Przekaż go przez stabilną warstwę edytora lub poleceń, zamiast tworzyć nowy menedżer przy każdym obliczeniu widoku. Wartość środowiskowa jest opcjonalna: Apple dokumentuje, że w nieobsługiwanych kontekstach zwraca nil i zezwala na pominięcie rejestracji cofania. Edycja nadal może być możliwa, ale taka ścieżka nie powinna informować, że cofanie jest dostępne. EnvironmentValues.undoManager

W przypadku płótna AppKit wyszukaj menedżera przez odpowiedniego respondera. Podstawowa implementacja NSResponder.undoManager pyta następnego respondera. Dlatego prywatnie utworzony menedżer wymaga jawnej integracji z kontekstem responderów płótna, jeśli standardowe polecenia menu mają do niego docierać. Samo przechowywanie go w modelu nie tworzy takiego połączenia. NSResponder.undoManager

Konfiguracja grupowania w samodzielnym przykładzie dotyczy jego dedykowanego menedżera. Nie kopiuj tej konfiguracji do menedżera przekazanego przez środowisko, jeśli nie zarządzasz zasadami grupowania wszystkich uczestników. W interfejsie zarejestruj zakończoną edycję płótna, korzystając z istniejących zasad obsługi zdarzeń danego kontekstu; grupuj kilka synchronicznych podoperacji, gdy składają się na jedną czynność.

Zanim usuniesz cel edytora, usuń jego oczekujące rejestracje w kontrolowanym punkcie cyklu życia. Apple udostępnia removeAllActions(withTarget:) do usuwania operacji cofania i ponawiania przypisanych do tego celu. Wyczyszczenie całego menedżera wpłynęłoby również na innych uczestników, jeśli menedżer jest współdzielony. removeAllActions(withTarget:)

Co należy sprawdzić przed wyborem ostatecznego projektu?

Przećwicz krótką sekwencję przekraczającą granice reprezentacji: przesuń kształt, usuń inny, cofnij obie czynności, a następnie ponów obie. Po każdym kroku sprawdzaj wartości, identyfikatory i kolejność. Dodaj anulowane przeciągnięcie, przeciągnięcie zakończone w pozycji początkowej i dwa niezależne okna edytora. Każdy przypadek sprawdza inną granicę: dane przywracania, zakończenie gestu, obsługę operacji pustej lub własność.

Traktuj masową podmianę płótna jako jawną operację. Zdecyduj, czy ma być możliwa do cofnięcia z wystarczającym stanem przywracania, czy po niej ma powstać nowy punkt odniesienia przez celowe wyczyszczenie nieaktualnej historii. Migawka pomijająca edytowalne pola i polecenie odwrotne, które nie potrafi znaleźć celu, to niekompletne implementacje.

Zacznij od migawek dla małego płótna opartego na wartościach, zachowaj jednego stabilnego właściciela i zatwierdzaj każdy gest jednokrotnie. Wprowadź polecenia odwrotne tam, gdzie dane przywracania są jasne, a pomiary uzasadniają dodatkową logikę. Dzięki temu można zmienić reprezentację historii bez zmiany znaczenia pojedynczej czynności użytkownika.

Źródła