Restore dimmed shapes, outlines, and drag labels from the drag source’s session-end callback. A destination’s exit callback only means the pointer left that target. For a Mac app, use SwiftUI’s source-session API when available, or let an AppKit source own the drag.

This guide addresses one task: returning a synthetic shape to its normal appearance after the user finishes dragging, including when no destination accepts it. The example keeps its content in memory and treats an accepted drop as a copy, so restoring feedback never requires deleting or moving the source.

Why does drag feedback stay active outside drop targets?

A common implementation sets an isDragging flag when dragging starts and clears it in performDrop. That connects cleanup to an event that only an accepting destination can handle. Releasing over empty window space leaves no destination responsible for resetting the source.

Apple defines DropDelegate as an interface for a view modified to accept drops. Its dropExited(info:) method reports that a validated drag exited that view. The drag may continue into another target, return to the first target, or finish elsewhere. Consequently, target exit cannot establish that the source session ended. DropDelegate

The same distinction applies to dropDestination(for:action:isTargeted:): isTargeted describes whether the pointer is inside the target area. Use it for the target’s border or background. Let a source-session callback control the source’s dimming or drag label. dropDestination(for:action:isTargeted:)

A useful design rule follows from those documented boundaries: assign each visual state to the event owner that can reliably end it.

Feedback or result Owner Reset or commit point
Dimmed source shape Drag source Source session ends
Highlighted destination Drop destination Pointer exits, with defensive cleanup at source completion for a local drag
Accepted synthetic item Destination/model Drop validates and succeeds
Temporary exported resources Transfer lifecycle Transfer completion, when applicable

Which supported approach should a Mac app use?

Apple’s current reference lists onDragSessionUpdated(_:) as available on macOS 26 and later. It observes updates to the closest drag session in its child view hierarchy. Attach it directly outside the source’s drag modifier so its scope is clear. Use an SDK that actually declares the method; runtime availability does not make a missing SDK declaration compile. onDragSessionUpdated(_:)

There is a documentation discrepancy worth handling narrowly: the modifier’s example shows an ended phase with location and operation arguments, while the dedicated phase reference declares ended(DropOperation). The example below matches .ended without extracting associated values because visual restoration does not need them. Consult your installed SDK’s declaration before adding operation-specific behavior. DragSession.Phase.ended(_:)

If your deployment target or SDK cannot use the SwiftUI callback, an AppKit drag source provides the documented completion boundary. Apple explicitly states that a source receives draggingSession(_:endedAt:operation:) when its drag ends or is cancelled. A custom source must initiate that session itself. beginDraggingSession(with:event:source:)

If you only need to reposition a shape inside one canvas, consider a local DragGesture instead of a system drag-and-drop session. @GestureState resets when its gesture becomes inactive, making it appropriate for that gesture’s temporary feedback. Keep that choice separate from a system drag that supplies transferable content to destinations. GestureState

Implement source-owned cleanup in SwiftUI

Create a macOS SwiftUI app using an SDK that exposes onDragSessionUpdated(_:), set its deployment target to macOS 26 or later, and replace the generated app and content-view declarations with this complete program. It supplies one synthetic string, shape.circle, and one receiving area.

The source uses draggable(_:) to provide transferable content. The destination accepts exactly one matching string and returns false for other input. Apple documents the destination’s Boolean result as indicating whether the drop succeeded. draggable(_:), dropDestination(for:action:isTargeted:)

import SwiftUI

@main
struct DragFeedbackDemoApp: App {
    var body: some Scene {
        WindowGroup {
            DragFeedbackDemo()
        }
    }
}

struct DragFeedbackDemo: View {
    private let payload = "shape.circle"

    @State private var isDragging = false
    @State private var isTargeted = false
    @State private var acceptedCopies = 0

    var body: some View {
        VStack(spacing: 24) {
            Text("Drag the circle into the receiving area")

            Circle()
                .fill(Color.blue)
                .frame(width: 72, height: 72)
                .opacity(isDragging ? 0.4 : 1)
                .draggable(payload)
                .onDragSessionUpdated { session in
                    switch session.phase {
                    case .initial, .active:
                        isDragging = true
                    case .ended, .dataTransferCompleted:
                        restoreFeedback()
                    default:
                        break
                    }
                }

            Text(isDragging ? "Dragging" : "Ready")

            RoundedRectangle(cornerRadius: 12)
                .fill(isTargeted
                      ? Color.blue.opacity(0.2)
                      : Color.gray.opacity(0.12))
                .overlay {
                    Text("Receiving area")
                }
                .frame(width: 280, height: 120)
                .dropDestination(for: String.self) { items, _ in
                    guard items.count == 1,
                          items.first == payload else {
                        return false
                    }
                    acceptedCopies += 1
                    return true
                } isTargeted: { targeted in
                    isTargeted = targeted
                }

            Text("Accepted copies: \(acceptedCopies)")
        }
        .padding(32)
        .frame(minWidth: 380, minHeight: 400)
    }

    private func restoreFeedback() {
        isDragging = false
        isTargeted = false
    }
}

The two flags have different jobs. isDragging changes the source’s opacity. isTargeted changes the receiving area’s fill. Entering and leaving the receiving area therefore cannot accidentally restore the source while the drag continues.

