Keep the shape’s committed position unchanged while SwiftUI previews the drag, then commit the final translation in onEnded. This preserves the starting position through completion without relying on AppKit mouse-up timing. Here is a complete example for moving a rectangle in your own Mac app.

Which completion boundary should own the starting state?

For a shape-moving interaction, the concrete task is to calculate every preview and the final position from the same starting position. The useful completion boundary is the successful completion of that shape’s gesture. Apple documents that onEnded(_:) supplies the gesture’s final value and runs only when the gesture ends successfully. It recommends @GestureState for temporary state that must reset regardless of how the gesture ends. Apple’s onEnded(_:) documentation.

Give the interaction two kinds of state: a committed model position and a temporary preview translation. During dragging, display their sum. At successful completion, apply the final translation to the committed position once. This is a design derived from Apple’s distinction between transient gesture updates and permanent state updates. Adding interactivity with gestures.

Information Owner in this example Lifetime
Rectangle’s committed center Private @State model Survives gesture completion
Current preview translation @GestureState Resets when the gesture becomes inactive
Final translation onEnded callback argument Used synchronously to commit the move
AppKit mouse-up event AppKit event handling Reports a button release; does not define SwiftUI gesture completion

Apple documents @State as SwiftUI-managed storage and a source of truth for values in a view hierarchy. @GestureState automatically resets when its gesture becomes inactive. Keeping the baseline in the former and the preview in the latter gives each value the lifetime this interaction needs. State, GestureState.

Build a complete Mac rectangle-drag example

Create a macOS SwiftUI app in Xcode with a deployment target of macOS 14 or later. Replace the generated app declaration and content view with the following single file, ensuring that the target has only one @main declaration. The example uses one synthetic rectangle stored entirely in memory. Its named-coordinate-space APIs are documented as available from macOS 14. Drag gesture initializer, Named coordinate spaces.

import SwiftUI

@main
struct GestureStartDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ShapeCanvas()
        }
    }
}

private struct SampleItem {
    var center = CGPoint(x: 160, y: 140)
}

private struct ShapeCanvas: View {
    @State private var item = SampleItem()
    @GestureState private var previewTranslation = CGSize.zero

    private let canvasWidth: CGFloat = 600
    private let canvasHeight: CGFloat = 360
    private let shapeWidth: CGFloat = 80
    private let shapeHeight: CGFloat = 60

    private func projectedCenter(_ translation: CGSize) -> CGPoint {
        let proposedX = item.center.x + translation.width
        let proposedY = item.center.y + translation.height
        let halfWidth = shapeWidth / 2
        let halfHeight = shapeHeight / 2

        return CGPoint(
            x: min(max(proposedX, halfWidth),
                   canvasWidth - halfWidth),
            y: min(max(proposedY, halfHeight),
                   canvasHeight - halfHeight)
        )
    }

    private var moveGesture: some Gesture {
        DragGesture(
            minimumDistance: 3,
            coordinateSpace: .named("shapeCanvas")
        )
        .updating($previewTranslation) { value, translation, _ in
            translation = value.translation
        }
        .onEnded { value in
            let finalCenter = projectedCenter(value.translation)
            item.center = finalCenter
        }
    }

    var body: some View {
        let displayedCenter = projectedCenter(previewTranslation)

        VStack(alignment: .leading, spacing: 12) {
            Text("Drag the blue rectangle")
                .font(.headline)

            ZStack {
                Color.gray.opacity(0.12)

                RoundedRectangle(cornerRadius: 10)
                    .fill(Color.blue)
                    .frame(width: shapeWidth, height: shapeHeight)
                    .contentShape(Rectangle())
                    .gesture(moveGesture)
                    .position(
                        x: displayedCenter.x,
                        y: displayedCenter.y
                    )
            }
            .frame(width: canvasWidth, height: canvasHeight)
            .coordinateSpace(.named("shapeCanvas"))
            .clipped()

            Text(
                "Committed center: " +
                "\(Int(item.center.x)), \(Int(item.center.y))"
            )
            .monospacedDigit()
        }
        .padding(20)
    }
}

The key invariant is that item.center never changes in updating. It remains the gesture’s model baseline until onEnded calculates finalCenter. The final calculation reads value.translation directly, so it does not require the preview property to retain its last value during completion.

The gesture measures movement in the enclosing canvas’s named coordinate space. The rectangle’s position(x:y:) places its center in that same parent space. Matching those spaces avoids mixing a moving shape’s local coordinates with canvas coordinates. Apple documents both named-space gesture measurement and center-based positioning. coordinateSpace(_:), position(x:y:).

The shared projectedCenter function also keeps the rectangle inside the canvas. Preview and commit use identical bounds: horizontal centers from 40 to 560, and vertical centers from 30 to 330. At an edge, movement remains based on total displacement from the original drag start; the rectangle moves away from the edge once that proposed position returns inside the bounds.

