Register undo operations against a long-lived model store, and let views submit edits through store methods. For a Mac shape editor, keep drag previews local, commit once on release, and have the store register the inverse that also makes redo possible.
This tutorial builds a small macOS SwiftUI app containing one synthetic rectangle. You can move it, hide its editor, undo the move, and show the editor again. The concrete task is to preserve undo behavior when an editing view disappears while its model remains alive. Everything stays in memory.
Why should the store receive the undo operation?
Apple’s closure-based registration method requires a reference-type target. It also documents that the undo manager maintains an unowned reference to that target. Therefore, registering an action does not establish the target’s lifetime. Apple recommends using the handler’s target parameter instead of capturing another reference to the same target. Apple’s undo registration documentation.
A store that owns the edited state is a suitable target. A stable owner keeps that store alive; temporary views display it and request changes. Here, the window’s root view owns the store with @StateObject, while the removable canvas observes it. SwiftUI preserves a state object during its declaring container’s lifetime, but changing the container’s identity can create a new instance. Apple’s StateObject documentation.
“Store-owned inverses” describes where the reversal logic lives. The undo manager still records and schedules the callbacks. The store supplies the old value and the method that restores it. This division follows Apple’s documented architecture, which allows the undo recipient to be either the changed object or its owner. Apple’s Undo Manager guide.
| Responsibility | Owner in this example |
|---|---|
| Committed rectangle position | Model store |
| Inverse registration and mutation | Model store method |
| Store lifetime | Stable window root |
| Temporary drag translation | Canvas gesture state |
| Undo history and execution | Environment undo manager |
| Selecting the applicable manager | View at the interaction boundary |
Implement a reversible store mutation
Create a macOS SwiftUI app with a deployment target of macOS 13 or later and a current Apple SDK. The following three files form the complete example; replace the generated app entry point and content view rather than keeping a second @main declaration. The Swift compiler and macOS frameworks are unavailable in this research environment, so this example has been inspected but not compiled here.
Create ShapeStore.swift. The store has one committed position and one mutation method. Capturing the previous position before assignment gives the method the information needed to reverse the edit.
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
}
}
The inverse calls the same method. Suppose the position changes from (120, 100) to (160, 100). The registered callback requests (120, 100). When undo executes that callback, the method sees (160, 100) as its previous value and registers a return trip. Apple documents that registrations made while undoing become redo operations. Skipping registration whenever isUndoing is true would break this pattern. Apple’s Undo Manager guide.
The closure operates on its store parameter and captures the manager weakly. This avoids a strong reference from a manager’s stored closure back to that manager. Swift’s reference-counting documentation explains that weak references do not keep their referenced instance alive and become nil when it is deallocated. The Swift Programming Language: Automatic Reference Counting.
The store and its mutation method are main-actor isolated. This matches current Apple documentation: UndoManager and the registration handler are main-actor isolated. The method performs registration and mutation synchronously, keeping this small edit within its calling interaction. Apple’s UndoManager documentation.
Keep the store above the removable editor
Create StoreUndoDemoApp.swift. The root owns the store regardless of whether the canvas is visible. Its buttons read the current environment manager when an action runs. The canvas receives that same manager for its edits.
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)
}
}
SwiftUI’s environment undo manager is optional. Apple explicitly permits skipping undo registration when the environment supplies nil. This example still moves the rectangle in that situation, but creates no undo entry. If an expected undo operation is absent, inspect whether the interaction received a manager before changing the inverse logic. Apple’s environment undoManager documentation.
Passing the manager into each mutation also keeps window context explicit. The store does not retain whichever window manager happened to appear first. For this example, each window root owns its own store. If several windows later edit one shared model, decide which history owns those edits before reusing this arrangement across windows.
Commit one drag without stretching an undo group across events
Create ShapeCanvas.swift. During dragging, the rectangle’s displayed position includes a temporary translation. Its committed store position changes only when the gesture ends successfully. Complete a drag before invoking another edit or undo command; this compact example assumes sequential interactions and does not arbitrate simultaneous commands during a drag.
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()
}
}
The gesture uses its final translation to commit the move. It does not depend on the reset timing of the preview property. Apple documents that updating(_:body:) resets gesture state when the gesture ends or is cancelled, while onEnded(_:) runs only after successful completion. Consequently, a cancelled gesture in this design leaves no committed movement and no new undo action. Apple’s gesture updating documentation, Apple’s gesture completion documentation.
This preserves three separate boundaries: a preview update changes transient display state; successful completion requests one model edit; the store registers that edit’s inverse. Calling the store mutation on every drag update would instead register intermediate positions throughout the gesture.
Apple’s default event grouping collects registrations around passes of the run loop. A drag can produce updates across multiple passes, so event grouping alone does not express the intended whole-drag transaction. Here, committing once avoids that problem without changing the shared manager’s configuration. Apple’s groupsByEvent documentation.
For a future command that synchronously changes several items, an explicit, balanced undo group can collect its inverses. Keep its beginning and end within that command. If live model mutation during dragging becomes necessary, design separate begin, update, commit, and cancel operations, including how other commands interact with the active edit. The preview-only approach here deliberately keeps that additional transaction machinery out of the store.
Verify ownership, redo, and window routing separately
Use the following sequence with the running app. The coordinates are illustrative expected values from the supplied initial state and button increment, not measured results.
| Action | Expected committed position or result |
|---|---|
| Launch a new window | (120, 100) |
Click Move Right |
(140, 100) |
Turn off Show Editor |
Canvas disappears; position remains (140, 100) |
Click Undo |
Position becomes (120, 100) while canvas remains absent |
Click Redo |
Position becomes (140, 100) |
Turn on Show Editor |
Rectangle appears at the redone position |
Complete a drag, then click Undo |
Rectangle returns to its position before that drag |
The hidden-editor check distinguishes model ownership from presentation lifetime. If undo works only while a particular panel exists, inspect the target registered with the manager and the object that retains it. If undo works but redo does nothing, inspect whether the inverse registers the return operation. If one drag takes many undos, inspect whether preview updates are reaching the store’s undoable mutation.
The explicit buttons make the mutation path easy to inspect. For normal Mac menu behavior, also test the standard Edit menu with the intended window active. AppKit’s base responder obtains its undo manager from the next responder. When a window asks its delegate for a manager, windowWillReturnUndoManager(_:) is the documented customization point; without that implementation, the window creates its own manager. Registering on an unrelated manager does not establish the intended responder-chain routing. Apple’s NSResponder undoManager documentation, Apple’s window undo-manager delegate documentation.
A store also needs a deliberate retirement boundary. If you replace a store while its manager continues to exist, remove that store’s registrations before releasing it, using removeAllActions(withTarget:) on each manager holding its actions. Do not clear history merely because the canvas disappears: keeping the store’s edits available during that disappearance is the behavior being built. Apple’s target-specific undo cleanup documentation.
This example has one fixed rectangle and no deletion path. When extending it to a collection, capture stable item identifiers and the values needed to reverse each operation. Deletion needs an inverse that can restore the item and its intended ordering; an old array index alone is insufficient if the collection can change.
The completed design gives the root responsibility for lifetime, the canvas responsibility for temporary interaction state, and the store responsibility for reversible edits. Hiding the canvas leaves those edits available because their inverse methods operate on the still-live store.
Sources
- registerUndo(withTarget:handler:) — Apple
- StateObject — Apple
- Undo Manager — Apple
- Automatic Reference Counting — Swift.org
- UndoManager — Apple
- undoManager — EnvironmentValues — Apple
- updating(_:body:) — Apple
- onEnded(_:) — Apple
- groupsByEvent — Apple
- undoManager — NSResponder — Apple
- windowWillReturnUndoManager(_:) — Apple
- removeAllActions(withTarget:) — Apple