Per una forma trascinata all’interno della tua app per Mac, visualizza in anteprima il movimento durante il gesto e conferma la posizione finale in un unico breve gruppo di annullamento. Mantieni il modello e il gestore degli annullamenti sotto un unico proprietario, abbina ogni apertura di gruppo a una chiusura e registra lo spostamento inverso affinché funzioni anche il ripristino.

Questo tutorial crea una piccola tela SwiftUI contenente un cerchio sintetico in memoria. L’obiettivo concreto è fare in modo che un trascinamento completato richieda esattamente un annullamento, indipendentemente dal numero di aggiornamenti del movimento ricevuti. Spiega anche come mantenere gli stessi confini in una vista AppKit.

Perché un trascinamento non dovrebbe lasciare aperto un gruppo di annullamento

Un trascinamento è un’interazione che si svolge nel tempo. Un gruppo di annullamento raccoglie operazioni reversibili. I due confini non devono avere la stessa durata. Apple documenta che groupsByEvent è impostato su true per impostazione predefinita e raggruppa le operazioni in base ai passaggi del ciclo di esecuzione; questo comportamento non definisce una transazione completa di trascinamento. I gruppi espliciti possono inoltre annidarsi all’interno di gruppi esistenti. Consulta la documentazione Apple sul raggruppamento automatico degli eventi e sull’avvio dei gruppi di annullamento.

Aprire un gruppo nel primo callback di movimento SwiftUI e chiuderlo in onEnded lascia una lacuna in caso di annullamento. Apple afferma esplicitamente che onEnded viene eseguito solo quando il gesto termina correttamente. Al contrario, lo stato mantenuto tramite updating viene reimpostato quando il gesto termina o viene annullato. Un endUndoGrouping() senza corrispondente apertura genera un’eccezione, quindi non è adatto nemmeno cercare di indovinare se un callback abbia aperto un gruppo. Consulta onEnded(_:), updating(_:body:) e endUndoGrouping().

Per questa tela, usa il seguente progetto. È una scelta implementativa derivata dai confini documentati:

Fase dell’interazione Stato visualizzato Modello confermato Raggruppamento degli annullamenti
Prima del trascinamento Centro memorizzato Centro originale Chiuso
Aggiornamenti del movimento Centro originale più traslazione temporanea Invariato Chiuso
Completamento corretto Centro finale Aggiornato una volta Apertura e chiusura sincrone
Annullamento L’anteprima viene reimpostata Invariato Mai aperto
Annullamento o ripristino Centro ripristinato Aggiornato tramite il setter reversibile Gestito da UndoManager

Questo approccio consente al cerchio di muoversi in modo continuo senza trattare ogni fotogramma di anteprima come una modifica permanente. Un trascinamento completato diventa un’unica operazione del modello con un nome.

Scegli chi possiede la cronologia degli annullamenti

L’esempio assegna a ogni tela il proprio modello e un gestore degli annullamenti privato. Poiché quel proprietario controlla ogni registrazione, imposta groupsByEvent = false una sola volta durante l’inizializzazione e raggruppa esplicitamente ogni spostamento confermato. Apple richiede che i gruppi vengano chiusi esplicitamente prima dell’annullamento quando il raggruppamento automatico è disabilitato. L’esempio chiude il gruppo prima di restituire il controllo dal metodo di conferma. Consulta groupsByEvent.

Un editor di documenti esistente potrebbe già disporre di un gestore degli annullamenti per il documento. SwiftUI espone il gestore contestuale tramite @Environment(\.undoManager) e Apple documenta che il valore può essere nil quando il contesto non supporta l’annullamento. In questa architettura, continua a usare il gestore del documento e le sue regole di raggruppamento consolidate; non applicare la configurazione del gestore privato di questo esempio a un gestore condiviso. Consulta EnvironmentValues.undoManager.

Con un gestore condiviso e il raggruppamento automatico abilitato, registrare un unico spostamento inverso al completamento evita comunque di creare voci nella cronologia a ogni movimento. Un sottogruppo esplicito può racchiudere lo spostamento, ma non lo rende indipendente da un gruppo esterno. Se altre modifiche condividono quel gruppo esterno, possono essere annullate insieme. Per garantire una transazione indipendente di livello superiore serve il coordinamento del proprietario della cronologia condivisa, non la chiusura dei gruppi da parte di una vista figlia.

Crea l’esempio SwiftUI completo

