Toon tijdens het slepen van een vorm in je Mac-app de verplaatsing als voorbeeld en leg de eindpositie vast in één korte undo-groep. Houd het model en de undo-manager onder één eigenaar, sluit elke geopende groep en registreer de omgekeerde verplaatsing zodat redo ook werkt.

Deze tutorial bouwt een kleine SwiftUI-canvas met één synthetische cirkel die in het geheugen staat. De concrete taak is ervoor te zorgen dat één voltooide versleping precies één undo vereist, ongeacht hoeveel verplaatsingsupdates er binnenkomen. Ook wordt uitgelegd hoe je dezelfde grenzen behoudt in een AppKit-weergave.

Waarom een undo-groep niet open moet blijven tijdens het slepen

Een versleping is een interactie die zich in de loop van de tijd ontvouwt. Een undo-groep verzamelt omkeerbare bewerkingen. Die grenzen hoeven niet dezelfde levensduur te hebben. Apple documenteert dat groupsByEvent standaard is ingesteld op true en bewerkingen groepeert rond passages van de runloop; dat gedrag definieert geen volledige versleeptransactie. Expliciete groepen kunnen ook binnen bestaande groepen genest zijn. Zie Apples documentatie over automatische gebeurtenisgroepering en undo-groepen beginnen.

Een groep openen in de eerste SwiftUI-verplaatsingscallback en sluiten in onEnded laat een gat bij annulering. Apple stelt expliciet dat onEnded alleen wordt uitgevoerd wanneer het gebaar succesvol eindigt. Ter vergelijking: status die via updating wordt bijgehouden, wordt gereset wanneer het gebaar eindigt of wordt geannuleerd. Een niet-afgesloten endUndoGrouping() veroorzaakt een uitzondering, dus raden of een callback een groep heeft geopend is ook ongeschikt. Zie onEnded(_:), updating(_:body:) en endUndoGrouping().

Gebruik voor deze canvas het volgende ontwerp. Dit is een implementatiekeuze die is afgeleid van die gedocumenteerde grenzen:

Interactiefase Weergavestatus Vastgelegd model Undo-groepering
Voor het slepen Opgeslagen middelpunt Oorspronkelijk middelpunt Gesloten
Verplaatsingsupdates Oorspronkelijk middelpunt plus tijdelijke verplaatsing Ongewijzigd Gesloten
Succesvolle voltooiing Eindpositie Eenmalig bijgewerkt Synchroon geopend en gesloten
Annulering Voorbeeld wordt gereset Ongewijzigd Nooit geopend
Undo of redo Hersteld middelpunt Bijgewerkt via de omkeerbare setter Beheerd door UndoManager

Met deze aanpak kan de cirkel vloeiend bewegen zonder elke voorbeeldweergave als een permanente bewerking te behandelen. Een voltooide versleping wordt één benoemde modelbewerking.

Bepaal wie de undo-geschiedenis beheert

In het voorbeeld heeft elke canvas een eigen model en privé-undo-manager. Omdat die eigenaar elke registratie beheert, stelt die groupsByEvent = false één keer in tijdens de initialisatie en groepeert elke vastgelegde verplaatsing expliciet. Apple vereist dat groepen expliciet worden gesloten voordat undo wordt uitgevoerd wanneer automatische groepering is uitgeschakeld. Het voorbeeld sluit de groep voordat de commitmethode terugkeert. Zie groupsByEvent.

Een bestaande documenteditor heeft mogelijk al een undo-manager voor het document. SwiftUI biedt toegang tot de contextuele manager via @Environment(\.undoManager), en Apple documenteert dat de waarde nil kan zijn als de context geen undo ondersteunt. Blijf in die architectuur de manager van het document en het bestaande groeperingsbeleid gebruiken; pas de configuratie van de privémanager uit dit voorbeeld niet toe op een gedeelde manager. Zie EnvironmentValues.undoManager.

Met een gedeelde manager en ingeschakelde automatische groepering voorkom je nog steeds geschiedenisitems voor elke afzonderlijke verplaatsing door bij voltooiing één omgekeerde verplaatsing te registreren. Een expliciete subgroep kan de verplaatsing bundelen, maar maakt die niet onafhankelijk van een omvattende groep. Als andere bewerkingen dezelfde omvattende groep delen, kunnen ze samen ongedaan worden gemaakt. Om een onafhankelijke transactie op het hoogste niveau te garanderen, is coördinatie nodig door de eigenaar van de gedeelde geschiedenis; een onderliggende weergave kan die niet garanderen door groepen te sluiten.

Bouw het volledige SwiftUI-voorbeeld

