Registreer undo-bewerkingen bij een langlevende modelopslag en laat views wijzigingen indienen via methoden van de opslag. Houd in een Mac-vormeditor sleepvoorbeelden lokaal, leg de wijziging één keer vast bij het loslaten en laat de opslag de inverse registreren die ook redo mogelijk maakt.

Deze tutorial bouwt een kleine macOS SwiftUI-app met één synthetische rechthoek. Je kunt deze verplaatsen, de editor verbergen, de verplaatsing ongedaan maken en de editor weer tonen. De concrete taak is het behoud van undo-gedrag wanneer een bewerkingsview verdwijnt terwijl het model blijft bestaan. Alles blijft in het geheugen.

Waarom moet de opslag de undo-bewerking ontvangen?

Apple’s registratiemethode op basis van closures vereist een target van een referentietype. Apple documenteert ook dat de undo-manager een niet-beheerde referentie naar dat target bijhoudt. Het registreren van een actie verlengt dus niet de levensduur van het target. Apple raadt aan de targetparameter van de handler te gebruiken in plaats van een andere referentie naar hetzelfde target vast te leggen. Apple’s documentatie over undo-registratie.

Een opslag die de bewerkte status beheert, is een geschikt target. Een stabiele eigenaar houdt die opslag in leven; tijdelijke views tonen deze en vragen om wijzigingen. Hier beheert de rootview van het venster de opslag met @StateObject, terwijl het verwijderbare canvas deze observeert. SwiftUI behoudt een state-object gedurende de levensduur van de container waarin het is gedeclareerd, maar als de identiteit van die container verandert, kan er een nieuwe instantie worden gemaakt. Apple’s documentatie over StateObject.

‘Inverses die door de opslag worden beheerd’ beschrijft waar de terugdraaillogica staat. De undo-manager registreert en plant de callbacks nog steeds in. De opslag levert de oude waarde en de methode die deze herstelt. Deze verdeling volgt de door Apple gedocumenteerde architectuur, waarin de ontvanger van undo het gewijzigde object of de eigenaar ervan kan zijn. Apple’s gids over Undo Manager.

Verantwoordelijkheid Eigenaar in dit voorbeeld
Vastgelegde positie van de rechthoek Modelopslag
Registratie van inverses en mutatie Methode van de modelopslag
Levensduur van de opslag Stabiele window-root
Tijdelijke verplaatsing tijdens slepen Gesture-status van het canvas
Undo-geschiedenis en uitvoering Undo-manager uit de omgeving
De toepasselijke manager selecteren View op de interactiegrens

Een omkeerbare mutatie van de opslag implementeren

Maak een macOS SwiftUI-app met een deployment target van macOS 13 of hoger en een actuele Apple SDK. De volgende drie bestanden vormen het volledige voorbeeld; vervang het gegenereerde app-entrypoint en de contentview in plaats van een tweede @main-declaratie te behouden. De Swift-compiler en macOS-frameworks zijn in deze onderzoeksomgeving niet beschikbaar, dus dit voorbeeld is nagekeken maar hier niet gecompileerd.

Maak ShapeStore.swift. De opslag heeft één vastgelegde positie en één mutatiemethode. Door de vorige positie vóór toewijzing vast te leggen, beschikt de methode over de informatie die nodig is om de wijziging terug te draaien.

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
    }
}

De inverse roept dezelfde methode aan. Stel dat de positie verandert van (120, 100) naar (160, 100). De geregistreerde callback vraagt om (120, 100). Wanneer undo die callback uitvoert, ziet de methode (160, 100) als vorige waarde en registreert een terugkeer. Apple documenteert dat registraties die tijdens undo worden gemaakt, redo-bewerkingen worden. Registratie overslaan wanneer isUndoing waar is, zou dit patroon doorbreken. Apple’s gids over Undo Manager.

