For a small in-memory Mac shape editor, start with value-state snapshots. Use inverse commands for frequent, narrowly scoped edits when their smaller payload justifies the extra logic. Both approaches need one stable owner and a clear commit boundary for each user action.
This guide addresses one task: implementing predictable undo and redo for moving, deleting, and rearranging synthetic shapes in your own Swift editor. The decision framework below is an engineering recommendation derived from Apple’s APIs; Apple does not prescribe a universal snapshot-versus-command choice.
What should each undo operation remember?
A snapshot records the previous editable state, such as an ordered array of shapes. Undo restores that array. An inverse command records the operation needed to reverse a particular edit, such as setting one shape’s center back to its previous coordinates.
Both representations work through Foundation’s UndoManager. It records operations against an object or its owner; it does not automatically observe arbitrary assignments to your model. Your mutation methods must register the appropriate restoration work. UndoManager
Define the editable state before choosing its history representation. For this editor, it includes shape IDs, coordinates, and array order. Keep hover indicators and drag previews outside it. Decide explicitly whether selection belongs in history: restoring selection can make a deletion easier to understand, but it also means undo changes the current selection.
Use this decision table for each operation, rather than requiring the whole editor to use one representation:
| Operation or condition | Suggested representation | Information to retain |
|---|---|---|
| Small canvas; several properties change together | Snapshot | Complete previous editable value |
| Move one shape | Inverse setter | Stable shape ID and previous center |
| Delete one shape | Snapshot initially; inverse command if needed | For an inverse: complete removed shape and original position |
| Rearrange several shapes | Snapshot unless the inverse is straightforward | Previous ordering and any affected properties |
| Measured history memory cost is excessive | Targeted inverse commands | Everything required for exact restoration |
The useful question is: can you enumerate everything the inverse must restore? If deleting a shape also changes selection and ordering, remembering only the deleted ID is insufficient. A snapshot is often easier to audit when the operation affects several related values.
When is a snapshot actually independent?
Swift structures and arrays have value semantics. An array of shapes built entirely from value types can therefore represent an earlier state independently of later mutations. However, copying an array of class instances preserves references to those instances; changing an instance can also change what your supposed snapshot observes. Wrapping those references in a structure does not remove that issue. Value And Reference Types In Swift
For a small editor, use plain value data for history: identifiers, coordinates, dimensions, and other editable attributes. Keep views and mutable rendering objects outside the snapshot. If the live model uses classes, construct a separate value representation that contains every attribute needed to restore it.
Do not infer a fixed memory cost from the word “snapshot.” Measure retained history and edit latency with representative synthetic canvases. As an illustrative payload calculation, 100 entries each retaining 1,000 shape records represent 100,000 record occurrences; 100 single-shape inverse entries retain only 100 previous centers plus identifiers. Those counts are not measured bytes or a claim about Swift’s storage allocation.
Set a history depth deliberately. Apple documents that levelsOfUndo limits top-level undo groups, and its default value of zero means unlimited groups. A group limit is useful, but it does not impose a byte budget: a batch snapshot and a single-coordinate edit can retain very different amounts of data. levelsOfUndo
How do snapshots and inverse commands preserve redo?
The restoration method should register the opposite restoration before changing the model. When undo runs that method, its registration supplies redo. For a snapshot, capture the currently installed state before restoring the previous state. For a position command, capture the current center before installing the saved center.
Apple’s archived undo architecture guide explains this reciprocal-registration pattern explicitly: a setter invoked during undo registers the value needed for redo. Its examples use Objective-C; the complete Swift example below uses the current closure API. Registering Undo Operations
Do not suppress registration merely because isUndoing is true. In this design, doing so would remove the registration that creates redo. Also avoid replaying a new relative movement when restoring a position: storing the exact previous center makes the intended restoration explicit.
A complete model-level example
This standalone macOS program combines a snapshot-based deletion with an inverse position setter. It uses only synthetic in-memory values and Foundation, with no persistence or UI dependencies. Save it as EditorUndo.swift and compile using a current Swift 6 toolchain and macOS SDK:
swiftc -parse-as-library EditorUndo.swift -o EditorUndo
./EditorUndo
The program intentionally owns a dedicated manager and disables event grouping so its assertions do not depend on a running app event loop. Every initial edit receives an explicit group. The restoration methods do not open additional groups during undo or redo. Apple requires explicitly closing groups before undo when automatic grouping is disabled. groupsByEvent
import Foundation
struct Center: Equatable {
var x: Double
var y: Double
}
struct Item: Equatable {
let id: Int
var center: Center
}
@MainActor
final class Editor {
private(set) var items: [Item]
let history: UndoManager
init(items: [Item], history: UndoManager) {
precondition(Set(items.map(\.id)).count == items.count)
self.items = items
self.history = history
}
func replaceItems(_ next: [Item], name: String) {
precondition(Set(next.map(\.id)).count == next.count)
guard next != items else { return }
let previous = items
history.registerUndo(withTarget: self) { target in
target.replaceItems(previous, name: name)
}
items = next
history.setActionName(name)
}
func setCenter(id: Int, to next: Center) {
guard let index = items.firstIndex(where: { $0.id == id }) else {
preconditionFailure("History refers to a missing item")
}
let previous = items[index].center
guard previous != next else { return }
history.registerUndo(withTarget: self) { target in
target.setCenter(id: id, to: previous)
}
items[index].center = next
history.setActionName("Move Shape")
}
}
@main
struct Demo {
@MainActor
static func main() {
let history = UndoManager()
history.groupsByEvent = false
history.levelsOfUndo = 100
let original = [
Item(id: 1, center: Center(x: 20, y: 30)),
Item(id: 2, center: Center(x: 80, y: 30))
]
let editor = Editor(items: original, history: history)
func edit(_ operation: () -> Void) {
history.beginUndoGrouping()
defer { history.endUndoGrouping() }
operation()
}
edit {
editor.setCenter(id: 1, to: Center(x: 50, y: 40))
}
let moved = editor.items
edit {
editor.replaceItems(
editor.items.filter { $0.id != 2 },
name: "Delete Shape"
)
}
let deleted = editor.items
history.undo()
precondition(editor.items == moved)
history.undo()
precondition(editor.items == original)
history.redo()
precondition(editor.items == moved)
history.redo()
precondition(editor.items == deleted)
print("Undo and redo checks passed")
}
}
The expected output is Undo and redo checks passed. The example was inspected but not executed here because a Swift compiler was unavailable in the research environment. It demonstrates the history core; a SwiftUI interface also needs an observable model or adapter to refresh its views.
The closure uses its target parameter instead of capturing the editor directly. Apple documents that the registration target must be a reference type, that the manager keeps an unowned reference to it, and that using the closure parameter avoids a retain cycle through a captured target. registerUndo(withTarget:handler:)
The preconditions expose broken model assumptions instead of silently dropping an undo operation. A production command boundary should reject an invalid edit before registration and present an appropriate application-level failure. Missing IDs during history replay indicate that something changed the model outside the expected history sequence.
How should a drag become one undoable action?
A drag produces many updates, while the reader expects one completed movement to undo. Apple’s automatic grouping follows passes of the run loop, so it cannot be treated as a definition of an entire gesture. Explicit groups can combine registrations, but their boundaries still need deliberate management. groupsByEvent, beginUndoGrouping()
For this editor, use a preview-and-commit sequence:
- When the drag starts, retain the target ID and committed starting center as the gesture’s baseline.
- Use
@GestureStateandupdating(_:body:)for the temporary translation. Render the shape at its baseline plus that translation without changing the committed items. - On successful
onEnded(_:), calculate the final center from the final gesture value and invoke the model’s undoable setter once. - Skip registration when the resulting center equals the committed center.
- On cancellation, discard the preview and retain the committed model.
This sequence follows SwiftUI’s documented distinction: gesture state resets when a gesture ends or is cancelled, while onEnded(_:) runs only on successful completion. Consequently, cleanup that must also occur after cancellation cannot depend solely on onEnded(_:). Adding interactivity with gestures
Keep the editor’s committed state stable during that preview. One simple policy is to disable other canvas mutations and canvas undo while a drag is active, then re-enable them after completion or cancellation. Otherwise, define how an intervening edit invalidates the saved baseline before accepting a drag commit.
If another part of the editor must observe continuously updated committed coordinates, preview-only rendering may not fit. In that case, implement an explicit gesture transaction: save the starting state, apply intermediate updates without separate history entries, and register one restoration on success. Cancellation must restore the starting state. Avoid leaving a shared undo group open across arbitrary callbacks or asynchronous work.
Who should own the model and undo manager?
Keep one stable editor owner for each independent editable canvas. It owns the committed items and is the target of history operations. Keep both model mutations and undo interactions on the main actor; current Apple documentation marks UndoManager and its closure handler as main-actor isolated. UndoManager
For SwiftUI, read the view’s manager using @Environment(\.undoManager). Pass that manager through a stable editor or command layer, rather than creating a new manager during each view evaluation. The environment value is optional: Apple documents that unsupported contexts return nil and permits skipping undo registration there. Editing can still proceed, but that path must not advertise available undo. EnvironmentValues.undoManager
For an AppKit canvas, resolve the manager through the appropriate responder. The base NSResponder.undoManager implementation asks the next responder. Therefore, a privately created manager needs explicit integration with the canvas’s responder context if standard menu actions are expected to reach it. Merely storing it in the model does not establish that connection. NSResponder.undoManager
The standalone example’s grouping configuration belongs to its dedicated manager. Do not copy that configuration onto an environment-provided manager without owning the grouping policy for every participant. In a UI, register the completed canvas edit using the context’s existing event policy; group several synchronous suboperations when they constitute one action.
Before disposing of an editor target, remove its pending registrations at a controlled lifecycle boundary. Apple provides removeAllActions(withTarget:) to remove undo and redo operations addressed to that target. Clearing the entire manager would also affect other participants if the manager is shared. removeAllActions(withTarget:)
What should you verify before choosing the final design?
Exercise a short sequence that crosses representation boundaries: move a shape, delete another, undo both, then redo both. Check values, IDs, and order after every step. Add a cancelled drag, a drag ending at its starting position, and two independent editor windows. Each case tests a different boundary: restoration data, gesture completion, no-op handling, or ownership.
Treat a bulk replacement of the canvas as an explicit operation. Either make it undoable with sufficient restoration state or establish a new baseline after intentionally clearing obsolete history. A snapshot that omits editable fields and an inverse command that cannot find its target are both incomplete implementations.
Start with snapshots for a small value-based canvas, retain one stable owner, and commit each gesture once. Introduce inverse commands where the restoration data is clear and measurements justify the added logic. That lets the history representation change without changing what one user action means.
Sources
- UndoManager — Apple
- registerUndo(withTarget:handler:) — Apple
- groupsByEvent — Apple
- beginUndoGrouping() — Apple
- levelsOfUndo — Apple
- EnvironmentValues.undoManager — Apple
- NSResponder.undoManager — Apple
- Adding interactivity with gestures — Apple
- removeAllActions(withTarget:) — Apple
- Registering Undo Operations — Apple, Documentation Archive
- Value And Reference Types In Swift — Swift.org