Gdy kształt jest przeciągany w aplikacji na Maca, pokazuj podgląd ruchu podczas gestu, a końcowe położenie zatwierdź w jednej krótkiej grupie cofania. Model i menedżer cofania powinny mieć jednego właściciela; każde otwarcie grupy musi mieć odpowiadające mu zamknięcie, a operację odwrotną należy zarejestrować, aby działało też ponawianie.
W tym samouczku utworzymy małe płótno SwiftUI z jednym syntetycznym okręgiem przechowywanym w pamięci. Celem jest sprawienie, by jedno zakończone przeciągnięcie wymagało dokładnie jednego cofnięcia, niezależnie od liczby odebranych aktualizacji położenia. Wyjaśnimy też, jak zachować te same granice w widoku AppKit.
Dlaczego grupa cofania nie powinna pozostawać otwarta podczas przeciągania
Przeciąganie to interakcja rozłożona w czasie. Grupa cofania zbiera odwracalne operacje. Te granice nie muszą mieć jednakowego czasu trwania. Apple dokumentuje, że groupsByEvent domyślnie ma wartość true i grupuje operacje w ramach przebiegów pętli zdarzeń; takie działanie nie definiuje kompletnej transakcji przeciągania. Jawne grupy mogą też być zagnieżdżane w istniejących grupach. Zobacz dokumentację Apple dotyczącą automatycznego grupowania zdarzeń i rozpoczynania grup cofania.
Otwarcie grupy w pierwszym wywołaniu zwrotnym SwiftUI aktualizującym położenie i zamknięcie jej w onEnded pozostawia lukę na wypadek anulowania. Apple wyraźnie stwierdza, że onEnded jest wywoływane tylko po pomyślnym zakończeniu gestu. Natomiast stan utrzymywany przez updating jest resetowany po zakończeniu lub anulowaniu gestu. Niezrównoważone endUndoGrouping() zgłasza wyjątek, dlatego zgadywanie, czy wywołanie zwrotne otworzyło grupę, również nie jest właściwe. Zobacz onEnded(_:), updating(_:body:) i endUndoGrouping().
W przypadku tego płótna zastosujemy poniższy projekt. To wybór implementacyjny wynikający z udokumentowanych granic:
| Etap interakcji | Stan wyświetlany | Zatwierdzony model | Grupowanie cofania |
|---|---|---|---|
| Przed przeciąganiem | Zapisany środek | Pierwotny środek | Zamknięte |
| Aktualizacje położenia | Pierwotny środek plus przejściowe przesunięcie | Bez zmian | Zamknięte |
| Pomyślne zakończenie | Końcowy środek | Zaktualizowany jednokrotnie | Otwierane i zamykane synchronicznie |
| Anulowanie | Podgląd resetowany | Bez zmian | Nigdy nieotwierane |
| Cofanie lub ponawianie | Przywrócony środek | Aktualizowany przez odwracalny setter | Obsługiwane przez UndoManager |
Dzięki temu okrąg może poruszać się płynnie, a każda klatka podglądu nie staje się trwałą zmianą. Zakończone przeciągnięcie tworzy jedną nazwaną operację modelu.
Wybierz właściciela historii cofania
W przykładzie każde płótno ma własny model i prywatny menedżer cofania. Ponieważ ten właściciel kontroluje każdą rejestrację, podczas inicjalizacji jednokrotnie ustawia groupsByEvent = false i jawnie grupuje każdy zatwierdzony ruch. Apple wymaga jawnego zamknięcia grup przed cofnięciem, gdy automatyczne grupowanie jest wyłączone. Przykład zamyka grupę przed powrotem z metody zatwierdzającej. Zobacz groupsByEvent.
Istniejący edytor dokumentów może już korzystać z menedżera cofania dokumentu. SwiftUI udostępnia kontekstowy menedżer przez @Environment(\.undoManager), a Apple dokumentuje, że jego wartością może być nil, jeśli kontekst nie obsługuje cofania. W takiej architekturze nadal korzystaj z menedżera dokumentu i ustalonej dla niego polityki grupowania; nie przenoś konfiguracji prywatnego menedżera z tego przykładu na współdzielony menedżer. Zobacz EnvironmentValues.undoManager.
Przy współdzielonym menedżerze z włączonym automatycznym grupowaniem rejestracja jednej operacji odwrotnej po zakończeniu nadal zapobiega tworzeniu osobnych wpisów historii dla kolejnych aktualizacji położenia. Jawna podgrupa może zebrać ruch w całość, ale nie gwarantuje niezależności od grupy nadrzędnej. Jeśli inne zmiany należą do tej samej grupy nadrzędnej, mogą zostać cofnięte razem. Zagwarantowanie niezależnej transakcji najwyższego poziomu wymaga koordynacji ze strony właściciela współdzielonej historii, a nie zamykania grup przez widok podrzędny.
Utwórz kompletny przykład SwiftUI
Utwórz projekt aplikacji SwiftUI na macOS z docelową wersją macOS 14 lub nowszą. Zastąp wygenerowaną deklarację aplikacji i widok zawartości dwoma poniższymi plikami. Pozostaw tylko jedną deklarację @main. Przykład używa lokalnych przycisków Undo i Redo, aby jasno określić właściciela; integracja z menu aplikacji i łańcuchem responderów to osobny krok.
Model używa odwracalnego settera: zapisuje bieżące położenie, rejestruje domknięcie, które je przywraca, a następnie stosuje nową wartość. Gdy to domknięcie zostaje wywołane podczas cofania, ten sam setter rejestruje operację odwrotną jako ponowienie. Apple pokazuje ten wzorzec wzajemnej rejestracji w dokumencie Registering Undo Operations. Aktualna dokumentacja oznacza również UndoManager i jego handler rejestracji jako izolowane do głównego aktora, dlatego model w przykładzie używa @MainActor. Zobacz UndoManager.
Umieść poniższy kompletny model w pliku ShapeModel.swift:
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 znajduje się celowo w tym samym synchronicznym zakresie co beginUndoGrouping(). Swift wykonuje odroczone działania przy wychodzeniu z tego zakresu, także przy wczesnym powrocie lub zgłoszeniu błędu. Nie można rozciągnąć ich na osobne wywołania zwrotne gestu. Ta para zapewnia zrównoważenie grupy modelu przy zwykłych ścieżkach sterowania. Zobacz Sterowanie przepływem — działania odroczone.
Domknięcie cofania działa na parametrze target, zamiast przechwytywać self. Apple dokumentuje, że menedżer przechowuje niewłasną referencję do celu i zaleca używanie przekazanego parametru, by uniknąć cykli referencji. Płótno utrzymuje model przy życiu podczas używania, a model jest właścicielem swojej historii. Zobacz registerUndo(withTarget:handler:).
Umieść aplikację i płótno w pliku ShapeUndoApp.swift:
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)
}
}
Gest korzysta z jednej nazwanej przestrzeni współrzędnych płótna, dzięki czemu przesunięcie jest mierzone względem planszy, a nie poruszającego się okręgu. Apple definiuje translation jako całkowite przemieszczenie od początku gestu. Przypisz tę wartość do podglądu; nie dodawaj jej wielokrotnie do modelu podczas ruchu. Zobacz DragGesture i translation.
Opcjonalny stan gestu wskazuje też, czy podgląd jest aktywny. Oba lokalne przyciski historii są wtedy wyłączone. Po pomyślnym zakończeniu gestu końcowa wartość gestu dostarcza zatwierdzane przesunięcie. Po anulowaniu stan gestu jest resetowany, a zapisany środek pozostaje bez zmian; nie ma otwartej grupy wymagającej sprzątania.
Przykład celowo ma tylko jednego autora zmian. Żadne inne polecenie nie zmienia okręgu podczas przeciągania. Jeśli dodasz przesuwanie klawiaturą, usuwanie lub inne polecenia edycji, ustal, czy każde z nich jest niedostępne podczas przeciągania, czy najpierw anuluje podgląd. Wszystkie polecenia muszą stosować tę politykę; samo wyłączenie tych dwóch przycisków nie obejmie dodatkowych ścieżek poleceń.
Sprawdź jedno przeciągnięcie, jedno cofnięcie i działające ponawianie
Prześledź rachunek na przykładowych współrzędnych. Począwszy od (120, 100), kolejne przesunięcia (10, 5), (25, 12) i (40, 20) dają środki podglądu (130, 105), (145, 112) i (160, 120). Zatwierdzony środek pozostaje równy (120, 100) aż do zakończenia gestu, a potem zmienia się jednokrotnie na (160, 120).
Cofnięcie wywołuje zarejestrowany setter z wartością (120, 100). Przed jej przywróceniem setter zapisuje (160, 120) i rejestruje to położenie do ponowienia. Nie wyłączaj rejestracji w wywołaniu zwrotnym cofania: to właśnie wzajemna rejestracja zachowuje możliwość ponowienia. Apple opisuje to działanie w dokumencie Registering Undo Operations.
Kod nie tworzy dodatkowej jawnej grupy wewnątrz odwracalnego settera. UndoManager kontroluje grupowanie podczas odtwarzania historii; Apple dokumentuje, że redo() zapisuje odtwarzane operacje na stosie cofania jako jedną grupę. Nazwa czynności jest przypisywana przez setActionName z użyciem syntetycznej etykiety Move Shape. Zobacz redo() i setActionName(_:).
Sprawdź te zachowania w aplikacji na Maca:
- Zakończ jedno przeciągnięcie z kilkoma zmianami kierunku. Jedno
Undopowinno przywrócić pozycję początkową. - Wybierz
Redo. Kształt powinien wrócić do końcowego położenia po przeciągnięciu. - Zakończ dwa osobne przeciągnięcia. Dwie operacje cofania powinny przywrócić wcześniejsze położenia w odwrotnej kolejności.
- Zakończ rozpoznany gest przeciągania w pozycji początkowej. Zabezpieczenie przed zmianą nie powinno utworzyć wpisu historii.
- Sprawdź ścieżkę anulowania lub usunięcia widoku w aplikacji nadrzędnej. Podgląd powinien zniknąć bez zatwierdzenia ruchu.
- Cofnij ruch, a następnie wykonaj inne przeciągnięcie. Poprzednia gałąź ponawiania powinna zniknąć; Apple dokumentuje, że nowa rejestracja cofania czyści stos ponawiania.
Ostatnie zachowanie wynika z dokumentu Apple Undo Manager architecture. Powyższe obliczenie współrzędnych sprawdzono za pomocą małego, deterministycznego skryptu w Pythonie. Kod źródłowy w Swift został przejrzany, ale nie skompilowano go ani nie uruchomiono, ponieważ w tym środowisku nie ma kompilatora Swift ani SDK systemu macOS.
Zastosuj te same granice na płótnie AppKit
W istniejącym NSView zachowaj sekwencję zdarzeń AppKit. Aktualna dokumentacja Apple definiuje mouseDragged(with:) jako ruch przy wciśniętym lewym przycisku myszy, a mouseUp(with:) jako zwolnienie tego przycisku. Zarchiwizowany przewodnik po zdarzeniach opisuje sekwencję trzech metod i konwersję współrzędnych potrzebną widokowi niestandardowemu. Zobacz mouseDragged(with:), mouseUp(with:) i Obsługa zdarzeń myszy.
Zastosuj ten sam projekt do tych metod:
- W
mouseDown(with:)wykonaj test trafienia syntetycznego kształtu i zapisz jego początkowy środek oraz położenie wskaźnika przeliczone na współrzędne płótna. - W
mouseDragged(with:)oblicz przesunięcie względem zapisanego położenia wskaźnika i narysuj przejściowy podgląd. - W
mouseUp(with:)oblicz końcowe przesunięcie, wyczyść stan śledzenia i jednokrotnie wywołaj synchroniczną metodę zatwierdzania modelu. - W ścieżce anulowania lub kończenia działania właściciela płótna odrzuć podgląd i wyczyść stan śledzenia bez zatwierdzania.
Do obsługi interakcji używaj jednego właściciela zdarzeń. Jeśli SwiftUI osadza płótno AppKit, wybierz gest SwiftUI albo obsługę zdarzeń myszy AppKit jako mechanizm sterujący ruchem i zatwierdzeniem. Rejestrowanie tego samego ruchu przez oba mechanizmy utworzyłoby zduplikowane operacje. Zachowaj spójność współrzędnych rysowania, uwzględniając odwrócony układ współrzędnych lub transformację płótna.
Zarchiwizowany przewodnik Apple wyjaśnia też, że podczas obsługi zdarzeń metodą trzech wywołań mogą być wysyłane polecenia klawiaturowe. Integracja z menu musi zatem respektować tę samą politykę aktywnego przeciągania co lokalne przyciski. Historię dokumentu pozostaw u właściciela dokumentu i zadbaj, by działania menu korzystały z menedżera należącego do tego właściciela.
Diagnozuj błędy grupowania bez zamykania cudzej grupy
Jeśli przeciągnięcie tworzy wiele kroków cofania, sprawdź, czy settery modelu rejestrują zmiany podczas aktualizacji położenia. Przenieś te rejestracje do końcowego zatwierdzenia. Jeśli kilka przeciągnięć jest cofanych razem, sprawdź politykę automatycznego grupowania menedżera i każdą grupę nadrzędną utworzoną przez właściciela. Jeśli po cofnięciu nie można ponowić operacji, sprawdź, czy ścieżka przywracania zarejestrowała operację odwrotną.
Jeśli grupowanie zgłasza wyjątek, sprawdź właściciela przed zmianą liczby wywołań zamykających grupę. Apple dokumentuje, że groupingLevel == 0 oznacza brak otwartej grupy, a undo() zgłasza wyjątek, gdy pozostaje otwarta więcej niż jedna grupa. Pętla zamykająca grupy, aż poziom osiągnie zero, może zamknąć grupy należące do innego wywołującego. Używaj poziomu jako narzędzia diagnostycznego i przestrzegaj umowy właściciela. Zobacz groupingLevel i undo().
W przypadku tego płótna przechowywanego w pamięci właściwą granicą jest zatwierdzony ruch: swobodnie pokazuj podgląd, zatwierdź zmianę raz, zarejestruj odwracalną zmianę i zamknij grupę przed powrotem. Dzięki temu każde zakończone przeciągnięcie daje jeden krok cofania, a anulowanie, dostarczanie zdarzeń i właściciel historii pozostają jasno określone.
Źródła
UndoManager — Apple
groupsByEvent — Apple
beginUndoGrouping() — Apple
endUndoGrouping() — Apple
onEnded(_:) — Apple
updating(_:body:) — Apple
undoManager — Apple
Registering Undo Operations — Apple
Control Flow — The Swift Programming Language source — Swift.org
DragGesture — Apple
translation — Apple
redo() — Apple
setActionName(_:) — Apple
Undo Manager — Apple
mouseDragged(with:) — Apple
mouseUp(with:) — Apple
Handling Mouse Events — Apple
groupingLevel — Apple
undo() — Apple