For a shape dragged inside your Mac app, preview movement during the gesture and commit the final position in one short undo group. Keep the model and undo manager under one owner, pair every group opening with a closing, and register the inverse move so redo works too.

This tutorial builds a small SwiftUI canvas containing one synthetic, in-memory circle. The concrete task is to make one completed drag require exactly one undo, regardless of how many movement updates arrive. It also explains how to preserve the same boundaries in an AppKit view.

Why a drag should not keep an undo group open

A drag is an interaction that unfolds over time. An undo group collects reversible operations. Those boundaries need not have the same lifetime. Apple documents that groupsByEvent defaults to true and groups operations around passes of the run loop; that behavior does not define a complete drag transaction. Explicit groups can also nest inside existing groups. See Apple’s documentation for automatic event grouping and beginning undo groups.

Opening a group in the first SwiftUI movement callback and closing it in onEnded leaves a cancellation gap. Apple explicitly states that onEnded runs only when the gesture ends successfully. By comparison, state maintained through updating resets when the gesture ends or is cancelled. An unmatched endUndoGrouping() raises an exception, so guessing whether a callback opened a group is also unsuitable. See onEnded(_:), updating(_:body:), and endUndoGrouping().

For this canvas, use the following design. It is an implementation choice derived from those documented boundaries:

Interaction stage Display state Committed model Undo grouping
Before dragging Stored center Original center Closed
Movement updates Original center plus transient translation Unchanged Closed
Successful completion Final center Updated once Open and close synchronously
Cancellation Preview resets Unchanged Never opened
Undo or redo Restored center Updated through the reversible setter Managed by UndoManager

This approach lets the circle move continuously without treating every preview frame as a permanent edit. A completed drag becomes one named model operation.

Choose who owns the undo history

The sample gives each canvas its own model and private undo manager. Because that owner controls every registration, it sets groupsByEvent = false once during initialization and explicitly groups each committed move. Apple requires explicitly closing groups before undo when automatic grouping is disabled. The sample closes the group before returning from the commit method. See groupsByEvent.

An existing document editor may already have a document undo manager. SwiftUI exposes the contextual manager through @Environment(\.undoManager), and Apple documents that the value can be nil when the context does not support undo. In that architecture, keep using the document’s manager and its established grouping policy; do not copy this sample’s private-manager configuration onto a shared manager. See EnvironmentValues.undoManager.

With a shared manager and automatic grouping enabled, registering one inverse move at completion still avoids movement-by-movement history entries. An explicit subgroup can package the move, but it does not force independence from an enclosing group. If other edits share that enclosing group, they can undo together. Guaranteeing an independent top-level transaction requires coordination by the owner of the shared history, not closing groups from a child view.

Build the complete SwiftUI example

Create a macOS SwiftUI app project with a deployment target of macOS 14 or later. Replace its generated app declaration and content view with the two files below. Keep only one @main declaration. The sample uses local Undo and Redo buttons to make ownership explicit; application menu and responder-chain integration is a separate integration step.

The model uses a reversible setter: capture the current position, register a closure that restores it, then apply the replacement. When that closure runs during undo, the same setter registers the inverse as redo. Apple demonstrates this reciprocal-registration pattern in Registering Undo Operations. Current documentation also marks UndoManager and its registration handler as main-actor isolated, so the sample model uses @MainActor. See UndoManager.

Put the following complete 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
    }
}

The defer is deliberately in the same synchronous scope as beginUndoGrouping(). Swift executes deferred actions as that scope exits, including through an early return or a thrown error. It cannot span separately invoked gesture callbacks. This pairing keeps the model’s group balanced on normal control-flow exits. See Control Flow — Deferred Actions.

The undo closure operates on its target parameter rather than capturing self. Apple documents that the manager holds an unowned reference to the target and recommends using the supplied parameter to avoid retain cycles. The canvas retains the model while it is in use, and the model owns its history. See registerUndo(withTarget:handler:).

Put the app and 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)
    }
}

The gesture uses one named canvas coordinate space, so translation is measured against the board rather than the moving circle. Apple defines translation as the total displacement from the gesture’s start. Assign it to the preview; do not repeatedly add it to the model during movement. See DragGesture and translation.

