När du drar en form i din Mac-app kan du förhandsvisa rörelsen under gesten och genomföra den slutliga positionen i en kort ångra-grupp. Låt en enda ägare hantera modellen och ångrahanteraren, para ihop varje gruppöppning med en stängning och registrera den omvända förflyttningen så att det också går att göra om.

Den här handledningen bygger en liten SwiftUI-canvas med en syntetisk cirkel i minnet. Den konkreta uppgiften är att en avslutad dragning ska kräva exakt en åtgärd för att ångra, oavsett hur många rörelseuppdateringar som kommer in. Den förklarar också hur du bevarar samma gränser i en AppKit-vy.

Varför en dragning inte bör lämna en ångra-grupp öppen

En dragning är en interaktion som pågår över tid. En ångra-grupp samlar återställningsbara operationer. Dessa gränser behöver inte ha samma livslängd. Apple dokumenterar att groupsByEvent som standard är true och grupperar operationer kring körslingans varv; det beteendet definierar inte en fullständig dragtransaktion. Explicita grupper kan också kapslas inuti befintliga grupper. Se Apples dokumentation om automatisk gruppering av händelser och påbörjande av ångra-grupper.

Om du öppnar en grupp i den första SwiftUI-rörelseåteranropningen och stänger den i onEnded uppstår ett glapp vid avbrytning. Apple anger uttryckligen att onEnded körs endast när gesten avslutas utan att avbrytas. Däremot återställs tillstånd som hanteras genom updating när gesten avslutas eller avbryts. Ett oparat anrop till endUndoGrouping() utlöser ett undantag, så det är inte heller lämpligt att försöka gissa om ett återanrop öppnade en grupp. Se onEnded(_:), updating(_:body:) och endUndoGrouping().

För den här canvasen använder du följande utformning. Det är ett implementeringsval utifrån de dokumenterade gränserna:

Interaktionssteg Visningstillstånd Bekräftad modell Gruppering för ångra
Före dragning Lagrad mittpunkt Ursprunglig mittpunkt Stängd
Rörelseuppdateringar Ursprunglig mittpunkt plus tillfällig förflyttning Oförändrad Stängd
Slutförd dragning Slutlig mittpunkt Uppdateras en gång Öppnas och stängs synkront
Avbrytning Förhandsvisningen återställs Oförändrad Öppnas aldrig
Ångra eller gör om Återställd mittpunkt Uppdateras via den reversibla sättaren Hanteras av UndoManager

Med det här tillvägagångssättet kan cirkeln röra sig kontinuerligt utan att varje förhandsvisningsbildruta blir en permanent ändring. En avslutad dragning blir en namngiven modelloperation.

Välj vem som äger ångrahistoriken

Exemplet ger varje canvas en egen modell och en privat ångrahanterare. Eftersom den ägaren styr alla registreringar ställer den in groupsByEvent = false en gång vid initieringen och grupperar varje bekräftad förflyttning uttryckligen. Apple kräver att grupper stängs uttryckligen före ångring när automatisk gruppering är inaktiverad. Exemplet stänger gruppen innan bekräftelsemetoden returnerar. Se groupsByEvent.

En befintlig dokumentredigerare kan redan ha en ångrahanterare för dokumentet. SwiftUI exponerar den kontextuella hanteraren genom @Environment(\.undoManager), och Apple dokumenterar att värdet kan vara nil när kontexten inte stöder ångring. I den arkitekturen fortsätter du att använda dokumentets hanterare och dess etablerade grupperingspolicy; kopiera inte det här exemplets konfiguration för en privat hanterare till en delad hanterare. Se EnvironmentValues.undoManager.

Med en delad hanterare där automatisk gruppering är aktiverad undviker registrering av en enda omvänd förflyttning när dragningen slutförs fortfarande separata historikposter för varje rörelse. En explicit undergrupp kan samla förflyttningen, men den garanterar inte att den står oberoende av en omslutande grupp. Om andra ändringar ingår i samma omslutande grupp kan de ångras tillsammans. För att garantera en oberoende transaktion på toppnivå krävs samordning av ägaren till den delade historiken, inte att en underordnad vy stänger grupper.

Bygg det fullständiga SwiftUI-exemplet

