For drag and drop inside your Mac app, the source declares supported operations, the destination chooses one, and your model implements the result. In SwiftUI on macOS 26 or later, configure both ends and remove the original only after a completed move.
This article builds a small desktop interaction: drag one synthetic circle into either Copy here or Move here. Everything stays in memory. The example makes the operation, item identity, and source cleanup explicit, then maps the same responsibilities to AppKit.
What must the source and destination agree on?
A transferable payload describes what travels between views. Copy or move describes what happens to the original. These are separate decisions. Apple’s SwiftUI documentation says that dragging supports copy by default; enabling move adds another supported operation. The destination then chooses how to receive the item. DragConfiguration
Use three responsibilities to design the interaction:
| Responsibility | Copy | Move |
|---|---|---|
| Source capability | Offer copy | Offer move |
| Destination commit | Insert an independent item | Receive the existing item |
| Source completion | Keep the original | Remove the original after the completed move |
For this circle example, an independent copy gets a new identifier. A move preserves the identifier while changing its location. That is an application design choice, consistent with Swift’s requirement that an identifiable entity have stable identity; Swift leaves the scope and duration of that identity to the application. Identifiable
The practical consequence is that returning a move operation does not relocate an element in your arrays. You still need destination insertion and source removal. Apple specifically demonstrates observing the completed drag operation to tell the model when to remove a source item. onDragSessionUpdated(_:)
Which SwiftUI APIs should a Mac app use?
For this implementation, use a macOS 26 or later deployment target and an SDK containing the documented configuration modifiers. Add draggable(_:) to the source, dragConfiguration(_:) to declare its operations, and dropConfiguration(_:) to select the destination’s operation. Both configuration modifiers are documented as available on macOS 26. dragConfiguration(_:), dropConfiguration(_:)
The current dropDestination(for:isEnabled:action:) overload supplies a DropSession to its action. Its action returns Void. This differs from the older overload whose action receives a location and returns a Boolean. Specify isEnabled: explicitly in the example so the intended overload is clear. dropDestination(for:isEnabled:action:)
At the destination, inspect suggestedOperations before selecting an operation. For a general move-preferred target, choose move when offered, otherwise copy when offered, otherwise forbidden. Apple’s documented example uses this move-to-copy fallback and notes that the configuration closure runs frequently, so keep it inexpensive. dropConfiguration(_:)
Our two targets deliberately have fixed meanings. Copy here accepts copy; Move here accepts move. A target returns forbidden when its named operation is unavailable. This makes the result predictable without requiring a keyboard modifier.
Build a complete in-memory copy-and-move example
Create a macOS SwiftUI app, set its deployment target to macOS 26 or later, and replace its generated app and content-view declarations with the following single file. The example transfers a built-in string containing an item identifier, so it needs no custom transferable type or custom type declaration. Apple documents both transferable payloads for drag sources and string payloads for drop destinations. draggable(_:), dropDestination(for:isEnabled:action:)
This is a complete minimal app intended for one open window and one source item. I inspected its API references against the retrieved documentation; I could not compile or run it here because Swift and the macOS SDK are unavailable.
import SwiftUI
import Foundation
@main
struct CopyMoveDemoApp: App {
var body: some Scene {
WindowGroup {
CopyMoveDemo()
}
}
}
struct CopyMoveDemo: View {
@State private var sourceID = UUID()
@State private var sourcePresent = true
@State private var copiedIDs: [UUID] = []
@State private var movedIDs: [UUID] = []
@State private var moveReceived = false
private var sourceConfiguration: DragConfiguration {
DragConfiguration(
operationsWithinApp: .init(allowMove: true),
operationsOutsideApp: .init(
allowCopy: false,
allowMove: false,
allowDelete: false
)
)
}
var body: some View {
VStack(spacing: 24) {
Text("Drag the circle to a target")
.font(.headline)
if sourcePresent {
Circle()
.fill(.blue)
.frame(width: 90, height: 90)
.overlay(Text("Circle").foregroundStyle(.white))
.draggable(sourceID.uuidString)
.dragConfiguration(sourceConfiguration)
.onDragSessionUpdated { session in
if session.phase == .initial {
moveReceived = false
}
if session.phase == .ended(.move), moveReceived {
sourcePresent = false
}
if case .ended = session.phase {
moveReceived = false
}
}
} else {
Text("Original moved")
.frame(width: 90, height: 90)
}
HStack(alignment: .top, spacing: 20) {
target(moving: false)
target(moving: true)
}
}
.padding(24)
.frame(minWidth: 520, minHeight: 320)
}
private func target(moving: Bool) -> some View {
let operation: DropOperation = moving ? .move : .copy
let ids = moving ? movedIDs : copiedIDs
return VStack(spacing: 10) {
Text(moving ? "Move here" : "Copy here")
.font(.headline)
ForEach(ids, id: \.self) { id in
Text("Circle \(id.uuidString.prefix(4))")
}
Spacer(minLength: 20)
}
.frame(width: 210, height: 150)
.background(.blue.opacity(0.08))
.contentShape(Rectangle())
.dropDestination(for: String.self, isEnabled: true) {
tokens, session in
guard session.localSession != nil,
sourcePresent,
tokens == [sourceID.uuidString] else {
return
}
if moving {
guard !movedIDs.contains(sourceID) else { return }
movedIDs.append(sourceID)
moveReceived = true
} else {
copiedIDs.append(UUID())
}
}
.dropConfiguration { session in
guard sourcePresent,
session.localSession != nil,
session.itemsCount == 1,
session.suggestedOperations.contains(operation) else {
return DropConfiguration(operation: .forbidden)
}
return DropConfiguration(operation: operation)
}
}
}
The source configuration offers copy and move inside the app. It disables operations outside the app using the macOS initializer for outside-app operations. The destination also checks localSession, which Apple documents as absent for a drag originating elsewhere. These settings keep the example focused on its own window’s in-memory item. DragConfiguration.OperationsWithinApp, init(allowCopy:allowMove:allowDelete:), DropSession
The drop action validates the exact token before inserting anything. Copy appends a new identifier. Move appends the original identifier and records that this destination received it. The source then removes its displayed original only when the session ends with move and that receipt exists. The receipt is an application-level safeguard for this limited demonstration, rather than a SwiftUI transaction feature.
Apple’s current drag-phase declaration includes initial, active, ending, ended, and dataTransferCompleted. The example uses the documented ended(DropOperation) case for the operation result. It does not remove the source while hovering or during the earlier ending phase. DragSession.Phase
Keep ownership and gesture boundaries explicit
The string is a lookup token, not the circle’s full model. The destination resolves it against the source state owned by this view. For multiple documents or windows, replace that local lookup with an explicit model owner and a receipt keyed to the actual drag session and items. A drag originating inside the app does not, by itself, establish which document owns an item.
For multiple draggable items, use stable model identifiers and obtain the dragged identifiers from the session where available. Apple provides draggedItemIDs(for:) for identifiable payloads or separately supplied identifiers. Avoid removing whatever happens to be selected when completion arrives; selection and dragged-item identity are different inputs. DragSession
Let SwiftUI’s draggable(_:) modifier install the drag interaction. Keep payload construction free of model removal. Apple documents that the modifier adds the appropriate gestures and cautions against dispatching an exporting closure to the main actor. draggable(_:)
How does the same agreement work in AppKit?
Use AppKit when your deployment target or existing view architecture calls for its explicit drag lifecycle. Its source protocol declares allowed operations through draggingSession(_:sourceOperationMaskFor:). The context lets you distinguish destinations inside and outside the application. For the interaction above, offer copy and move within the app and no operation outside it. draggingSession(_:sourceOperationMaskFor:)
The destination registers for the relevant pasteboard type, then chooses an operation in draggingEntered(_:) and updates that choice as needed in draggingUpdated(_:). Apple requires the destination to consider the overlap between source operations and its own supported operations and data types. Return one operation, or none when there is no acceptable overlap. NSDraggingDestination, draggingEntered(_:)
Perform the insertion in performDragOperation(_:), after preparation succeeds. Return true only when the destination accepts the data. The source receives the completed operation through draggingSession(_:endedAt:operation:); use that result, together with your model’s ownership rules, to complete source-side removal for a move. performDragOperation(_:), draggingSession(_:endedAt:operation:)
If you embed an AppKit drag surface in a SwiftUI interface, let that surface own the AppKit interaction. beginDraggingSession(with:event:source:) takes the initiating mouse-down event, and dragging-item frames use the view’s coordinate system. Preserve that event and coordinate relationship instead of fabricating an event from a later SwiftUI gesture value. beginDraggingSession(with:event:source:)
Older SwiftUI destination code can return DropProposal(operation: .move) from DropDelegate.dropUpdated(info:). That expresses the destination’s proposal. It does not replace source capability configuration, completed-operation observation, or your model mutations. When the newer SwiftUI source APIs are unavailable and you require explicit control of both ends, the AppKit source and destination lifecycle provides that path. dropUpdated(info:), NSDraggingSource
Verify the operation and the model together
For the example, use this expected-state checklist. These are acceptance conditions derived from the implementation, not reported test results:
| Action | Original | Copy target | Move target |
|---|---|---|---|
Drop once on Copy here |
Remains | Gains one new identifier | Unchanged |
| Copy again | Remains | Gains another new identifier | Unchanged |
Drop on Move here |
Disappears after completed move | Unchanged | Receives the original identifier |
| Cancel or release away from both targets | Remains | Unchanged | Unchanged |
When behavior disagrees, inspect the stages in order: source capabilities, destination operation, destination insertion, and source completion. A duplicate original after move points toward missing source cleanup. Two removals point toward competing model owners. An unexpected copy points toward a source that did not offer move or a destination that selected copy.
Keep the final responsibility clear: the negotiated operation describes the transfer, while your model implements the item’s location and identity. Offer the operations you support, choose an allowed operation at the destination, and make each model change once at its documented lifecycle boundary.
Sources
DragConfiguration — Apple
Identifiable — Apple
onDragSessionUpdated(_:) — Apple
dragConfiguration(_:) — Apple
dropConfiguration(_:) — Apple
draggable(_:) — Apple
DropSession — Apple
DragSession.Phase — Apple
DragSession — Apple
NSDraggingDestination — Apple
draggingEntered(_:) — Apple
performDragOperation(_:) — Apple
dropUpdated(info:) — Apple
NSDraggingSource — Apple