De closure werkt met de parameter store en legt de manager zwak vast. Zo ontstaat er geen sterke verwijzing van een opgeslagen closure van de manager terug naar diezelfde manager. De documentatie van Swift over referentietelling legt uit dat zwakke verwijzingen hun doelinstantie niet in leven houden en nil worden wanneer die wordt gedealloceerd. De programmeertaal Swift: automatisch beheer van referentietelling.

De opslag en de mutatiemethode zijn geïsoleerd op de main actor. Dit komt overeen met de actuele documentatie van Apple: UndoManager en de registratiehandler zijn geïsoleerd op de main actor. De methode voert registratie en mutatie synchroon uit, zodat deze kleine wijziging binnen de aanroepende interactie blijft. Apple’s documentatie over UndoManager.

Plaats de opslag boven de verwijderbare editor

Maak StoreUndoDemoApp.swift. De root beheert de opslag, ongeacht of het canvas zichtbaar is. De knoppen lezen de huidige omgevingsmanager wanneer een actie wordt uitgevoerd. Het canvas krijgt diezelfde manager voor zijn bewerkingen.

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)
    }
}

De undo-manager uit de SwiftUI-omgeving is optioneel. Apple staat expliciet toe dat undo-registratie wordt overgeslagen wanneer de omgeving nil levert. Dit voorbeeld verplaatst de rechthoek dan nog steeds, maar maakt geen undo-item aan. Als een verwachte undo-bewerking ontbreekt, controleer dan of de interactie een manager heeft ontvangen voordat je de inverselogica wijzigt. Apple’s documentatie over de undoManager van EnvironmentValues.

Door de manager aan elke mutatie door te geven, blijft ook de windowcontext expliciet. De opslag bewaart niet de manager van het eerste venster dat toevallig voorbijkwam. In dit voorbeeld beheert de root van elk venster een eigen opslag. Als meerdere vensters later één gedeeld model bewerken, bepaal dan welke geschiedenis eigenaar is van die bewerkingen voordat je deze opzet voor meerdere vensters hergebruikt.

Leg één sleepactie vast zonder een undo-groep over meerdere events uit te spreiden

Maak ShapeCanvas.swift. Tijdens het slepen bevat de weergegeven positie van de rechthoek een tijdelijke verplaatsing. De vastgelegde positie in de opslag verandert pas wanneer de gesture succesvol eindigt. Rond een sleepactie af voordat je een andere bewerking of undo-opdracht uitvoert; dit compacte voorbeeld gaat uit van opeenvolgende interacties en regelt geen gelijktijdige opdrachten tijdens een sleepactie.

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()
    }
}

De gesture gebruikt de uiteindelijke verplaatsing om de wijziging vast te leggen. De code is niet afhankelijk van het moment waarop de preview-property wordt gereset. Apple documenteert dat updating(_:body:) de gesture-status reset wanneer de gesture eindigt of wordt geannuleerd, terwijl onEnded(_:) alleen wordt uitgevoerd na succesvolle voltooiing. Een geannuleerde gesture leidt in dit ontwerp dus niet tot een vastgelegde verplaatsing en ook niet tot een nieuwe undo-actie. Apple’s documentatie over gesture-updates, Apple’s documentatie over het voltooien van gestures.

Dit bewaart drie afzonderlijke grenzen: een preview-update wijzigt tijdelijke weergavestatus; succesvolle voltooiing vraagt om één modelbewerking; de opslag registreert de inverse van die bewerking. Als je de mutatie van de opslag bij elke update van het slepen aanroept, worden in plaats daarvan tijdens de gesture tussenposities geregistreerd.

De standaardgroepering per event van Apple verzamelt registraties rond iteraties van de runloop. Een sleepactie kan updates over meerdere iteraties opleveren, dus alleen groepering per event drukt niet de bedoeling uit om de hele sleepactie als één transactie te behandelen. Door hier één keer vast te leggen, vermijden we dat probleem zonder de configuratie van de gedeelde manager te wijzigen. Apple’s documentatie over groupsByEvent.