The phase reference identifies initial as the session about to begin, active as movement, and ended as completion. The example restores feedback at ended; it also makes the same reset harmlessly repeatable at dataTransferCompleted. It never waits exclusively for transfer completion to restore an unsuccessful drag. DragSession.Phase

Apple describes dataTransferCompleted as the point where dragged items have transferred and temporary items can be cleaned up. This example has no temporary export resources. In a larger implementation, keep visual cleanup separate from resource cleanup so restoring opacity does not discard something a transfer still needs. DragSession.Phase.dataTransferCompleted

The circle remains in the hierarchy throughout the session. An accepted drop increments the destination’s count; an unaccepted drop changes no content. That makes the example useful for diagnosing feedback independently of reordering or move semantics. The code was inspected against the retrieved declarations; it was not compiled or run because the available environment is Linux without SwiftUI.

How should the AppKit fallback preserve event ownership?

For an older supported macOS version, replace the drag-source implementation with an NSView hosted through NSViewRepresentable. The representable creates and updates the native view and bridges its callbacks to SwiftUI state. SwiftUI controls the represented view’s layout, so use SwiftUI sizing modifiers rather than assigning the represented view’s frame yourself. NSViewRepresentable

Implement the fallback as one source-owned sequence:

  1. In the native source view’s mouseDown(with:), retain the actual mouse-down event delivered to that view.
  2. In mouseDragged(with:), apply your chosen drag threshold and guard against starting a second session.
  3. Create an NSDraggingItem from an in-memory pasteboard writer carrying the synthetic item identifier.
  4. Supply a generated shape image and set its dragging frame in the source view’s coordinate system.
  5. Call beginDraggingSession(with:event:source:) using the retained mouse-down event and the object implementing NSDraggingSource.
  6. Show source feedback from draggingSession(_:willBeginAt:) and restore it unconditionally from draggingSession(_:endedAt:operation:).
  7. Clear the retained input event and session bookkeeping when tracking finishes. Keep the source and its callback owner alive through session completion.

These details follow AppKit’s explicit contract: the session-start method takes the initiating mouse-down event, dragging-item frames use the view’s coordinate system, and the source receives lifecycle messages. NSDraggingItem accepts an object conforming to NSPasteboardWriting; its single-image convenience method sets the dragging frame and contents. beginDraggingSession(with:event:source:), init(pasteboardWriter:), setDraggingFrame(_:contents:)

For this own-app interaction, return .copy for NSDraggingContext.withinApplication and an empty operation set for other contexts. That is an explicit choice to offer only local copies. AppKit asks the source which operations it allows through draggingSession(_:sourceOperationMaskFor:). draggingSession(_:sourceOperationMaskFor:)

Restore appearance for every completion result, rather than only a successful copy. Keep any model changes conditional on the operation your app actually implemented. The completion callback’s point is in screen coordinates; do not compare it directly with a SwiftUI target’s local bounds. draggingSession(_:endedAt:operation:)

A transparent native view placed behind an existing SwiftUI .draggable does not become that session’s source merely by conforming to NSDraggingSource. The source is the object passed when the session starts. Choose one owner for initiation and completion, and keep the native drag surface limited to the shape’s intended interaction area.

Why not use mouse-up monitoring or provider deinitialization?

An app-wide mouse-up monitor observes input dispatch, which is a different boundary from drag completion. Apple documents that local event monitors do not receive events consumed by nested tracking loops. That limitation makes a monitor an unsuitable substitute for the source’s documented completion callback. addLocalMonitorForEvents(matching:handler:)

Likewise, an item provider’s deinit reports object lifetime. Swift calls a deinitializer immediately before an instance is deallocated; that contract does not establish when a drag ended. Using provider destruction as a completion signal couples UI state to reference ownership rather than the drag lifecycle. Deinitialization

Adding a second DragGesture alongside a system drag has the same conceptual problem: its state belongs to that gesture. Use @GestureState when the local gesture owns the interaction; use the drag source’s session callback when the system drag owns it. GestureState

Verify the fix with a completion matrix

Use the synthetic circle to check each path separately. These are expected behaviors for the implementation, not reported test results.

Interaction Expected source feedback Expected destination result
Drop the circle inside the receiving area Returns to normal Count increases once
Release over empty window space Returns to normal Count stays unchanged
Leave the receiving area while still dragging Remains dimmed Target highlight clears
Re-enter and complete a valid drop Returns to normal at completion Count increases once
Release outside the app window without an accepting destination Returns to normal Count stays unchanged
Cancel an active drag Returns to normal Count stays unchanged
Repeat several unsuccessful drags Each session restores normally No accumulated accepted copies

If the source remains dimmed, first check that its completion observer wraps the actual drag source and that the source remains present. If only the target highlight sticks, inspect the target’s entry/exit handling and shared cleanup. If the accepted count changes on a rejected drop, examine validation and model mutation separately from visual restoration.

The durable fix is to make completion ownership explicit: the source restores session-wide feedback, the destination handles its own hover state and accepted content, and transfer cleanup follows its documented lifecycle. With those responsibilities separated, dropping outside every target leaves no visual state waiting for a destination callback.

Sources