Enregistrez les opérations d’annulation auprès d’un stockage de modèle à longue durée de vie et laissez les vues lui soumettre leurs modifications par l’intermédiaire de ses méthodes. Pour un éditeur de formes sur Mac, gardez les aperçus du glissement locaux, validez une seule fois au relâchement et faites enregistrer par le stockage l’inverse qui rend également possible le rétablissement.
Ce tutoriel crée une petite app macOS SwiftUI contenant un rectangle synthétique. Vous pouvez le déplacer, masquer son éditeur, annuler le déplacement, puis réafficher l’éditeur. L’objectif concret est de préserver le comportement d’annulation lorsqu’une vue d’édition disparaît alors que son modèle reste actif. Tout reste en mémoire.
Pourquoi le stockage devrait-il recevoir l’opération d’annulation ?
La méthode d’enregistrement basée sur une fermeture d’Apple exige une cible de type référence. Apple précise également que le gestionnaire d’annulation conserve une référence non possédée vers cette cible. Par conséquent, l’enregistrement d’une action ne garantit pas la durée de vie de la cible. Apple recommande d’utiliser le paramètre cible du gestionnaire plutôt que de capturer une autre référence vers cette même cible. Documentation d’Apple sur l’enregistrement des annulations.
Un stockage qui possède l’état modifié est une cible appropriée. Un propriétaire stable maintient ce stockage en vie ; les vues temporaires l’affichent et demandent les modifications. Ici, la vue racine de la fenêtre possède le stockage avec @StateObject, tandis que le canevas amovible l’observe. SwiftUI préserve un objet d’état pendant toute la durée de vie du conteneur qui le déclare, mais un changement d’identité du conteneur peut créer une nouvelle instance. Documentation d’Apple sur StateObject.
L’expression « inverses appartenant au stockage » décrit l’emplacement de la logique d’inversion. Le gestionnaire d’annulation continue d’enregistrer et de planifier les rappels. Le stockage fournit l’ancienne valeur et la méthode qui la rétablit. Cette répartition suit l’architecture documentée par Apple, qui permet au destinataire de l’annulation d’être soit l’objet modifié, soit son propriétaire. Guide d’Apple sur le gestionnaire d’annulation.
| Responsabilité | Propriétaire dans cet exemple |
|---|---|
| Position validée du rectangle | Stockage du modèle |
| Enregistrement de l’inverse et modification | Méthode du stockage du modèle |
| Durée de vie du stockage | Vue racine stable de la fenêtre |
| Translation temporaire du glissement | État du geste du canevas |
| Historique et exécution des annulations | Gestionnaire d’annulation de l’environnement |
| Sélection du gestionnaire approprié | Vue à la frontière de l’interaction |
Implémenter une modification réversible du stockage
Créez une app macOS SwiftUI avec une cible de déploiement macOS 13 ou ultérieure et un SDK Apple récent. Les trois fichiers suivants constituent l’exemple complet ; remplacez le point d’entrée de l’app générée et sa vue de contenu au lieu de conserver une seconde déclaration @main. Le compilateur Swift et les frameworks macOS ne sont pas disponibles dans cet environnement de recherche ; cet exemple a donc été examiné, mais n’y a pas été compilé.
Créez ShapeStore.swift. Le stockage comporte une position validée et une méthode de modification. Capturer la position précédente avant l’affectation fournit à la méthode les informations nécessaires pour annuler la modification.
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’inverse appelle la même méthode. Supposons que la position passe de (120, 100) à (160, 100). Le rappel enregistré demande (120, 100). Lorsque l’annulation exécute ce rappel, la méthode considère (160, 100) comme sa valeur précédente et enregistre un retour. Apple précise que les enregistrements effectués pendant une annulation deviennent des opérations de rétablissement. Ne pas enregistrer lorsque isUndoing est vrai briserait ce mécanisme. Guide d’Apple sur le gestionnaire d’annulation.
La fermeture agit sur son paramètre store et capture le gestionnaire avec une référence faible. Cela évite que la fermeture stockée par le gestionnaire conserve une référence forte vers ce même gestionnaire. La documentation de Swift sur le comptage automatique des références explique que les références faibles ne maintiennent pas en vie l’instance référencée et deviennent nil lorsque celle-ci est désallouée. Le langage de programmation Swift : comptage automatique des références.
Le stockage et sa méthode de modification sont isolés sur l’acteur principal. Cela correspond à la documentation actuelle d’Apple : UndoManager et le gestionnaire d’enregistrement sont isolés sur l’acteur principal. La méthode effectue l’enregistrement et la modification de façon synchrone, ce qui maintient cette petite modification dans le cadre de l’interaction qui l’a appelée. Documentation d’Apple sur UndoManager.
Garder le stockage au-dessus de l’éditeur amovible
Créez StoreUndoDemoApp.swift. La racine possède le stockage, que le canevas soit visible ou non. Ses boutons lisent le gestionnaire actuel de l’environnement lorsqu’une action est exécutée. Le canevas reçoit ce même gestionnaire pour ses modifications.
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)
}
}
Le gestionnaire d’annulation de l’environnement SwiftUI est facultatif. Apple autorise explicitement l’omission de l’enregistrement d’une annulation lorsque l’environnement fournit nil. Dans ce cas, cet exemple déplace tout de même le rectangle, mais ne crée aucune entrée d’annulation. Si une opération d’annulation attendue est absente, vérifiez si l’interaction a reçu un gestionnaire avant de modifier la logique d’inversion. Documentation d’Apple sur undoManager dans l’environnement.
Transmettre le gestionnaire à chaque modification explicite également le contexte de la fenêtre. Le stockage ne conserve pas le gestionnaire de fenêtre qui se serait présenté en premier. Dans cet exemple, chaque racine de fenêtre possède son propre stockage. Si plusieurs fenêtres modifient ensuite un même modèle partagé, déterminez à quel historique appartiennent ces modifications avant de réutiliser cette organisation entre fenêtres.
Valider un seul glissement sans étendre un groupe d’annulation sur plusieurs événements
Créez ShapeCanvas.swift. Pendant le glissement, la position affichée du rectangle inclut une translation temporaire. La position validée dans le stockage ne change qu’à la fin réussie du geste. Terminez un glissement avant d’effectuer une autre modification ou d’exécuter une commande d’annulation ; ce petit exemple suppose des interactions séquentielles et ne gère pas les commandes simultanées pendant un glissement.
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()
}
}
Le geste utilise sa translation finale pour valider le déplacement. Il ne dépend pas du moment où la propriété d’aperçu est réinitialisée. Apple précise que updating(_:body:) réinitialise l’état du geste lorsque celui-ci se termine ou est annulé, tandis que onEnded(_:) s’exécute uniquement après une fin réussie. Ainsi, un geste annulé dans cette conception ne laisse ni déplacement validé ni nouvelle action d’annulation. Documentation d’Apple sur la mise à jour des gestes, Documentation d’Apple sur la fin des gestes.
Cela préserve trois frontières distinctes : une mise à jour de l’aperçu modifie l’état d’affichage temporaire ; la fin réussie demande une modification du modèle ; le stockage enregistre l’inverse de cette modification. Appeler la méthode de modification du stockage à chaque mise à jour du glissement enregistrerait plutôt les positions intermédiaires tout au long du geste.
Le regroupement par défaut des événements rassemble les enregistrements autour des passages de la boucle d’exécution. Un glissement peut produire des mises à jour sur plusieurs passages ; le regroupement par événement ne représente donc pas à lui seul la transaction voulue pour l’ensemble du glissement. Ici, la validation unique évite ce problème sans modifier la configuration du gestionnaire partagé. Documentation d’Apple sur groupsByEvent.
Pour une future commande qui modifie plusieurs éléments de façon synchrone, un groupe d’annulation explicite et équilibré peut rassembler ses inverses. Limitez son début et sa fin à cette commande. Si une modification directe du modèle pendant le glissement devient nécessaire, concevez des opérations distinctes de début, de mise à jour, de validation et d’annulation, en précisant également l’interaction avec les autres commandes pendant la modification. L’approche fondée uniquement sur l’aperçu choisie ici laisse délibérément cette mécanique transactionnelle supplémentaire hors du stockage.
Vérifier séparément la propriété, le rétablissement et le routage des fenêtres
Suivez la séquence ci-dessous avec l’app en cours d’exécution. Les coordonnées sont des valeurs attendues données à titre indicatif à partir de l’état initial fourni et de l’incrément du bouton ; elles ne proviennent pas de mesures.
| Action | Position validée ou résultat attendu |
|---|---|
| Ouvrir une nouvelle fenêtre | (120, 100) |
Cliquer sur Move Right |
(140, 100) |
Désactiver Show Editor |
Le canevas disparaît ; la position reste (140, 100) |
Cliquer sur Undo |
La position devient (120, 100) alors que le canevas reste absent |
Cliquer sur Redo |
La position devient (140, 100) |
Réactiver Show Editor |
Le rectangle apparaît à sa position rétablie |
Terminer un glissement, puis cliquer sur Undo |
Le rectangle revient à sa position antérieure au glissement |
Le test avec l’éditeur masqué permet de distinguer la propriété du modèle de la durée de vie de la présentation. Si l’annulation ne fonctionne que lorsqu’un panneau particulier existe, vérifiez la cible enregistrée auprès du gestionnaire et l’objet qui la conserve. Si l’annulation fonctionne, mais que le rétablissement ne fait rien, vérifiez si l’inverse enregistre l’opération de retour. Si un glissement nécessite de nombreuses annulations, vérifiez si les mises à jour de l’aperçu parviennent à la modification réversible du stockage.
Les boutons explicites facilitent l’inspection du chemin de modification. Pour obtenir le comportement habituel des menus Mac, testez également le menu standard Edit avec la fenêtre voulue active. Le répondeur de base d’AppKit obtient son gestionnaire d’annulation à partir du répondeur suivant. Lorsqu’une fenêtre demande un gestionnaire à son délégué, windowWillReturnUndoManager(_:) est le point de personnalisation documenté ; sans cette implémentation, la fenêtre crée son propre gestionnaire. Enregistrer les annulations auprès d’un gestionnaire sans lien ne garantit pas le routage voulu dans la chaîne des répondeurs. Documentation d’Apple sur undoManager dans NSResponder, Documentation d’Apple sur le délégué du gestionnaire d’annulation de fenêtre.
Le stockage doit également avoir une limite de retrait clairement définie. Si vous remplacez un stockage alors que son gestionnaire existe toujours, supprimez les enregistrements de ce stockage avant de le libérer, en utilisant removeAllActions(withTarget:) sur chaque gestionnaire qui détient ses actions. Ne videz pas l’historique simplement parce que le canevas disparaît : le comportement mis en place consiste précisément à conserver les modifications du stockage pendant cette disparition. Documentation d’Apple sur le nettoyage ciblé des annulations.
Cet exemple comporte un rectangle fixe et aucun chemin de suppression. Pour l’étendre à une collection, capturez des identifiants d’éléments stables et les valeurs nécessaires pour annuler chaque opération. Une suppression a besoin d’un inverse capable de restaurer l’élément et l’ordre voulu ; un ancien indice dans le tableau ne suffit pas si la collection peut changer.
La conception achevée confie à la racine la durée de vie, au canevas l’état temporaire de l’interaction et au stockage les modifications réversibles. Masquer le canevas laisse ces modifications disponibles, car leurs méthodes d’inversion agissent sur le stockage toujours actif.
Sources
- 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