Voor een toekomstige opdracht die synchroon meerdere items wijzigt, kan een expliciete, correct gebalanceerde undo-groep de inverses verzamelen. Laat het begin en einde daarvan binnen die opdracht vallen. Als tijdens het slepen live mutatie van het model nodig wordt, ontwerp dan afzonderlijke bewerkingen voor starten, bijwerken, vastleggen en annuleren, inclusief hoe andere opdrachten omgaan met de actieve bewerking. De aanpak met alleen een preview houdt die extra transactielogica bewust buiten de opslag.

Verifieer eigenaarschap, redo en vensterroutering afzonderlijk

Voer de volgende stappen uit met de draaiende app. De coördinaten zijn verwachte, illustratieve waarden op basis van de opgegeven beginstatus en knopstap, geen gemeten resultaten.

Actie Verwachte vastgelegde positie of resultaat
Open een nieuw venster (120, 100)
Klik op Move Right (140, 100)
Schakel Show Editor uit Canvas verdwijnt; positie blijft (140, 100)
Klik op Undo Positie wordt (120, 100) terwijl het canvas verborgen blijft
Klik op Redo Positie wordt (140, 100)
Schakel Show Editor in Rechthoek verschijnt op de opnieuw uitgevoerde positie
Voltooi een sleepactie en klik daarna op Undo Rechthoek keert terug naar de positie van vóór die sleepactie

De controle met de verborgen editor maakt het verschil zichtbaar tussen model-eigenaarschap en de levensduur van de presentatie. Als undo alleen werkt terwijl een bepaald paneel bestaat, controleer dan het target dat bij de manager is geregistreerd en het object dat dit target vasthoudt. Als undo werkt maar redo niets doet, controleer dan of de inverse de terugkeeractie registreert. Als je voor één sleepactie veel keren undo moet uitvoeren, controleer dan of preview-updates de undo-bare mutatie van de opslag bereiken.

Met de expliciete knoppen is het mutatiepad eenvoudig te inspecteren. Test voor normaal Mac-menugedrag ook het standaardmenu Edit terwijl het bedoelde venster actief is. De basisklasse van AppKit’s responder haalt zijn undo-manager op bij de volgende responder. Wanneer een venster de delegate om een manager vraagt, is windowWillReturnUndoManager(_:) het gedocumenteerde aanpassingspunt; zonder die implementatie maakt het venster zijn eigen manager aan. Registreren bij een niet-gerelateerde manager zorgt niet voor de bedoelde routering via de responder-chain. Apple’s documentatie over undoManager van NSResponder, Apple’s documentatie over de delegate voor de undo-manager van een venster.

Ook voor een opslag is een weloverwogen moment nodig waarop deze wordt opgeruimd. Als je een opslag vervangt terwijl de manager blijft bestaan, verwijder dan de registraties van die opslag voordat je deze vrijgeeft, met removeAllActions(withTarget:) op elke manager die de acties ervan bevat. Wis de geschiedenis niet alleen omdat het canvas verdwijnt: het behoud van de wijzigingen van de opslag terwijl het canvas verdwijnt, is precies het gedrag dat we hier implementeren. Apple’s documentatie over targetgerichte opruiming van undo-acties.

Dit voorbeeld heeft één vaste rechthoek en geen verwijderfunctie. Leg bij uitbreiding naar een verzameling stabiele item-ID’s en de benodigde waarden vast om elke bewerking terug te draaien. Voor verwijdering is een inverse nodig die het item en de bedoelde volgorde kan herstellen; alleen een oude array-index volstaat niet als de verzameling kan veranderen.

In het voltooide ontwerp beheert de root de levensduur, het canvas de tijdelijke interactiestatus en de opslag de omkeerbare bewerkingen. Als het canvas wordt verborgen, blijven die bewerkingen beschikbaar omdat hun inverse-methoden op de nog bestaande opslag werken.

Bronnen