Pour une forme glissée dans votre app Mac, prévisualisez le mouvement pendant le geste et validez la position finale dans un groupe d'annulation court. Gardez le modèle et le gestionnaire d'annulation sous un seul propriétaire, associez chaque ouverture de groupe à une fermeture, et enregistrez le mouvement inverse pour que le rétablissement fonctionne aussi.
Ce tutoriel construit un petit canevas SwiftUI contenant un cercle synthétique en mémoire. La tâche concrète consiste à faire en sorte qu'un glisser terminé nécessite exactement une annulation, quel que soit le nombre de mises à jour de mouvement reçues. Il explique également comment préserver les mêmes limites dans une vue AppKit.
Pourquoi un glisser ne devrait pas garder un groupe d'annulation ouvert
Un glisser est une interaction qui se déroule dans le temps. Un groupe d'annulation collecte des opérations réversibles. Ces limites n'ont pas besoin d'avoir la même durée de vie. Apple documente que groupsByEvent prend par défaut la valeur true et regroupe les opérations autour des passages de la boucle d'exécution ; ce comportement ne définit pas une transaction de glisser complète. Les groupes explicites peuvent également s'imbriquer dans des groupes existants. Consultez la documentation d'Apple pour le regroupement automatique d'événements et le début des groupes d'annulation.
Ouvrir un groupe dans le premier rappel de mouvement SwiftUI et le fermer dans onEnded laisse un écart d'annulation. Apple indique explicitement que onEnded ne s'exécute que lorsque le geste se termine avec succès. En comparaison, l'état maintenu via updating se réinitialise lorsque le geste se termine ou est annulé. Un endUndoGrouping() non associé lève une exception, donc deviner si un rappel a ouvert un groupe est également inapproprié. Voir onEnded(_:), updating(_:body:), et endUndoGrouping().
Pour ce canevas, utilisez la conception suivante. C'est un choix d'implémentation dérivé de ces limites documentées :
| Étape d'interaction | État d'affichage | Modèle validé | Groupement d'annulation |
|---|---|---|---|
| Avant le glisser | Centre stocké | Centre original | Fermé |
| Mises à jour de mouvement | Centre original plus translation transitoire | Inchangé | Fermé |
| Achèvement réussi | Centre final | Mis à jour une fois | Ouvrir et fermer de manière synchrone |
| Annulation | Aperçu réinitialisé | Inchangé | Jamais ouvert |
| Annulation ou rétablissement | Centre restauré | Mis à jour via le setter réversible | Géré par UndoManager |
Cette approche permet au cercle de se déplacer continuellement sans traiter chaque image d'aperçu comme une modification permanente. Un glisser terminé devient une opération de modèle nommée unique.
Choisissez qui possède l'historique d'annulation
L'exemple donne à chaque canevas son propre modèle et son propre gestionnaire d'annulation privé. Comme ce propriétaire contrôle chaque enregistrement, il définit groupsByEvent = false une fois lors de l'initialisation et groupe explicitement chaque déplacement validé. Apple exige de fermer explicitement les groupes avant l'annulation lorsque le regroupement automatique est désactivé. L'exemple ferme le groupe avant de retourner de la méthode de validation. Voir groupsByEvent.
Un éditeur de documents existant peut déjà avoir un gestionnaire d'annulation de document. SwiftUI expose le gestionnaire contextuel via @Environment(\.undoManager), et Apple documente que la valeur peut être nil lorsque le contexte ne prend pas en charge l'annulation. Dans cette architecture, continuez à utiliser le gestionnaire du document et sa politique de regroupement établie ; ne copiez pas la configuration du gestionnaire privé de cet exemple sur un gestionnaire partagé. Voir EnvironmentValues.undoManager.
Avec un gestionnaire partagé et le regroupement automatique activé, l'enregistrement d'un mouvement inverse à l'achèvement évite toujours les entrées d'historique mouvement par mouvement. Un sous-groupe explicite peut empaqueter le mouvement, mais il ne force pas l'indépendance par rapport à un groupe englobant. Si d'autres modifications partagent ce groupe englobant, elles peuvent être annulées ensemble. Garantir une transaction de niveau supérieur indépendante nécessite une coordination par le propriétaire de l'historique partagé, et non la fermeture de groupes depuis une vue enfant.
Construire l'exemple SwiftUI complet
Créez un projet d'app macOS SwiftUI avec une cible de déploiement macOS 14 ou ultérieure. Remplacez sa déclaration d'app générée et sa vue de contenu par les deux fichiers ci-dessous. Gardez une seule déclaration @main. L'exemple utilise des boutons Annuler et Rétablir locaux pour rendre la propriété explicite ; l'intégration au menu de l'application et à la chaîne de répondeurs est une étape d'intégration distincte.
Le modèle utilise un setter réversible : capturez la position actuelle, enregistrez une fermeture qui la restaure, puis appliquez le remplacement. Lorsque cette fermeture s'exécute pendant l'annulation, le même setter enregistre l'inverse comme rétablissement. Apple montre ce motif d'enregistrement réciproque dans Enregistrement des opérations d'annulation. La documentation actuelle marque également UndoManager et son gestionnaire d'enregistrement comme isolés à l'acteur principal, donc le modèle de l'exemple utilise @MainActor. Voir UndoManager.
Placez le modèle complet suivant dans 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
}
}
Le defer est délibérément dans la même portée synchrone que beginUndoGrouping(). Swift exécute les actions différées à la sortie de cette portée, y compris par un retour anticipé ou une erreur levée. Il ne peut pas s'étendre sur des rappels de geste invoqués séparément. Ce couplage maintient le groupe du modèle équilibré sur les sorties normales du flux de contrôle. Voir Flux de contrôle — Actions différées.
La fermeture d'annulation opère sur son paramètre target plutôt que de capturer self. Apple documente que le gestionnaire détient une référence non possédée vers la cible et recommande d'utiliser le paramètre fourni pour éviter les cycles de rétention. Le canevas retient le modèle pendant son utilisation, et le modèle possède son historique. Voir registerUndo(withTarget:handler:).
Placez l'app et le canevas dans 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)
}
}
Le geste utilise un espace de coordonnées de canevas nommé unique, donc la translation est mesurée par rapport au panneau plutôt qu'au cercle en mouvement. Apple définit translation comme le déplacement total depuis le début du geste. Attribuez-le à l'aperçu ; ne l'ajoutez pas répétitivement au modèle pendant le mouvement. Voir DragGesture et translation.
L'état de geste optionnel indique également si un aperçu est actif. Les deux boutons d'historique locaux sont désactivés pendant cet aperçu. En cas d'achèvement réussi, la valeur finale du geste fournit la translation validée. En cas d'annulation, l'état du geste se réinitialise et le centre stocké reste inchangé ; il n'y a pas de groupe ouvert à nettoyer.
Cet exemple a intentionnellement un seul écrivain. Aucune autre commande ne modifie le cercle pendant un glisser. Si vous ajoutez le déplacement au clavier, la suppression ou d'autres commandes d'édition, définissez si chaque commande est indisponible pendant le glisser ou annule d'abord l'aperçu. Acheminez toutes les commandes via cette politique ; désactiver ces deux boutons seuls ne peut pas régir les chemins de commande supplémentaires.
Vérifier un glisser, une annulation et un rétablissement fonctionnel
Utilisez des coordonnées illustratifs pour inspecter l'arithmétique. En partant de (120, 100), les translations cumulées de (10, 5), (25, 12) et (40, 20) produisent des centres d'aperçu de (130, 105), (145, 112) et (160, 120). Le centre validé reste à (120, 100) jusqu'à l'achèvement, puis change une fois pour (160, 120).
L'annulation invoque le setter enregistré avec (120, 100). Avant de le restaurer, le setter capture (160, 120) et enregistre cette position pour le rétablissement. Ne supprimez pas l'enregistrement dans le rappel d'annulation : cet enregistrement réciproque est ce qui préserve le rétablissement. Apple décrit ce comportement dans Enregistrement des opérations d'annulation.
Le code ne crée pas de groupe explicite supplémentaire à l'intérieur du setter réversible. UndoManager contrôle le regroupement pendant la relecture de l'historique ; Apple documente que redo() enregistre les opérations relues sur la pile d'annulation comme un groupe unique. Le nom de l'action est attribué via setActionName, en utilisant l'étiquette synthétique Move Shape. Voir redo() et setActionName(_:).
Vérifiez ces comportements dans l'app Mac :
- Effectuez un glisser avec plusieurs changements de direction. Un
Annulerdoit restaurer sa position de départ. - Choisissez
Rétablir. La forme doit revenir à la position finale du glisser terminé. - Effectuez deux glissers séparés. Deux actions d'annulation doivent restaurer les deux positions antérieures dans l'ordre inverse.
- Terminez un glisser reconnu à sa position de départ. La garde sans changement ne doit créer aucune entrée d'historique.
- Testez un chemin d'annulation ou de suppression de vue dans l'app conteneur. L'aperçu doit disparaître sans déplacement validé.
- Annulez un déplacement, puis effectuez un déplacement différent. La branche de rétablissement précédente doit disparaître ; Apple documente qu'un nouvel enregistrement d'annulation efface la pile de rétablissement.
Le dernier comportement suit l'architecture du gestionnaire d'annulation d'Apple. Le calcul de coordonnées ci-dessus a été vérifié avec un petit calcul Python déterministe. La source Swift a été inspectée mais n'a pas été compilée ni exécutée ici car cet environnement n'a pas de compilateur Swift ni de SDK macOS.
Appliquer les mêmes limites à un canevas AppKit
Pour un NSView existant, préservez la séquence d'événements d'AppKit. La référence actuelle d'Apple définit mouseDragged(with:) comme le mouvement avec le bouton gauche enfoncé et mouseUp(with:) comme le relâchement de ce bouton. Son guide d'événements archivé décrit la séquence en trois méthodes et la conversion de coordonnées nécessaire pour une vue personnalisée. Voir mouseDragged(with:), mouseUp(with:), et Gestion des événements de souris.
Appliquez la même conception à ces méthodes :
- Dans
mouseDown(with:), testez la forme synthétique et sauvegardez son centre de départ et la position du pointeur convertie en coordonnées de canevas. - Dans
mouseDragged(with:), calculez le déplacement à partir de cette position de pointeur sauvegardée et redessinez un aperçu transitoire. - Dans
mouseUp(with:), calculez le déplacement final, effacez l'état de suivi, et appelez la méthode de validation synchrone du modèle une fois. - Dans le chemin d'annulation ou de démantèlement du propriétaire du canevas, jetez l'aperçu et effacez l'état de suivi sans valider.
Utilisez un seul propriétaire d'événement pour l'interaction. Si SwiftUI incorpore le canevas AppKit, choisissez soit le geste SwiftUI, soit les gestionnaires de souris AppKit pour piloter le mouvement et la validation. Avoir les deux enregistrer le même mouvement créerait des opérations en double. Gardez les coordonnées de dessin cohérentes, y compris tout système de coordonnées inversé ou transformation de canevas.
Le guide archivé d'Apple explique également que les commandes clavier peuvent être distribuées pendant l'approche de suivi en trois méthodes. Toute intégration de menu doit donc respecter la même politique de glisser actif que les boutons locaux. Gardez l'historique du document avec son propriétaire de document et assurez-vous que les actions de menu s'adressent au gestionnaire de ce propriétaire.
Diagnostiquer les échecs de regroupement sans fermer le groupe de quelqu'un d'autre
Si un glisser crée de nombreuses étapes d'annulation, vérifiez si les setters du modèle s'enregistrent pendant les mises à jour de mouvement. Déplacez ces enregistrements vers la validation finale. Si plusieurs glissers s'annulent ensemble, inspectez la politique de regroupement automatique du gestionnaire et tout groupe englobant créé par le propriétaire. Si le rétablissement n'est pas disponible après l'annulation, inspectez si le chemin de restauration a enregistré l'opération inverse.
Si le regroupement lève une exception, inspectez la propriété avant de changer le nombre d'appels de fermeture. Apple documente que groupingLevel == 0 signifie qu'aucun groupe n'est ouvert, et que undo() lève une exception lorsque plus d'un groupe reste ouvert. Une boucle qui ferme les groupes jusqu'à ce que le niveau atteigne zéro peut fermer des groupes appartenant à un autre appelant. Utilisez le niveau comme diagnostic et appliquez le contrat du propriétaire. Voir groupingLevel et undo().
Pour ce canevas en mémoire, la limite utile est le déplacement validé : prévisualisez librement, validez une fois, enregistrez un changement réversible, et fermez le groupe avant de retourner. Cela donne à chaque glisser terminé une étape d'annulation tout en gardant l'annulation, la distribution d'événements et la propriété de l'historique explicites.
Sources
UndoManager — Apple
groupsByEvent — Apple
beginUndoGrouping() — Apple
endUndoGrouping() — Apple
onEnded(_:) — Apple
updating(_:body:) — Apple
undoManager — Apple
Flux de contrôle — La source du langage de programmation Swift — Swift.org
DragGesture — Apple
translation — Apple
redo() — Apple
setActionName(_:) — Apple
Gestionnaire d'annulation — Apple
mouseDragged(with:) — Apple
mouseUp(with:) — Apple
Gestion des événements de souris — Apple
groupingLevel — Apple
undo() — Apple