A movement smaller than the configured 3-point recognition threshold does not constitute a recognized drag. This example deliberately treats the start as the beginning of a recognized shape-moving interaction. A requirement to capture model state at the physical mouse-down event is a different boundary. Drag gesture initializer.

Why the starting position survives the final update

Apple defines DragGesture.Value.translation as the total translation from the gesture’s start to its current event. It is cumulative, rather than a delta since the preceding callback. Consequently, adding each reported translation to an already-updated model position would overcount movement. DragGesture.Value.

Consider these explicitly illustrative inputs, all within the canvas bounds:

Stage Reported total translation Committed center Displayed center
Before dragging (0, 0) (160, 140) (160, 140)
First update (12, 5) (160, 140) (172, 145)
Second update (30, -10) (160, 140) (190, 130)
Successful end (45, 20) Becomes (205, 160) Settles at (205, 160)

Every preview uses the same baseline. Completion applies the final (45, 20) once. A subsequent successful drag with translation (-15, 10) then starts from (205, 160) and commits (190, 170). These calculations were checked with a deterministic Python calculation. The SwiftUI application was inspected against the retrieved API documentation but was not compiled or run in this environment.

Do not substitute startLocation for the rectangle’s starting center. Apple defines startLocation as the location of the drag’s first event. A pointer can begin near a rectangle’s corner, so that location and the shape’s model center can differ. Similarly, predictedEndTranslation is a velocity-based prediction; use the final translation for this direct-placement interaction. DragGesture.Value.

Cancellation requires no rollback of the committed position in this design because preview updates never overwrite it. Apple documents that gesture state resets when the gesture becomes inactive, while onEnded runs only after success. The temporary displacement therefore disappears without committing a cancelled move. This conclusion follows from those documented behaviors; it does not depend on assuming an order between the reset and the successful-completion callback. GestureState, onEnded(_:).

Why an AppKit mouse-up monitor should not clear the baseline

An AppKit local event monitor receives events before dispatch to the app. Clearing shared gesture-start state from a monitored mouse-up event can therefore remove it before downstream handling processes that event. That is a consequence of the documented dispatch boundary, not a documented SwiftUI callback-order guarantee. addLocalMonitorForEvents(matching:handler:).

Likewise, NSResponder.mouseUp(with:) reports that the user released the left mouse button. Its contract does not declare that every SwiftUI gesture callback or view update has completed. Keep raw-event observation and recognized-gesture completion as separate responsibilities. mouseUp(with:).

The APIs reviewed here do not establish a general callback meaning that SwiftUI has finished all processing associated with a raw event. A queued cleanup block or a short timer would add a scheduling assumption without establishing that relationship. For this task, the supported path is to perform the final model calculation inside onEnded, where the final gesture value is available.

If the interaction must be implemented in a custom AppKit view, let that view own its input sequence and session state, and integrate it through NSViewRepresentable. Apple provides creation, update, coordinator, and dismantling hooks for that integration. Keep the represented view’s session in the AppKit view or its coordinator rather than rebuilding it from each representable value. SwiftUI controls the represented view’s layout, so do not independently change its managed frame or bounds. NSViewRepresentable.

When does the interaction need an explicit snapshot?

The example’s committed position is sufficient because no other action changes it while the drag is active. For a larger canvas, use this decision aid before introducing a separate session object:

Requirement Design consequence
Move one item whose committed position stays unchanged during preview Use the demonstrated baseline-plus-preview approach
Move several selected items together Preserve their stable identifiers and starting positions as one interaction snapshot
Allow other commands to edit positions during dragging Define whether to block those edits, cancel the drag, or resolve against a model revision
Capture state at physical mouse-down before recognition Use an explicitly owned AppKit input boundary; first gesture update is not equivalent
Keep a separate session after completion Give it an explicit owner and termination policy rather than relying on temporary gesture state

A snapshot must preserve values. Swift structures have value semantics, while assigning a class instance copies its reference. Therefore, an array of references to mutable item objects does not by itself freeze their positions. For a multi-item move, capture the identifiers and coordinate values needed for the final calculation. Structures and Classes — The Swift Programming Language.

If you introduce a persistent session, define successful completion, cancellation, and view removal explicitly. Clearing it only in onEnded leaves cancellation unaddressed, because that callback is success-only. Do not repurpose the gesture-state reset transaction as a universal completion notification: Apple documents its reset closure as providing a transaction for resetting gesture state. onEnded(_:), init(wrappedValue:reset:).

For the ordinary rectangle-moving task, preserve the baseline by leaving the committed model untouched during preview. Calculate the final position from the successful gesture’s own value, commit it once, and let temporary gesture state reset. This gives the starting position a clear owner and lifetime through the calculation that needs it.

Sources