A SwiftUI library for macOS that provides grouped/individual item dragging with "pile" formation.
GroupedDrag enables multiple selected items to be dragged together as a cohesive group in SwiftUI on macOS. When you drag one selected item, all selected items follow in a visually appealing pile formation.
This is distinct from standard SwiftUI dragging which only supports single-item drags.
- Platform: macOS 15.0+ (iOS not supported)
- Swift: 6.2+
- Dependencies: None
This package is macOS-only. When integrating into a cross-platform project (macOS + iOS), use #if os(macOS) guards:
#if os(macOS)
import GroupedDrag
#endif
struct AssetGridView: View {
@State private var assets: [Asset] = []
@State private var selection: Set<UUID> = []
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(assets) { asset in
AssetRow(asset: asset, isSelected: selection.contains(asset.id))
}
}
}
#if os(macOS)
// Grouped dragging only available on macOS
.draggingGroup(assets.filter { selection.contains($0.id) })
#endif
}
}The package includes @available(macOS 15.0, *) annotations, so iOS targets will get compile-time errors if you accidentally try to use these APIs.
- File → Add Package Dependencies
- Add local package:
../Packages/GroupedDrag - Add to your target
dependencies: [
.package(path: "../Packages/GroupedDrag")
]Your item type must conform to Draggable and Identifiable:
import GroupedDrag
// Define your internal draggable type
struct Asset: Identifiable, Draggable {
let id: UUID
let name: String
let url: URL
func draggingPasteboardItem() -> NSPasteboardItem {
let item = NSPasteboardItem()
// Use your app's custom pasteboard type for internal drops
item.setString(id.uuidString, forType: .assetID)
return item
}
}Important: For internal drops, use custom pasteboard types (like
.assetIDabove), not system types like.string. System types like.stringwill cause problems when dragging to apps like TextEdit—they'll receive your internal UUID strings, which is nonsensical. Only provide system types (file URLs, image data, etc.) when you explicitly want external apps to receive that data.
import SwiftUI
import GroupedDrag
struct AssetRow: View {
let asset: Asset
let isSelected: Bool
var body: some View {
HStack {
Image(systemName: "photo")
Text(asset.name)
}
.padding(8)
.background(isSelected ? Color.accentColor : Color.clear)
.draggable(item: asset, enabled: isSelected)
}
}
struct AssetGridView: View {
@State private var assets: [Asset] = []
@State private var selection: Set<UUID> = []
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(assets) { asset in
AssetRow(asset: asset, isSelected: selection.contains(asset.id))
}
}
}
// REQUIRED: Enable grouped dragging at container level
.draggingGroup(assets)
}
}public protocol Draggable {
/// Returns an NSPasteboardItem representing this item during drag.
/// You provide whatever pasteboard types your app needs.
func draggingPasteboardItem() -> NSPasteboardItem
}Tip: You can provide multiple pasteboard types in a single NSPasteboardItem:
- One for internal app drops (e.g., your item's ID)
- One for external apps (e.g., file URLs, image data)
Applied at the container level. Required for draggable to work.
.draggingGroup(allItems) // All items available for dragging
.draggingGroup(selectedItems) // Only selected items (typical pattern)Applied to individual views. Makes that view draggable.
.draggable(item: myItem, enabled: isSelected)
.draggable(item: myItem, enabled: true) // Always draggableTrack when dragging starts/ends:
// Closure-based
.draggable(item: item, enabled: isSelected, onDraggingChanged: { isDragging in
// Called when drag starts (true) and ends (false)
})
// Binding-based
@State private var isDragging = false
.draggable(item: item, enabled: isSelected, isDragging: $isDragging)For drags that stay within your app, define your own custom pasteboard type. This prevents conflicts with system types and ensures other apps don't receive meaningless internal identifiers.
extension NSPasteboard.PasteboardType {
static var assetID: Self { .init("com.yourapp.assetid") }
}
struct Asset: Identifiable, Draggable {
let id: UUID
let name: String
func draggingPasteboardItem() -> NSPasteboardItem {
let item = NSPasteboardItem()
// Use your custom type - no other app will claim this
item.setString(id.uuidString, forType: .assetID)
return item
}
}
// Drop handler
.onDrop(of: [.assetID], isTargeted: nil) { providers in
guard let provider = providers.first else { return false }
Task {
if let data = try? await provider.loadItem(forTypeIdentifier: NSPasteboard.PasteboardType.assetID.identifier, options: nil) as? Data,
let idString = String(data: data, encoding: .utf8),
let id = UUID(uuidString: idString) {
// Find and handle the dropped item
}
}
return true
}Why custom types matter: If you use .string for internal drops, dragging your item to TextEdit will insert the UUID string. With a custom type, TextEdit simply won't accept the drop—exactly what you want.
When accepting drops from other apps, you'll need to handle standard pasteboard types. This section contains critical information that took significant effort to discover—read carefully to avoid repeating the same pain.
NSItemProvider is the standard mechanism for cross-process drag and drop on macOS. It allows data from Finder, Photos, Safari, or any other app to flow into your SwiftUI view. The onDrop(of:isTargeted:) modifier receives an array of NSItemProvider objects, each representing one dropped item.
This is the single most important piece of information in this entire README.
Apple's NSItemProvider async/await APIs for loading images on macOS are broken. The async loadItem(forTypeIdentifier:options:) method appears to work but silently fails when casting to NSImage. The completion handler version loadObject(ofClass:completionHandler:) is the only reliable approach.
What doesn't work:
// DOES NOT WORK - silently fails, NSImage cast always nil
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
let image = try? await provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) as? NSImage
// image will ALWAYS be nil here, even when provider reports type is available
}What actually works:
// NSItemProvider extension with completion handlers
extension NSItemProvider {
func loadImage(completion: @escaping (NSImage?) -> Void) {
// First try loading NSImage directly
if hasItemConformingToTypeIdentifier(UTType.image.identifier) {
loadObject(ofClass: NSImage.self) { reading, error in
if let image = reading as? NSImage {
completion(image)
} else if let error = error {
print("Failed to load NSImage: \(error)")
completion(nil)
} else {
completion(nil)
}
}
return
}
// Fallback to PNG data representation
loadDataRepresentation(forTypeIdentifier: UTType.png.identifier) { data, error in
if let data = data, let image = NSImage(data: data) {
completion(image)
} else {
completion(nil)
}
}
}
}When handling multiple dropped items, you must coordinate asynchronous loads before updating your UI. Use DispatchGroup for this:
.onDrop(of: [UTType.fileURL, UTType.image, UTType.png, UTType.jpeg, UTType.pdf], isTargeted: nil) { providers in
var newItems: [DroppedItem] = []
let group = DispatchGroup()
for provider in providers {
// Handle images from Photos, Safari, etc.
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
group.enter()
provider.loadObject(ofClass: NSImage.self) { reading, error in
if let image = reading as? NSImage {
DispatchQueue.main.async {
newItems.append(DroppedItem(
icon: "photo",
color: .green,
description: "Image",
image: image
))
}
}
group.leave()
}
}
// Handle file URLs from Finder
else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
group.enter()
provider.loadObject(ofClass: URL.self) { reading, error in
if let url = reading as? URL {
DispatchQueue.main.async {
newItems.append(DroppedItem(
icon: "doc",
color: .blue,
description: url.lastPathComponent,
image: NSImage(contentsOf: url)
))
}
}
group.leave()
}
}
}
// Update UI only after all loads complete
group.notify(queue: .main) {
droppedItems = newItems
}
return true
}Swift's async/await versions of these APIs exist but fail silently when loading images on macOS. The completion handler versions are the only reliable approach. This is a platform bug that Apple has not fixed as of macOS 15.0+. There is no documented workaround—only empirical testing revealed the issue.
NSItemProvider.loadObject() is asynchronous. When dropping multiple files, each load completes at unknown times. Without coordination, your UI state becomes inconsistent. DispatchGroup.notify() ensures all loads complete before you update your @State, avoiding partial/inconsistent UI states.
The order of type checks matters. Always check image types before file URLs, as some providers offer both. Use hasItemConformingToTypeIdentifier(_:) before attempting to load, as this reliably reports what the provider can actually deliver.
If you want other apps to receive meaningful data when dragging from your app, provide additional pasteboard types alongside your internal type:
extension NSPasteboard.PasteboardType {
static var assetID: Self { .init("com.yourapp.assetid") }
}
struct Asset: Identifiable, Draggable {
let id: UUID
let thumbnailData: Data?
func draggingPasteboardItem() -> NSPasteboardItem {
let item = NSPasteboardItem()
// Internal type - your app uses this
item.setString(id.uuidString, forType: .assetID)
// External type - apps like Photos can use this
if let imageData = thumbnailData {
item.setData(imageData, forType: .png)
}
return item
}
}Key insight: Your internal type handles app-internal drag/drop logic. External types (file URLs, image data, text) are only for when you explicitly want interoperability. Don't conflate the two by using .string as your internal type.
@State private var selection: Set<Item.ID> = []
var body: some View {
List(selection: $selection) {
ForEach(items) { item in
Text(item.name)
.draggable(item: item, enabled: selection.contains(item.id))
}
}
// Only selected items participate in grouped drag
.draggingGroup(items.filter { selection.contains($0.id) })
}@State private var isDragging = false
var body: some View {
HStack {
if isDragging {
ProgressView()
.controlSize(.small)
}
contentView
}
.draggable(item: item, enabled: isSelected, isDragging: $isDragging)
}When the view you attach .draggable to is minimal or invisible (e.g. a clear hit-testable overlay), you can provide a separate preview view that is rendered only when the drag begins:
Color.clear
.contentShape(Rectangle())
.draggable(item: item, enabled: isSelected, preview: {
Image(nsImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
})The preview closure is evaluated and captured to an image at drag-start time. It is not part of the normal view hierarchy, making this ideal for grid or list cells where the drag handle is separate from the visible content.
How it works internally: the preview is rendered into a temporary off-screen NSWindow, giving SwiftUI the full window lifecycle needed to composite the view before bitmap capture. Without this, NSHostingView content may render blank.
Define app-specific pasteboard types for internal drag operations:
extension NSPasteboard.PasteboardType {
/// Your app's internal item identifier type
static var itemID: Self { .init("com.yourapp.itemid") }
static var folderID: Self { .init("com.yourapp.folderid") }
}Use these types for all internal drag operations. The reverse-DNS naming convention (com.yourapp.typename) ensures no collisions with system types or other apps' types.
For your custom pasteboard types to be properly recognized, declare them in your app's Info.plist under UTExportedTypeDeclarations:
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.yourapp.itemid</string>
<key>UTTypeDescription</key>
<string>Item Identifier</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
</dict>
</array>Without this declaration, your custom types may still work for internal drag/drop, but they won't be properly recognized by the pasteboard system in all scenarios. See the sample app for a complete example.
Draggable Protocol
│
├─ draggingPasteboardItem() → NSPasteboardItem
│ (you implement this - provides pasteboard data)
│
└─ DraggingGroupManager
│
├─ Registers views when they appear
├─ Captures visual representation on drag start
├─ Coordinates multiple views for pile formation
└─ Provides environment to child views
Standard SwiftUI .draggable() only supports single-item drags. GroupedDrag:
- Maintains weak references to selected item views
- Captures each view's visual representation
- Initiates drag with all captured images
- Uses
NSDraggingSession.draggingFormation = .pilefor visual cohesion
- NSDraggingSource - AppKit dragging protocol
- NSPasteboardItem - Pasteboard data container
- UTType - Uniform Type Identifier definitions
MIT License. See LICENSE file for details.