Skapa ett macOS SwiftUI-approjekt med macOS 14 eller senare som distributionsmål. Ersätt den genererade appdeklarationen och innehållsvyn med de två filerna nedan. Behåll bara en @main-deklaration. Exemplet använder lokala knappar för Undo och Redo för att göra ägarskapet tydligt; integrering med programmets menyer och svararkedja är ett separat integrationssteg.

Modellen använder en reversibel sättare: fånga den aktuella positionen, registrera en avslutning som återställer den och tillämpa sedan ersättningsvärdet. När avslutningen körs vid ångring registrerar samma sättare den omvända ändringen som en gör om-åtgärd. Apple visar detta mönster med ömsesidig registrering i Registrera ångraåtgärder. Den aktuella dokumentationen anger också att UndoManager och dess registreringshanterare är isolerade till huvudaktören, så exemplet använder @MainActor för modellen. Se UndoManager.

Placera följande fullständiga modell i 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 ligger avsiktligt i samma synkrona omfång som beginUndoGrouping(). Swift kör uppskjutna åtgärder när omfånget avslutas, även vid en tidig retur eller ett kastat fel. Det kan inte sträcka sig över separat anropade geståteranrop. Den här parningen håller modellens grupp balanserad när kontrollflödet avslutas normalt. Se Kontrollflöde — uppskjutna åtgärder.

Ångraavslutningen arbetar på parametern target i stället för att fånga self. Apple dokumenterar att hanteraren har en icke-ägande referens till målet och rekommenderar att den angivna parametern används för att undvika retain-cykler. Canvasen behåller modellen medan den används, och modellen äger sin historik. Se registerUndo(withTarget:handler:).

Placera appen och canvasen i 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)
    }
}

Gesten använder ett namngivet koordinatutrymme för canvasen, så förflyttningen mäts i förhållande till arbetsytan i stället för den rörliga cirkeln. Apple definierar translation som den totala förflyttningen från gestens startpunkt. Tilldela den till förhandsvisningen; lägg inte upprepade gånger till den i modellen under rörelsen. Se DragGesture och translation.

Det valfria gesttillståndet visar också om en förhandsvisning är aktiv. Båda de lokala historikknapparna är inaktiverade under förhandsvisningen. När dragningen avslutas utan avbrott tillhandahåller det slutliga gestvärdet den förflyttning som ska bekräftas. Vid avbrytning återställs gesttillståndet och den lagrade mittpunkten förblir oförändrad; ingen öppen grupp behöver städas upp.

Det här exemplet har avsiktligt en enda skrivare. Inget annat kommando ändrar cirkeln under en dragning. Om du lägger till tangentbordsförflyttning, borttagning eller andra redigeringskommandon definierar du om varje kommando ska vara otillgängligt under dragningen eller först avbryta förhandsvisningen. Låt alla kommandon följa den policyn; det räcker inte att inaktivera bara de här två knapparna för att styra ytterligare kommandovägar.

Verifiera en dragning, en ångring och en fungerande gör om

Använd illustrativa koordinater för att granska beräkningen. Med start vid (120, 100) ger kumulativa förflyttningar på (10, 5), (25, 12) och (40, 20) förhandsvisningsmittpunkterna (130, 105), (145, 112) och (160, 120). Den bekräftade mittpunkten förblir (120, 100) fram till slutförandet och ändras sedan en gång till (160, 120).

Ångring anropar den registrerade sättaren med (120, 100). Innan den återställs fångar sättaren (160, 120) och registrerar den positionen för att kunna göra om. Förhindra inte registrering inuti ångraåteranropet: den ömsesidiga registreringen är det som bevarar möjligheten att göra om. Apple beskriver detta beteende i Registrera ångraåtgärder.

Koden skapar ingen extra explicit grupp inuti den reversibla sättaren. UndoManager styr grupperingen när historiken spelas upp igen; Apple dokumenterar att redo() registrerar återspelade operationer på ångrakön som en enda grupp. Åtgärdsnamnet tilldelas via setActionName med den syntetiska etiketten Move Shape. Se redo() och setActionName(_:).