Crea un progetto di app SwiftUI per macOS con una versione minima di macOS 14 o successiva. Sostituisci la dichiarazione dell’app generata e la vista del contenuto con i due file seguenti. Mantieni una sola dichiarazione @main. L’esempio usa pulsanti locali Undo e Redo per rendere esplicita la proprietà; l’integrazione con i menu dell’applicazione e la catena dei responder è un passaggio separato.

Il modello usa un setter reversibile: acquisisce la posizione corrente, registra una closure che la ripristina, quindi applica la sostituzione. Quando la closure viene eseguita durante l’annullamento, lo stesso setter registra l’operazione inversa come ripristino. Apple mostra questo schema di registrazione reciproca in Registering Undo Operations. La documentazione attuale indica inoltre UndoManager e il suo gestore di registrazione come isolati sul main actor; per questo il modello di esempio usa @MainActor. Consulta UndoManager.

Inserisci il seguente modello completo in 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 si trova deliberatamente nello stesso ambito sincrono di beginUndoGrouping(). Swift esegue le azioni differite quando si esce dall’ambito, anche in caso di ritorno anticipato o errore generato. Non può estendersi a callback del gesto invocati separatamente. Questo abbinamento mantiene bilanciato il gruppo del modello nei normali percorsi di controllo. Consulta Flusso di controllo — Azioni differite.

La closure di annullamento opera sul parametro target anziché catturare self. Apple documenta che il gestore mantiene un riferimento non posseduto al target e consiglia di usare il parametro fornito per evitare cicli di mantenimento. La tela mantiene il modello mentre è in uso, e il modello possiede la propria cronologia. Consulta registerUndo(withTarget:handler:).

Inserisci l’app e la tela in 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)
    }
}

Il gesto usa un unico spazio di coordinate denominato per la tela, così la traslazione viene misurata rispetto al piano e non al cerchio in movimento. Apple definisce translation come lo spostamento totale dall’inizio del gesto. Assegnalo all’anteprima; non sommarlo ripetutamente al modello durante il movimento. Consulta DragGesture e translation.

Lo stato facoltativo del gesto indica anche se è attiva un’anteprima. Durante l’anteprima, entrambi i pulsanti locali della cronologia sono disabilitati. Al completamento corretto, il valore finale del gesto fornisce la traslazione da confermare. In caso di annullamento, lo stato del gesto viene reimpostato e il centro memorizzato resta invariato; non c’è alcun gruppo aperto da ripulire.

Questo esempio ha intenzionalmente un solo autore delle modifiche. Nessun altro comando modifica il cerchio durante un trascinamento. Se aggiungi lo spostamento da tastiera, l’eliminazione o altri comandi di modifica, stabilisci se ciascun comando è indisponibile durante il trascinamento o se prima annulla l’anteprima. Applica questa regola a tutti i comandi; disabilitare solo questi due pulsanti non può governare percorsi di comando aggiuntivi.

Verifica un trascinamento, un annullamento e un ripristino funzionante

Usa coordinate illustrative per verificare i calcoli. Partendo da (120, 100), le traslazioni cumulative di (10, 5), (25, 12) e (40, 20) producono centri di anteprima pari a (130, 105), (145, 112) e (160, 120). Il centro confermato resta (120, 100) fino al completamento, quando cambia una sola volta in (160, 120).

L’annullamento invoca il setter registrato con (120, 100). Prima di ripristinarla, il setter acquisisce (160, 120) e registra quella posizione per il ripristino. Non sopprimere la registrazione nella callback di annullamento: è proprio questa registrazione reciproca a mantenere disponibile il ripristino. Apple descrive questo comportamento in Registering Undo Operations.

Il codice non crea gruppi espliciti aggiuntivi nel setter reversibile. UndoManager controlla il raggruppamento durante la riproduzione della cronologia; Apple documenta che redo() registra le operazioni riprodotte nello stack degli annullamenti come un unico gruppo. Il nome dell’azione viene assegnato tramite setActionName, usando l’etichetta sintetica Move Shape. Consulta redo() e setActionName(_:).

