AppKit Drag and Drop on macOS: Source, Destination, and Operation Semantics
Implement AppKit drag and drop with explicit source operations, destination validation, typed payloads, stable identity, and cancellation-safe imports.
AppKit drag and drop is a negotiation that spans a source, a destination, and pasteboard data. The source describes what can be dragged and which operations it permits. A destination advertises the data types it can accept and repeatedly evaluates the drag as the pointer moves. Only after the user releases does the destination validate and import the payload. A hover highlight is therefore not proof that an import will succeed.
This article focuses on the NSDraggingSource and NSDraggingDestination lifecycle for AppKit views and windows. NSPasteboard has its own model for typed representations, lazy providers, and file promises; drag and drop uses a drag-session pasteboard and should not be confused with the general clipboard.
Design the payload around stable identity
Before implementing callbacks, decide what the drag represents: a copy of a value, a move of an item, a reference to an in-app record, a URL, or a file promise. Use a pasteboard type that accurately describes the representation. If the payload can be consumed by other applications, prefer a standard type such as a file URL or text representation where appropriate. If it is app-private, define a versioned custom type and validate its contents.
For an in-app move, include stable model identifiers rather than view indices or pointers to cell objects. A collection can reorder while the drag is in flight. At drop time, re-resolve each identifier against the current model and reject items that have disappeared or are no longer eligible. Never make the UI’s displayed row number the durable identity of the dragged object.
Register a destination for only the types it handles. Registration is part of the interaction contract: AppKit calls destination entry methods only when the dragged data contains an advertised type. A destination should not claim every type and then discover at commit time that it has no import path. Conversely, do not assume that a source’s private type is available to arbitrary third-party apps.
Source operations express allowed outcomes
Implement draggingSession(_:sourceOperationMaskFor:) to state which operations the source permits for the current drag context. A source can support copy, move, or other documented operations depending on its ownership model. The destination chooses among the operations allowed by the source and the operations it can actually perform. A destination must not return an operation that the source disallows.
The source receives callbacks when the session begins and ends. The ending callback reports the operation and screen point; use that result to update source state only if the chosen operation’s semantics require it. In particular, a source should not delete an item merely because a drag began or because the pointer entered another view. For a move, delete or commit the move only after the destination has accepted it and the source’s move contract says the source should remove the original.
Modifier keys can affect the intended operation. The source may choose how modifiers influence its operation mask, while the destination must still evaluate the actual allowed operations and its own capabilities. Test with and without modifiers across app boundaries rather than assuming every client uses the same copy/move convention.
Destination callbacks have distinct responsibilities
draggingEntered is called when a matching drag enters the destination. Return one operation the destination is currently willing to perform, or none. draggingUpdated can revise the operation and insertion location while the pointer moves. draggingExited should clear transient highlights and insertion indicators. These callbacks should be cheap and should not start a permanent import.
When the user releases, AppKit gives the destination a preparation opportunity. If preparation returns false, the actual import callback does not proceed. performDragOperation is where the destination performs the real import and returns whether it accepted the data. concludeDragOperation is the cleanup boundary. Keep validation separate from mutation so an invalid payload cannot partially alter the model.
import AppKit
final class DropTargetView: NSView {
var importHandler: (([URL]) -> Bool)?
override func awakeFromNib() {
super.awakeFromNib()
registerForDraggedTypes([.fileURL])
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
guard sender.draggingPasteboard.availableType(from: [.fileURL]) != nil else {
return []
}
return sender.draggingSourceOperationMask.intersection(.copy)
}
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool {
sender.draggingPasteboard.availableType(from: [.fileURL]) != nil
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
let options: [NSPasteboard.ReadingOptionKey: Any] = [
.urlReadingFileURLsOnly: true
]
guard let urls = sender.draggingPasteboard.readObjects(
forClasses: [NSURL.self],
options: options
) as? [URL], !urls.isEmpty else {
return false
}
return importFiles(urls)
}
private func importFiles(_ urls: [URL]) -> Bool {
guard !urls.isEmpty,
urls.allSatisfy({ $0.isFileURL }),
let importHandler else {
return false
}
// The configured handler must validate and take responsibility for these files.
return importHandler(urls)
}
}
The sample only accepts file URLs and requests file-URL reading. It returns false until an application installs an import handler; that handler must validate content, access, duplicates, and size, then return true only after it has taken responsibility for the files. A production destination should also handle draggingExited, concludeDragOperation, and draggingUpdated when it shows hover or insertion state. It should intersect the source’s operation mask with the exact operations it can safely complete.
Keep hover state reversible and import state atomic
Drag callbacks occur repeatedly and can end without a drop. Hover state should be transient and reversible. Clear it when the drag exits, when the session ends, or when the window closes. Avoid writing to the data model from draggingEntered or draggingUpdated; those methods can run many times and the user may eventually drop elsewhere.
At commit time, parse the pasteboard representation into validated values before mutating persistent state. If importing multiple files, decide whether the operation is all-or-nothing or permits partial results. Report the precise per-item failure if the UI supports it. A Boolean return from performDragOperation should match whether the receiver accepted the operation, not simply whether a callback ran.
For long-running imports, hand validated immutable inputs to a bounded work queue and provide immediate accepted/pending feedback only if the UI’s semantics allow it. If the destination returns success, it should mean the app took responsibility for the payload, not that an unrelated background process has already finished every later step. Preserve a progress/error state and support cancellation after acceptance.
File URLs, security-scoped access, and promises
A file URL on a pasteboard is a reference to a location, not a guarantee that the file is readable or will remain present. Handle permission errors, deleted paths, directories, packages, and large files deliberately. In a sandboxed macOS app, file access depends on the system interaction and any granted sandbox extension; Apple documents implicit access for Open/Save panels and files dragged onto the app’s Dock icon. Do not infer durable permission from a fileURL pasteboard type alone. Check access during the actual import, honor security-scoped access when the system provides it, and balance any scope you start. If the interaction does not grant the access your workflow needs, use a supported user-selected file or bookmark flow.
A file promise has a different timing contract: the source can create the file only after a destination chooses to accept the promise. Do not pretend the promised file exists during hover validation. Use the appropriate promise receiver APIs when accepting promised files and surface progress or failure if writing takes time. The distinction between an ordinary file URL and a promised file is essential for applications exporting generated documents.
Treat drag payloads as untrusted even when they originate in your own app. Validate file type by content where appropriate, enforce size limits before allocating large buffers, and sanitize names before using them as output paths. Do not deserialize arbitrary object graphs or trust a custom pasteboard field just because its type identifier is private to the app.
Internal reorder versus external import
An internal collection reorder and an external file drop are separate use cases. For a reorder, map the drop location to a model insertion point, validate that the dragged IDs still exist, and update ordering in one model transaction. For an external import, validate the offered type and produce new domain objects. Do not overload the same callback with implicit behavior based on a view index.
The destination can be a table, collection, outline, or custom view. Use the control’s documented drag-and-drop delegate or data-source APIs when available; they often provide row or item semantics that a raw NSView does not. For a custom destination, define how the proposed insertion point changes as the pointer moves, including empty space, group headers, and boundaries between items.
After a move or import, restore selection by stable identifiers rather than old index paths. A reorder changes index positions. If the destination rejects a drag, ensure no partial highlight, temporary file, or model mutation remains. If the source reports a move but the destination later fails, the application must have a recovery rule that prevents data loss.
Diagnose failure by lifecycle stage
If the destination receives no entry callback, check type registration, the source pasteboard contents, window/view hit testing, and whether the drag crosses a supported context. If it receives entry but no drop, inspect the returned operation and modifier behavior. If prepare succeeds but the model is unchanged, instrument payload decoding and the performDragOperation result. If rows move but the source item remains, inspect the negotiated operation and source-end policy.
Log a request identifier, offered types, selected operation, destination state, and result. Avoid logging full user paths or payload contents. For files, record a sanitized type and size class rather than the entire URL. A lifecycle trace can distinguish a negotiation failure from a parser failure without exposing the document.
Acceptance checks
Test dragging supported and unsupported types, copying and moving, modifier-key changes, cancellation, a source disappearing mid-drag, duplicate files, a missing file URL, large input, and an empty destination. Assert that hover callbacks never mutate persistent state, invalid payloads produce no partial import, accepted imports become recoverable work, and source items are removed only under the documented move semantics.
AppKit drag and drop is a typed negotiation with separate hover, prepare, commit, and cleanup phases. Stable identity, accurate operation masks, and atomic import behavior keep the UI truthful even when the pointer, source, destination, or file changes during the gesture.
Related:
- NSPasteboard on macOS: Typed Data, Lazy Providers, and File Promises
- NSCollectionView Diffable Data Sources: Stable IDs and Snapshot Updates
Sources: