Registra le operazioni di annullamento su uno store di modelli con una lunga durata e lascia che le viste inviino le modifiche tramite i metodi dello store. Per un editor di forme per Mac, mantieni locali le anteprime del trascinamento, conferma la modifica una sola volta al rilascio e fai in modo che lo store registri l’operazione inversa che rende possibile anche il ripristino.
Questo tutorial crea una piccola app macOS SwiftUI contenente un rettangolo di prova. Puoi spostarlo, nascondere il suo editor, annullare lo spostamento e mostrare di nuovo l’editor. L’obiettivo concreto è mantenere il comportamento di annullamento quando una vista di modifica scompare mentre il suo modello rimane attivo. Tutto resta in memoria.
Perché lo store dovrebbe ricevere l’operazione di annullamento?
Il metodo di registrazione basato su closure di Apple richiede un target di tipo riferimento. La documentazione specifica inoltre che il gestore di annullamento mantiene un riferimento non posseduto a quel target. Pertanto, registrare un’azione non ne prolunga la durata. Apple consiglia di usare il parametro target dell’handler anziché acquisire un altro riferimento allo stesso target. Documentazione Apple sulla registrazione delle operazioni di annullamento.
Uno store che possiede lo stato modificato è un target adatto. Un proprietario stabile mantiene attivo lo store; le viste temporanee lo mostrano e richiedono le modifiche. Qui, la vista radice della finestra possiede lo store con @StateObject, mentre la canvas rimovibile lo osserva. SwiftUI preserva un oggetto di stato per tutta la durata del contenitore in cui è dichiarato, ma una modifica dell’identità del contenitore può creare una nuova istanza. Documentazione Apple di StateObject.
«Operazioni inverse di proprietà dello store» descrive dove risiede la logica di ripristino. Il gestore di annullamento continua a registrare e pianificare le callback. Lo store fornisce il valore precedente e il metodo che lo ripristina. Questa suddivisione segue l’architettura documentata da Apple, che consente al destinatario dell’annullamento di essere l’oggetto modificato oppure il suo proprietario. Guida Apple al gestore di annullamento.
| Responsabilità | Proprietario in questo esempio |
|---|---|
| Posizione confermata del rettangolo | Store del modello |
| Registrazione dell’operazione inversa e modifica | Metodo dello store del modello |
| Durata dello store | Vista radice stabile della finestra |
| Traslazione temporanea durante il trascinamento | Stato del gesto della canvas |
| Cronologia ed esecuzione delle operazioni di annullamento | Gestore di annullamento dell’ambiente |
| Selezione del gestore applicabile | Vista al confine dell’interazione |
Implementa una modifica reversibile dello store
Crea un’app macOS SwiftUI con una versione minima di macOS 13 o successiva e un SDK Apple aggiornato. I tre file seguenti costituiscono l’esempio completo; sostituisci il punto di ingresso dell’app e la vista dei contenuti generati, senza mantenere una seconda dichiarazione @main. Il compilatore Swift e i framework macOS non sono disponibili in questo ambiente di ricerca, quindi l’esempio è stato esaminato ma qui non è stato compilato.
Crea ShapeStore.swift. Lo store contiene una posizione confermata e un metodo di modifica. Acquisire la posizione precedente prima dell’assegnazione fornisce al metodo le informazioni necessarie per invertire la modifica.
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
}
}
L’operazione inversa chiama lo stesso metodo. Supponiamo che la posizione cambi da (120, 100) a (160, 100). La callback registrata richiede (120, 100). Quando l’annullamento esegue quella callback, il metodo vede (160, 100) come valore precedente e registra il percorso di ritorno. Apple documenta che le registrazioni effettuate durante l’annullamento diventano operazioni di ripristino. Saltare la registrazione ogni volta che isUndoing è true interromperebbe questo schema. Guida Apple al gestore di annullamento.
La closure opera sul parametro store e acquisisce il gestore con un riferimento debole. In questo modo si evita che la closure conservata dal gestore crei un riferimento forte di ritorno al gestore stesso. La documentazione sul conteggio dei riferimenti di Swift spiega che i riferimenti deboli non mantengono in vita l’istanza a cui fanno riferimento e diventano nil quando questa viene deallocata. Il linguaggio di programmazione Swift: conteggio automatico dei riferimenti.
Lo store e il suo metodo di modifica sono isolati dall’attore principale. Ciò corrisponde alla documentazione Apple aggiornata: UndoManager e l’handler di registrazione sono isolati dall’attore principale. Il metodo esegue la registrazione e la modifica in modo sincrono, mantenendo questa piccola modifica nell’interazione che la invoca. Documentazione Apple di UndoManager.
Mantieni lo store sopra l’editor rimovibile
Crea StoreUndoDemoApp.swift. La radice possiede lo store indipendentemente dalla visibilità della canvas. I pulsanti leggono il gestore dell’ambiente corrente quando viene eseguita un’azione. La canvas riceve lo stesso gestore per le proprie modifiche.
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)
}
}
Il gestore di annullamento nell’ambiente SwiftUI è facoltativo. Apple consente esplicitamente di saltare la registrazione quando l’ambiente fornisce nil. In tale situazione questo esempio sposta comunque il rettangolo, ma non crea voci nella cronologia di annullamento. Se manca un’operazione di annullamento prevista, verifica che l’interazione abbia ricevuto un gestore prima di modificare la logica dell’operazione inversa. Documentazione Apple di undoManager in EnvironmentValues.
Passare il gestore a ogni modifica rende esplicito anche il contesto della finestra. Lo store non conserva il gestore della finestra che si è presentata per prima. In questo esempio, ogni radice di finestra possiede il proprio store. Se in seguito più finestre modificheranno un unico modello condiviso, decidi a quale cronologia appartengono tali modifiche prima di riutilizzare questa struttura tra le finestre.
Conferma un solo trascinamento senza estendere un gruppo di annullamento tra gli eventi
Crea ShapeCanvas.swift. Durante il trascinamento, la posizione visualizzata del rettangolo include una traslazione temporanea. La posizione confermata nello store cambia soltanto quando il gesto termina correttamente. Completa un trascinamento prima di invocare un’altra modifica o un comando di annullamento; questo esempio compatto presuppone interazioni sequenziali e non gestisce comandi simultanei durante un trascinamento.
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()
}
}
Il gesto usa la traslazione finale per confermare lo spostamento. Non dipende dai tempi di ripristino della proprietà di anteprima. Apple documenta che updating(_:body:) ripristina lo stato del gesto quando questo termina o viene annullato, mentre onEnded(_:) viene eseguito solo dopo il completamento riuscito. Di conseguenza, in questo progetto un gesto annullato non lascia né uno spostamento confermato né una nuova azione di annullamento. Documentazione Apple sull’aggiornamento del gesto, Documentazione Apple sul completamento del gesto.
Questo mantiene tre confini distinti: un aggiornamento dell’anteprima modifica lo stato transitorio visualizzato; il completamento riuscito richiede una modifica del modello; lo store registra l’operazione inversa di tale modifica. Chiamare il metodo di modifica dello store a ogni aggiornamento del trascinamento registrerebbe invece le posizioni intermedie durante tutto il gesto.
Il raggruppamento predefinito degli eventi di Apple raccoglie le registrazioni attorno ai cicli del run loop. Un trascinamento può produrre aggiornamenti su più cicli, quindi il solo raggruppamento degli eventi non esprime la transazione complessiva desiderata per l’intero trascinamento. Qui, confermare una sola volta evita il problema senza modificare la configurazione del gestore condiviso. Documentazione Apple di groupsByEvent.
Per un comando futuro che modifica sincronicamente più elementi, un gruppo di annullamento esplicito e bilanciato può raccogliere le operazioni inverse. Mantieni l’apertura e la chiusura del gruppo all’interno di quel comando. Se diventa necessario modificare il modello in tempo reale durante il trascinamento, progetta operazioni distinte di inizio, aggiornamento, conferma e annullamento, specificando anche come interagiscono gli altri comandi con la modifica attiva. L’approccio basato solo sull’anteprima adottato qui tiene volutamente fuori dallo store questo ulteriore meccanismo transazionale.
Verifica separatamente la proprietà, il ripristino e l’instradamento tra finestre
Segui questa sequenza con l’app in esecuzione. Le coordinate sono valori illustrativi previsti in base allo stato iniziale e all’incremento del pulsante forniti, non risultati misurati.
| Azione | Posizione confermata o risultato previsto |
|---|---|
| Avvia una nuova finestra | (120, 100) |
Fai clic su Move Right |
(140, 100) |
Disattiva Show Editor |
La canvas scompare; la posizione rimane (140, 100) |
Fai clic su Undo |
La posizione diventa (120, 100) mentre la canvas resta nascosta |
Fai clic su Redo |
La posizione diventa (140, 100) |
Attiva Show Editor |
Il rettangolo appare nella posizione ripristinata |
Completa un trascinamento, poi fai clic su Undo |
Il rettangolo torna alla posizione precedente al trascinamento |
La verifica con l’editor nascosto distingue la proprietà del modello dalla durata della presentazione. Se l’annullamento funziona solo mentre esiste un determinato pannello, controlla il target registrato nel gestore e l’oggetto che lo mantiene in vita. Se l’annullamento funziona ma il ripristino no, controlla se l’operazione inversa registra l’operazione di ritorno. Se un trascinamento richiede molti annullamenti, controlla se gli aggiornamenti dell’anteprima raggiungono la modifica annullabile dello store.
I pulsanti espliciti rendono facile esaminare il percorso della modifica. Per il normale comportamento dei menu su Mac, verifica anche il menu standard Edit con la finestra desiderata attiva. Il responder di base di AppKit ottiene il proprio gestore di annullamento dal responder successivo. Quando una finestra chiede al delegato un gestore, windowWillReturnUndoManager(_:) è il punto di personalizzazione documentato; senza questa implementazione, la finestra crea un gestore proprio. Registrare le operazioni su un gestore non correlato non stabilisce l’instradamento desiderato nella catena dei responder. Documentazione Apple di undoManager in NSResponder, Documentazione Apple del delegato del gestore di annullamento della finestra.
Anche lo store necessita di un confine di dismissione definito. Se sostituisci uno store mentre il suo gestore continua a esistere, rimuovi le registrazioni di quello store prima di rilasciarlo, usando removeAllActions(withTarget:) su ogni gestore che contiene le sue azioni. Non cancellare la cronologia solo perché la canvas scompare: mantenere disponibili le modifiche dello store durante la sua scomparsa è proprio il comportamento che si vuole ottenere. Documentazione Apple sulla rimozione mirata delle operazioni di annullamento.
Questo esempio ha un rettangolo fisso e non include un percorso di eliminazione. Quando lo estendi a una raccolta, acquisisci identificatori stabili degli elementi e i valori necessari per invertire ogni operazione. L’inversione di un’eliminazione deve poter ripristinare l’elemento e il suo ordine previsto; il solo vecchio indice nell’array non basta se la raccolta può cambiare.
Il progetto completato affida alla radice la durata dello store, alla canvas lo stato temporaneo dell’interazione e allo store le modifiche reversibili. Nascondere la canvas lascia disponibili tali modifiche perché i relativi metodi di annullamento operano sullo store ancora attivo.
Fonti
- registerUndo(withTarget:handler:) — Apple
- StateObject — Apple
- Undo Manager — Apple
- Automatic Reference Counting — Swift.org
- UndoManager — Apple
- undoManager — EnvironmentValues — Apple
- updating(_:body:) — Apple
- onEnded(_:) — Apple
- groupsByEvent — Apple
- undoManager — NSResponder — Apple
- windowWillReturnUndoManager(_:) — Apple
- removeAllActions(withTarget:) — Apple