Verifica questi comportamenti nell’app per Mac:

  1. Completa un trascinamento con diversi cambi di direzione. Un solo Undo dovrebbe ripristinare la posizione iniziale.
  2. Scegli Redo. La forma dovrebbe tornare alla posizione finale del trascinamento completato.
  3. Completa due trascinamenti separati. Due annullamenti dovrebbero ripristinare le due posizioni precedenti in ordine inverso.
  4. Completa un trascinamento riconosciuto nella posizione iniziale. Il controllo che rileva l’assenza di cambiamenti non dovrebbe creare voci nella cronologia.
  5. Prova un percorso di annullamento o rimozione della vista nell’app contenitore. L’anteprima dovrebbe scomparire senza confermare uno spostamento.
  6. Annulla uno spostamento, poi completa uno spostamento diverso. Il ramo di ripristino precedente dovrebbe scomparire; Apple documenta che una nuova registrazione di annullamento svuota lo stack dei ripristini.

Quest’ultimo comportamento è descritto nell’architettura di Undo Manager di Apple. Il calcolo delle coordinate sopra è stato verificato con un piccolo calcolo deterministico in Python. Il codice Swift è stato esaminato, ma non compilato né eseguito qui, perché questo ambiente non dispone di un compilatore Swift o di un SDK macOS.

Applica gli stessi confini a una tela AppKit

Per una NSView esistente, mantieni la sequenza di eventi di AppKit. Il riferimento attuale di Apple definisce mouseDragged(with:) come il movimento con il pulsante sinistro premuto e mouseUp(with:) come il rilascio di quel pulsante. La guida archiviata sugli eventi descrive la sequenza di tre metodi e la conversione delle coordinate necessarie per una vista personalizzata. Consulta mouseDragged(with:), mouseUp(with:) e Gestione degli eventi del mouse.

Mappa lo stesso progetto su quei metodi:

  • In mouseDown(with:), verifica con hit-test la forma sintetica e salva il centro iniziale e la posizione del puntatore convertita nelle coordinate della tela.
  • In mouseDragged(with:), calcola lo spostamento rispetto alla posizione salvata del puntatore e ridisegna un’anteprima temporanea.
  • In mouseUp(with:), calcola lo spostamento finale, cancella lo stato di tracciamento e chiama una sola volta il metodo sincrono di conferma del modello.
  • Nel percorso di annullamento o smantellamento del proprietario della tela, elimina l’anteprima e cancella lo stato di tracciamento senza confermare.

Usa un solo proprietario degli eventi per l’interazione. Se SwiftUI incorpora la tela AppKit, scegli se affidare lo spostamento e la conferma al gesto SwiftUI o ai gestori degli eventi del mouse di AppKit. Se entrambi registrano lo stesso spostamento, verranno create operazioni duplicate. Mantieni coerenti le coordinate di disegno, tenendo conto anche di eventuali sistemi di coordinate con asse verticale invertito o trasformazioni della tela.

La guida archiviata di Apple spiega anche che i comandi da tastiera possono essere inviati durante il tracciamento a tre metodi. Qualsiasi integrazione con i menu deve quindi rispettare la stessa regola applicata ai pulsanti locali durante un trascinamento attivo. Mantieni la cronologia del documento presso il proprietario del documento e assicurati che le azioni dei menu si rivolgano al gestore di quel proprietario.

Diagnostica i problemi di raggruppamento senza chiudere il gruppo di qualcun altro

Se un trascinamento crea molti passaggi di annullamento, controlla se i setter del modello registrano operazioni durante gli aggiornamenti del movimento. Sposta queste registrazioni alla conferma finale. Se diversi trascinamenti vengono annullati insieme, controlla la regola di raggruppamento automatico del gestore e gli eventuali gruppi esterni creati dal proprietario. Se il ripristino non è disponibile dopo l’annullamento, controlla se il percorso di ripristino ha registrato l’operazione inversa.

Se il raggruppamento genera un’eccezione, controlla la proprietà prima di cambiare il numero di chiamate di chiusura. Apple documenta che groupingLevel == 0 significa che non è aperto alcun gruppo e che undo() genera un’eccezione quando resta aperto più di un gruppo. Un ciclo che chiude i gruppi finché il livello non raggiunge zero può chiudere gruppi appartenenti a un altro chiamante. Usa il livello come indicatore diagnostico e rispetta il contratto del proprietario. Consulta groupingLevel e undo().

Per questa tela in memoria, il confine utile è lo spostamento confermato: visualizza liberamente l’anteprima, conferma una volta, registra una modifica reversibile e chiudi il gruppo prima di restituire il controllo. In questo modo ogni trascinamento completato corrisponde a un passaggio di annullamento, mantenendo espliciti l’annullamento, la consegna degli eventi e la proprietà della cronologia.

Fonti