Maak een macOS SwiftUI-appproject met een implementatiedoel van macOS 14 of hoger. Vervang de gegenereerde appdeclaratie en contentweergave door de twee onderstaande bestanden. Behoud slechts één @main-declaratie. Het voorbeeld gebruikt lokale knoppen Undo en Redo om het eigenaarschap expliciet te maken; integratie met het applicatiemenu en de responder chain is een afzonderlijke integratiestap.

Het model gebruikt een omkeerbare setter: leg de huidige positie vast, registreer een closure die deze herstelt en pas vervolgens de vervangende positie toe. Wanneer die closure tijdens undo wordt uitgevoerd, registreert dezelfde setter de omgekeerde bewerking als redo. Apple demonstreert dit patroon van wederzijdse registratie in Undo-bewerkingen registreren. In de huidige documentatie zijn ook UndoManager en de registratiehandler ervan geïsoleerd tot de main actor; daarom gebruikt het voorbeeldmodel @MainActor. Zie UndoManager.

Plaats het volgende volledige model 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
    }
}

De defer staat bewust in dezelfde synchrone scope als beginUndoGrouping(). Swift voert uitgestelde acties uit wanneer die scope wordt verlaten, ook bij een vroege return of een gegooide fout. De scope kan zich niet uitstrekken over afzonderlijk aangeroepen gebaarcallbacks. Deze koppeling houdt de groepen van het model in balans bij normale uitgangen uit de control flow. Zie Control Flow — Uitgestelde acties.

De undo-closure werkt op de parameter target in plaats van self vast te leggen. Apple documenteert dat de manager een niet-beheerde verwijzing naar het doelobject vasthoudt en raadt aan de meegegeven parameter te gebruiken om retain cycles te voorkomen. De canvas houdt het model vast zolang het wordt gebruikt, en het model beheert zijn geschiedenis. Zie registerUndo(withTarget:handler:).

Plaats de app en canvas 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)
    }
}

Het gebaar gebruikt één benoemde canvas-coördinatenruimte, zodat de verplaatsing wordt gemeten ten opzichte van het bord en niet de bewegende cirkel. Apple definieert translation als de totale verplaatsing vanaf het begin van het gebaar. Wijs die toe aan het voorbeeld; tel die tijdens de verplaatsing niet steeds op bij het model. Zie DragGesture en translation.

De optionele gebaarstatus geeft ook aan of er een voorbeeld actief is. Beide lokale geschiedenisknoppen zijn tijdens dat voorbeeld uitgeschakeld. Bij een succesvolle voltooiing levert de uiteindelijke gebaarwaarde de vast te leggen verplaatsing. Bij annulering wordt de gebaarstatus gereset en blijft het opgeslagen middelpunt ongewijzigd; er is geen open groep die moet worden opgeruimd.

Dit voorbeeld heeft bewust één enkele schrijver. Geen andere opdracht wijzigt de cirkel tijdens een versleping. Als je toetsenbordverplaatsing, verwijderen of andere bewerkingsopdrachten toevoegt, bepaal dan of elke opdracht tijdens het slepen niet beschikbaar is of eerst het voorbeeld annuleert. Leid alle opdrachten via dat beleid; alleen deze twee knoppen uitschakelen beheerst geen extra opdrachtpaden.

Controleer of één versleping één undo oplevert en redo werkt

Gebruik ter illustratie coördinaten om de berekening te controleren. Vanaf (120, 100) leveren cumulatieve verplaatsingen van (10, 5), (25, 12) en (40, 20) voorbeeldmiddelpunten op van (130, 105), (145, 112) en (160, 120). Het vastgelegde middelpunt blijft (120, 100) tot de voltooiing; daarna verandert het één keer in (160, 120).

Undo voert de geregistreerde setter uit met (120, 100). Voordat die de positie herstelt, legt de setter (160, 120) vast en registreert die positie voor redo. Onderdruk registratie niet in de undo-callback: die wederzijdse registratie zorgt ervoor dat redo behouden blijft. Apple beschrijft dit gedrag in Undo-bewerkingen registreren.

De code maakt geen extra expliciete groep in de omkeerbare setter. UndoManager beheert de groepering tijdens het afspelen van de geschiedenis; Apple documenteert dat redo() opnieuw afgespeelde bewerkingen als één groep op de undo-stack registreert. De actienaam wordt ingesteld via setActionName, met het synthetische label Move Shape. Zie redo() en setActionName(_:).