Kontrollera följande beteenden i Mac-appen:

  1. Slutför en dragning med flera riktningsändringar. En enda Undo ska återställa startpositionen.
  2. Välj Redo. Formen ska återgå till slutpositionen från den slutförda dragningen.
  3. Slutför två separata dragningar. Två ångraåtgärder ska återställa de två tidigare positionerna i omvänd ordning.
  4. Avsluta en registrerad dragning vid dess startposition. Skyddet mot oförändrade värden ska förhindra att en historikpost skapas.
  5. Prova en avbrytning eller en väg där vyn tas bort i den omslutande appen. Förhandsvisningen ska försvinna utan att en förflyttning bekräftas.
  6. Ångra en förflyttning och slutför sedan en annan förflyttning. Den tidigare grenen för att göra om ska försvinna; Apple dokumenterar att en ny ångraregistrering tömmer gör om-stacken.

Det sista beteendet följer Apples Undo Manager-arkitektur. Koordinatberäkningen ovan kontrollerades med en liten deterministisk Python-beräkning. Swift-källkoden granskades men kompilerades eller kördes inte här eftersom den här miljön saknar Swift-kompilator och macOS SDK.

Använd samma gränser i en AppKit-canvas

För en befintlig NSView bevarar du AppKits händelsesekvens. Apples aktuella referens definierar mouseDragged(with:) som förflyttning medan vänster musknapp hålls nere och mouseUp(with:) som när den knappen släpps. Apples arkiverade händelseguide beskriver sekvensen med tre metoder och koordinatomvandlingen som behövs i en anpassad vy. Se mouseDragged(with:), mouseUp(with:) och Hantera mushändelser.

Anpassa samma utformning till dessa metoder:

  • I mouseDown(with:) träfftestar du den syntetiska formen och sparar dess startmittpunkt samt pekarens position omvandlad till canvasens koordinater.
  • I mouseDragged(with:) beräknar du förflyttningen från den sparade pekarpositionen och ritar om en tillfällig förhandsvisning.
  • I mouseUp(with:) beräknar du den slutliga förflyttningen, rensar spårningstillståndet och anropar modellens synkrona bekräftelsemetod en gång.
  • I canvasägarens avbrytnings- eller nedmonteringsväg kastar du bort förhandsvisningen och rensar spårningstillståndet utan att bekräfta.

Använd en enda händelseägare för interaktionen. Om SwiftUI bäddar in AppKit-canvasen väljer du antingen SwiftUI-gesten eller AppKit-mushanterarna för att styra rörelsen och bekräftelsen. Om båda registrerar samma förflyttning skapas dubbla operationer. Håll ritkoordinaterna konsekventa, inklusive eventuella vända koordinatsystem eller canvastransformer.

Apples arkiverade guide förklarar också att tangentbordskommandon kan skickas medan spårningsmetoden med tre steg pågår. All menyintegrering måste därför följa samma policy för aktiv dragning som de lokala knapparna. Låt dokumentägaren behålla dokumenthistoriken och se till att menyåtgärder riktas till den ägarens hanterare.

Felsök grupperingsproblem utan att stänga någon annans grupp

Om en dragning skapar många ångersteg kontrollerar du om modellsättare registrerar ändringar under rörelseuppdateringarna. Flytta dessa registreringar till den slutliga bekräftelsen. Om flera dragningar ångras tillsammans granskar du hanterarens policy för automatisk gruppering och eventuella omslutande grupper som ägaren har skapat. Om det inte går att göra om efter ångring granskar du om återställningsvägen registrerade den omvända operationen.

Om grupperingen utlöser ett undantag granskar du ägarskapet innan du ändrar antalet stängningsanrop. Apple dokumenterar att groupingLevel == 0 betyder att ingen grupp är öppen, och att undo() utlöser ett undantag när fler än en grupp är öppen. En loop som stänger grupper tills nivån når noll kan stänga grupper som tillhör en annan anropare. Använd nivån som diagnostik och följ ägarens kontrakt. Se groupingLevel och undo().

För den här canvasen i minnet är den användbara gränsen den bekräftade förflyttningen: förhandsvisa fritt, bekräfta en gång, registrera en reversibel ändring och stäng gruppen innan du returnerar. Då ger varje avslutad dragning ett ångersteg samtidigt som avbrytning, händelsehantering och historikägarskap förblir tydliga.

Källor