For a shape dragged inside your Mac app, update its offset directly during the gesture, then animate that same offset with a spring on release. On macOS 14 and later, SwiftUI can carry the tracked drag velocity into the spring automatically.

This tutorial builds a small desktop interaction: drag a synthetic blue tile in any direction, release it, and let it spring back to its starting position. All state stays in memory, and the gesture belongs to the tile in the app’s own window.

How does the spring inherit the drag’s final velocity?

SwiftUI’s Transaction.tracksVelocity records velocity as animatable properties change. Apple documents that gesture onChanged and updating callbacks automatically enable this tracking. When the interaction ends, an animation can use the accumulated velocity. You therefore do not need to calculate and pass an initial velocity for this ordinary SwiftUI interaction. Apple’s velocity-tracking documentation describes this handoff explicitly.

The important connection is the animated property. During dragging, write the gesture’s translation into the tile’s offset. On release, change that same offset to its destination inside withAnimation. Keeping the view and its state alive gives SwiftUI a continuous property to track.

Keep the drag updates unanimated in this implementation. Apple explains that transaction velocity tracking and an animation managing its own velocity are mutually exclusive. An interactive spring during dragging is another supported technique, but it changes how the tile follows the pointer; it is unnecessary for the direct-follow interaction here. Transaction velocity tracking

A spring’s destination and its initial motion serve different purposes. The destination says where the tile should settle. The inherited velocity says how it is moving when released. A tile released while moving away from home can initially continue outward before the spring brings it back. Apple demonstrates springs continuing motion from gesture velocity in Animate with springs.

Build a complete macOS drag-and-release example

Create a macOS app in Xcode using SwiftUI, set its deployment target to macOS 14.0 or later, and replace the generated app entry file with the following code. Keep only one @main app declaration in the target. The example uses the macOS 14 animation-completion API. Apple’s animation-completion documentation

This is a complete single-file application with no assets or package dependencies. Its API signatures were checked against the documentation; the desktop application was not compiled or run in this research environment.

import SwiftUI

@main
struct SpringReleaseDemoApp: App {
    var body: some Scene {
        WindowGroup {
            SpringReleaseView()
        }
    }
}

struct SpringReleaseView: View {
    @State private var offset: CGSize = .zero
    @State private var isReturning = false
    @GestureState private var isDragging = false