Controleer dit gedrag in de Mac-app:

  1. Voltooi één versleping met meerdere richtingsveranderingen. Eén Undo moet de oorspronkelijke positie herstellen.
  2. Kies Redo. De vorm moet terugkeren naar de eindpositie van de voltooide versleping.
  3. Voltooi twee afzonderlijke verslepingen. Twee undo-acties moeten de twee eerdere posities in omgekeerde volgorde herstellen.
  4. Eindig een herkende versleping op de beginpositie. De controle op ongewijzigde waarden moet voorkomen dat er een geschiedenisitem wordt aangemaakt.
  5. Probeer in de omvattende app een annulerings- of weergaveverwijderingspad. Het voorbeeld moet verdwijnen zonder vastgelegde verplaatsing.
  6. Maak een verplaatsing ongedaan en voltooi daarna een andere verplaatsing. De eerdere redo-tak moet verdwijnen; Apple documenteert dat een nieuwe undo-registratie de redo-stack leegt.

Dit laatste gedrag volgt uit Apples Undo Manager-architectuur. De coördinatenberekening hierboven is gecontroleerd met een kleine deterministische Python-berekening. De Swift-broncode is bekeken, maar is hier niet gecompileerd of uitgevoerd omdat deze omgeving geen Swift-compiler of macOS SDK heeft.

Pas dezelfde grenzen toe op een AppKit-canvas

Behoud voor een bestaande NSView de AppKit-gebeurtenisvolgorde. De huidige referentie van Apple definieert mouseDragged(with:) als verplaatsing terwijl de linkermuisknop is ingedrukt en mouseUp(with:) als het loslaten van die knop. De gearchiveerde gebeurtenisgids beschrijft de reeks van drie methoden en de coördinatenconversie die een aangepaste weergave nodig heeft. Zie mouseDragged(with:), mouseUp(with:) en Muisevenementen afhandelen.

Pas hetzelfde ontwerp toe op die methoden:

  • In mouseDown(with:) controleer je via hit-testing of de synthetische vorm geraakt is en sla je het beginmiddelpunt op, samen met de aanwijzerlocatie omgerekend naar canvascoördinaten.
  • Bereken in mouseDragged(with:) de verplaatsing vanaf die opgeslagen aanwijzerlocatie en teken een tijdelijke voorbeeldweergave opnieuw.
  • Bereken in mouseUp(with:) de uiteindelijke verplaatsing, wis de trackingstatus en roep de synchrone commitmethode van het model één keer aan.
  • Verwerp in het annulerings- of afbouwpad van de canvaseigenaar het voorbeeld en wis de trackingstatus zonder iets vast te leggen.

Gebruik één gebeurteniseigenaar voor de interactie. Als SwiftUI de AppKit-canvas insluit, kies dan óf het SwiftUI-gebaar óf de AppKit-muishandlers om de verplaatsing en vastlegging aan te sturen. Als beide dezelfde verplaatsing registreren, ontstaan dubbele bewerkingen. Houd de tekencoördinaten consistent, inclusief een eventueel omgekeerd coördinatensysteem of canvastransformatie.

Apples gearchiveerde gids legt ook uit dat toetsenbordopdrachten kunnen worden verzonden tijdens het volgen met de reeks van drie methoden. Elke menu-integratie moet daarom hetzelfde beleid voor actief slepen volgen als de lokale knoppen. Houd de documentgeschiedenis bij de eigenaar van het document en zorg ervoor dat menuacties de manager van die eigenaar aanroepen.

Problemen met groepering opsporen zonder de groep van iemand anders te sluiten

Als een versleping veel undo-stappen oplevert, controleer dan of modelsetters tijdens verplaatsingsupdates registreren. Verplaats die registraties naar de uiteindelijke vastlegging. Als meerdere verslepingen samen ongedaan worden gemaakt, controleer dan het automatische groeperingsbeleid van de manager en eventuele omvattende groepen die door de eigenaar zijn aangemaakt. Als redo na undo niet beschikbaar is, controleer dan of het herstelpad de omgekeerde bewerking heeft geregistreerd.

Als groepering een uitzondering veroorzaakt, controleer dan eerst het eigenaarschap voordat je het aantal sluitingsaanroepen wijzigt. Apple documenteert dat groupingLevel == 0 betekent dat er geen groep open is en dat undo() een uitzondering veroorzaakt wanneer er meer dan één groep open blijft. Een lus die groepen sluit totdat het niveau nul bereikt, kan groepen sluiten die bij een andere aanroeper horen. Gebruik het niveau als diagnostisch middel en handhaaf het contract van de eigenaar. Zie groupingLevel en undo().

Voor deze canvas die in het geheugen bestaat, is de vastgelegde verplaatsing de bruikbare grens: toon vrij een voorbeeld, leg één keer vast, registreer een omkeerbare wijziging en sluit de groep voordat je terugkeert. Zo krijgt elke voltooide versleping één undo-stap, terwijl annulering, gebeurtenisafhandeling en geschiedeniseigenaarschap expliciet blijven.

Bronnen