The optional gesture state also indicates whether a preview is active. Both local history buttons are disabled during that preview. On successful completion, the final gesture value supplies the committed translation. On cancellation, gesture state resets and the stored center remains unchanged; there is no open group to clean up.

This example intentionally has a single writer. No other command changes the circle during a drag. If you add keyboard movement, deletion, or other editing commands, define whether each command is unavailable during dragging or first cancels the preview. Route all commands through that policy; disabling these two buttons alone cannot govern additional command paths.

Verify one drag, one undo, and a working redo

Use illustrative coordinates to inspect the arithmetic. Starting at (120, 100), cumulative translations of (10, 5), (25, 12), and (40, 20) produce preview centers of (130, 105), (145, 112), and (160, 120). The committed center stays at (120, 100) until completion, then changes once to (160, 120).

Undo invokes the registered setter with (120, 100). Before restoring it, the setter captures (160, 120) and registers that position for redo. Do not suppress registration inside the undo callback: that reciprocal registration is what preserves redo. Apple describes this behavior in Registering Undo Operations.

The code creates no extra explicit group inside the reversible setter. UndoManager controls grouping during history replay; Apple documents that redo() records replayed operations on the undo stack as a single group. The action name is assigned through setActionName, using the synthetic label Move Shape. See redo() and setActionName(_:).

Check these behaviors in the Mac app:

  1. Complete one drag with several direction changes. One Undo should restore its starting position.
  2. Choose Redo. The shape should return to the completed drag’s final position.
  3. Complete two separate drags. Two undo actions should restore the two earlier positions in reverse order.
  4. Finish a recognized drag at its starting position. The no-change guard should create no history entry.
  5. Exercise a cancellation or view-removal path in the containing app. The preview should disappear without a committed move.
  6. Undo a move, then complete a different move. The previous redo branch should disappear; Apple documents that a new undo registration clears the redo stack.

The last behavior follows Apple’s Undo Manager architecture. The coordinate calculation above was checked with a small deterministic Python calculation. The Swift source was inspected but was not compiled or run here because this environment has no Swift compiler or macOS SDK.

Apply the same boundaries to an AppKit canvas

For an existing NSView, preserve AppKit’s event sequence. Apple’s current reference defines mouseDragged(with:) as movement with the left button pressed and mouseUp(with:) as release of that button. Its archived event guide describes the three-method sequence and coordinate conversion needed by a custom view. See mouseDragged(with:), mouseUp(with:), and Handling Mouse Events.

Map the same design onto those methods:

  • In mouseDown(with:), hit-test the synthetic shape and save its starting center and the pointer location converted into canvas coordinates.
  • In mouseDragged(with:), calculate displacement from that saved pointer location and redraw a transient preview.
  • In mouseUp(with:), calculate the final displacement, clear tracking state, and call the model’s synchronous commit method once.
  • In the canvas owner’s cancellation or teardown path, discard the preview and clear tracking state without committing.

Use one event owner for the interaction. If SwiftUI embeds the AppKit canvas, choose either the SwiftUI gesture or the AppKit mouse handlers to drive movement and commitment. Having both register the same move would create duplicate operations. Keep drawing coordinates consistent, including any flipped coordinate system or canvas transform.

Apple’s archived guide also explains that keyboard commands can be dispatched during the three-method tracking approach. Any menu integration must therefore respect the same active-drag policy as the local buttons. Keep document history with its document owner and ensure menu actions address that owner’s manager.

Diagnose grouping failures without closing someone else’s group

If a drag creates many undo steps, check whether model setters register during movement updates. Move those registrations to the final commit. If several drags undo together, inspect the manager’s automatic grouping policy and any enclosing owner-created group. If redo is unavailable after undo, inspect whether the restoration path registered the inverse operation.

If grouping throws an exception, inspect ownership before changing the number of closing calls. Apple documents that groupingLevel == 0 means no group is open, and that undo() raises an exception when more than one group remains open. A loop that closes groups until the level reaches zero can close groups belonging to another caller. Use the level as a diagnostic and enforce the owner’s contract. See groupingLevel and undo().

For this in-memory canvas, the useful boundary is the committed move: preview freely, commit once, register a reversible change, and close the group before returning. That gives each completed drag one undo step while keeping cancellation, event delivery, and history ownership explicit.

Sources