    var body: some View {
        VStack(spacing: 16) {
            Text("Drag the tile, then release")
                .font(.headline)

            ZStack {
                RoundedRectangle(cornerRadius: 20)
                    .stroke(.gray.opacity(0.4), lineWidth: 2)
                    .frame(width: 112, height: 112)

                RoundedRectangle(cornerRadius: 20)
                    .fill(.blue)
                    .frame(width: 112, height: 112)
                    .contentShape(Rectangle())
                    .offset(offset)
                    .gesture(tileDrag)
                    .allowsHitTesting(!isReturning)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .coordinateSpace(name: "canvas")
        }
        .padding(24)
        .frame(width: 640, height: 440)
        .onChange(of: isDragging) { _, active in
            guard !active, !isReturning else { return }

            // Cancellation cleanup has no successful release value.
            withTransaction(Transaction(animation: nil)) {
                offset = .zero
            }
        }
    }

    private var tileDrag: some Gesture {
        DragGesture(
            minimumDistance: 3,
            coordinateSpace: .named("canvas")
        )
        .updating($isDragging) { _, active, _ in
            active = true
        }
        .onChanged { value in
            // Gesture callbacks automatically track velocity.
            offset = value.translation
        }
        .onEnded { _ in
            returnHome()
        }
    }

    private func returnHome() {
        guard !isReturning else { return }
        isReturning = true

        withAnimation(
            .spring(duration: 0.45, bounce: 0.2),
            completionCriteria: .removed
        ) {
            offset = .zero
        } completion: {
            isReturning = false
        }
    }
}

The gesture is attached to the moving tile, while its named coordinate space belongs to the stationary canvas. This makes the interaction’s coordinate choice explicit. Apple’s DragGesture documentation describes attaching a configured drag with the gesture modifier.

value.translation is the total displacement since the drag began. Assign it to offset; do not repeatedly add it to the previous offset. Repeated addition would accumulate the same movement multiple times. In this example every accepted drag starts from home, so no separate starting offset is needed. Apple’s translation definition

The successful release calls returnHome() immediately. It neither resets the offset beforehand nor schedules the spring for later. That keeps the tracked drag changes and the release animation connected through the same state property.

Why keep separate drag, position, and return states?

The three properties answer different questions: offset stores the requested displacement, isDragging tracks an active gesture, and isReturning records the example’s return phase. The tile’s owner holds these values locally. Apple recommends declaring view-owned state as private and sharing bindings only where another view needs to change it. State documentation

The wrapper syntax also has a specific purpose. Swift property wrappers manage access to a wrapped value and can expose a projected value using the dollar-sign prefix. Here, $isDragging supplies the gesture-state projection used by updating; it is not a second, independently maintained flag. The Swift Programming Language: Properties

Cancellation needs separate handling because onEnded runs only when a gesture finishes successfully. Apple recommends @GestureState for state that must reset regardless of how a gesture ends. The example observes that reset and restores the synthetic tile if no normal return is underway. This fallback snaps home; it does not claim to have a final release velocity for a cancelled gesture. Gesture completion behavior, GestureState

The example also deliberately accepts a new drag only after the return animation finishes. Setting the model offset to zero starts the animation, so zero is already the requested destination while the tile is still visibly moving. Simply removing this restriction would require designing how a new drag takes over from the currently displayed position.

allowsHitTesting(!isReturning) controls whether the tile participates in hit testing. The .removed completion criterion releases the restriction when the entire animation finishes. This avoids guessing a delay from the spring’s duration parameter. Hit-testing control, Animation removal completion

How should you tune and check the release?

Start with a fixed destination and fixed spring settings. Change the way you release the tile before changing the animation parameters. That separates velocity inheritance from spring tuning.

Use the following checks within the window. These are a diagnostic sequence derived from the documented handoff, not measured results from this example.

Check Action What to look for
Nearly stationary release Drag away from home, pause, then release. The return should begin with little carried motion.
Outward release Drag away from home and release while still moving outward. The spring should carry outward motion before turning back.
Inward release Drag away, reverse toward home, then release. The return should continue the inward motion.
Diagonal release Release while moving along both axes. Check that the handoff retains the direction, rather than behaving like a horizontal-only return.
Next interaction Wait for the return to finish, then drag again. The new drag should begin from the home position.

Tune duration for pace and bounce for character. The example’s 0.45 and 0.2 are illustrative starting values, not prescribed settings. Apple defines duration as perceptual duration and describes zero bounce as critically damped, with positive bounce values producing increasingly bouncy springs. Spring configuration

Perceptual duration is not an exact promise about when every visible movement ends. Apple distinguishes it from settling duration, which depends on the spring’s motion. Use the completion callback for this example’s return phase rather than treating 0.45 as a timer. Animate with springs

If the release appears to ignore movement, first inspect the handoff: is the gesture changing the same offset that the spring animates? Does some other code overwrite that offset before release? Is the view being replaced? Does an animation modifier animate the drag updates? These checks follow directly from the documented requirement to track changing animatable properties before handing them to an animation.

When should you read velocity explicitly or use AppKit?

DragGesture.Value.velocity exposes the current drag velocity, and onEnded supplies the gesture’s final value after successful completion. Reading it is useful when velocity influences an application decision, such as choosing between two synthetic snap locations. For the fixed return destination in this example, automatic tracking already supplies the animation handoff. Drag velocity, Final gesture value

If you choose an explicit spring-initial-velocity API, check its representation before passing a gesture measurement. Apple’s gesture-driven animation demonstration converts velocity to unit velocity by dividing by the remaining travel distance. Its illustrative arithmetic would turn 300 points per second over 150 points of travel into 2 animation distances per second. That conversion also requires a deliberate policy when remaining distance is zero. The automatic SwiftUI path avoids this calculation here. Enhance your UI animations and transitions

Use AppKit when an existing app-owned AppKit view or recognizer needs to own the interaction. NSPanGestureRecognizer.velocity(in:) returns horizontal and vertical velocity in points per second relative to the supplied view. Although its Swift signature accepts an optional view, Apple explicitly says that argument must not be nil. AppKit pan velocity

For macOS 26 and later, NSGestureRecognizerRepresentable is the documented way to integrate an AppKit gesture recognizer into a SwiftUI hierarchy. SwiftUI manages its creation and updates and provides coordinate-conversion support. Apple cautions that the recognizer’s underlying view may have different geometry from the SwiftUI view, so use the representable’s converter when crossing that boundary. AppKit gesture integration

For the tile interaction, keep the SwiftUI gesture as the single owner of drag updates and let the release spring animate its existing offset. Once that handoff works, adjust the destination and spring parameters while preserving the same state and coordinate boundaries.

Sources