Skip to content

Dimension-North-Inc/GroupedDrag

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 

Repository files navigation

GroupedDrag

Swift 6.2+ macOS 15.0+ License: MIT

A SwiftUI library for macOS that provides grouped/individual item dragging with "pile" formation.

What This Package Does

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.

Requirements

  • Platform: macOS 15.0+ (iOS not supported)
  • Swift: 6.2+
  • Dependencies: None

Cross-Platform Projects

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.

Installation

Xcode

  1. File → Add Package Dependencies
  2. Add local package: ../Packages/GroupedDrag
  3. Add to your target

Swift Package Manager

dependencies: [
    .package(path: "../Packages/GroupedDrag")
]

Quick Start

Step 1: Conform to Draggable

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 .assetID above), not system types like .string. System types like .string will 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.

Step 2: Add Modifiers to Your Views

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)
    }
}

API Reference

Draggable Protocol

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)

View Modifiers

.draggingGroup(_:)

Applied at the container level. Required for draggable to work.

.draggingGroup(allItems)              // All items available for dragging
.draggingGroup(selectedItems)         // Only selected items (typical pattern)

.draggable(item:enabled:)

Applied to individual views. Makes that view draggable.

.draggable(item: myItem, enabled: isSelected)
.draggable(item: myItem, enabled: true)  // Always draggable

Drag State Callbacks

Track 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)

Understanding Internal vs External Drops

Internal Drops (Within Your App)

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.

External Drops (From Finder, Photos, Web)

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.

Why NSItemProvider?

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.

THE NSITEMPROVIDER IMAGE LOADING TRAP

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)
            }
        }
    }
}

Complete Working Drop Handler

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
}

Why Completion Handlers Instead of async/await?

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.

Why DispatchGroup?

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.

Type Detection Order

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.

Providing Multiple Representations

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.

Common Patterns

Selection-Based Group Dragging

@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) })
}

Drag State Feedback

@State private var isDragging = false

var body: some View {
    HStack {
        if isDragging {
            ProgressView()
                .controlSize(.small)
        }
        contentView
    }
    .draggable(item: item, enabled: isSelected, isDragging: $isDragging)
}

On-Demand Drag Preview

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.

Custom Pasteboard Types

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.

Declaring Types in Info.plist

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.

Architecture

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

Why This Exists

Standard SwiftUI .draggable() only supports single-item drags. GroupedDrag:

  1. Maintains weak references to selected item views
  2. Captures each view's visual representation
  3. Initiates drag with all captured images
  4. Uses NSDraggingSession.draggingFormation = .pile for visual cohesion

See Also

License

MIT License. See LICENSE file for details.

About

Enhanced dragging for SwiftUI macOS apps

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages