12

In SwiftUI on MacOs, when implementing onDrop(of supportedTypes: [String], isTargeted: Binding<Bool>?, perform action: @escaping ([NSItemProvider]) -> Bool) -> some View we receive an array of NSItemProvider and this makes it possible to drop multiple items inside our view.

When implementing onDrag(_ data: @escaping () -> NSItemProvider) -> some View , how can we provide multiple items to drag?

I've not been able to find any examples online of multiple items drag and I'd like to know if there's another way to implement a drag operation that allows me to provide multiple NSItemProvider or the way to do it with the above method

My goal is to be able to select multiple items and drag them exactly how it happens in the Finder. In order to do that I want to provide an [URL] as [NItemProvider], but at the moment I can only provide one URL per drag Operation.

Shoni Fari
  • 197
  • 8
  • I'm dealing with the same challenge, I cannot find any information about it in SwiftUI. – Gal Yedidovich Dec 07 '20 at 14:29
  • 3
    .onDrag isn't meant to be used for dragging more than one item, unfortunately. This functionality, like much of drag & drop in general among other things, is still not implemented in SwiftUI. – lupinglade Feb 23 '21 at 00:40
  • Have you found a way to drag multiple items/files? – JCB Jan 02 '22 at 21:12
  • @user1046037 Have you tried making a single JSON String? Without a [Minimal Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) it is impossible to help you troubleshoot. We would be creating everything in an attempt to guess what you are trying to reproduce. – lorem ipsum Jan 10 '22 at 03:10
  • Are you using a list and what you're trying to drag&drop are list items? – Pierre Janineh Jan 10 '22 at 09:53
  • @PierreJanineh Yes I am using a SwiftUI `List` to drag and drop items – user1046037 Jan 10 '22 at 15:13
  • 1
    `onDrag` does support dragging multiple items for `List` on iPad, just not on iPhoneOS (v14.x) or macOS (v11.x anyway) that I can tell. Probably need to report this as a bug if it isn't fixed on iOS 15 and macOS 12. – Dad Jan 13 '22 at 20:40

2 Answers2

1

Might be worth checking if View's exportsItemProviders functions added in macOS 12 do what we need. If you use the version of List that supports multi-selection (List(selection: $selection) where @State var selection: Set<UUID> = [] (or whatever)).

Unfortunately my Mac is still on macOS 11.x so I can't test this :-/

Dad
  • 6,388
  • 2
  • 28
  • 34
1

Actually, you do not need an [NSItemProvider] to process a drag and drop with multiple items in SwiftUI. Since you must keep track of the multiple selected Items in your own selection manager anyway, use that selection when generating a custom dragging preview and when processing the drop.

Replace the ContentView of a new MacOS App project with all of the code below. This is a complete working sample of how to drag and drop multiple items using SwiftUI.

To use it, you must select one or more items in order to initiate a drag and then it/they may be dragged onto any other unselected item. The results of what would happen during the drop operation is printed on the console.

I threw this together fairly quickly, so there may be some inefficiencies in my sample, but it does seem to work well.

import SwiftUI
import Combine

struct ContentView: View {
    
    private let items = ["Item 0", "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7"]
    
    @StateObject var selection = StringSelectionManager()
    @State private var refreshID = UUID()
    @State private var dropTargetIndex: Int? = nil
    
    var body: some View {
        VStack(alignment: .leading) {
            ForEach(0 ..< items.count, id: \.self) { index in
                HStack {
                    Image(systemName: "folder")
                    Text(items[index])
                }
                .opacity((dropTargetIndex != nil) && (dropTargetIndex == index) ? 0.5 : 1.0)
                // This id must change whenever the selection changes, otherwise SwiftUI will use a cached preview
                .id(refreshID)
                .onDrag { itemProvider(index: index) } preview: {
                    DraggingPreview(selection: selection)
                }
                .onDrop(of: [.text], delegate: MyDropDelegate(items: items,
                                                              selection: selection,
                                                              dropTargetIndex: $dropTargetIndex,
                                                              index: index) )
                .padding(2)
                .onTapGesture { selection.toggle(items[index]) }
                .background(selection.isSelected(items[index]) ?
                            Color(NSColor.selectedContentBackgroundColor) : Color(NSColor.windowBackgroundColor))
                .cornerRadius(5.0)
            }
        }
        .onReceive(selection.objectWillChange, perform: { refreshID = UUID() } )
        .frame(width: 300, height: 300)
    }
    
    private func itemProvider(index: Int) -> NSItemProvider {
        // Only allow Items that are part of a selection to be dragged
        if selection.isSelected(items[index]) {
            return NSItemProvider(object: items[index] as NSString)
        } else {
            return NSItemProvider()
        }
    }
    
}

struct DraggingPreview: View {
    
    var selection: StringSelectionManager
    
    var body: some View {
        VStack(alignment: .leading, spacing: 1.0) {
            ForEach(selection.items, id: \.self) { item in
                HStack {
                    Image(systemName: "folder")
                    Text(item)
                        .padding(2.0)
                        .background(Color(NSColor.selectedContentBackgroundColor))
                        .cornerRadius(5.0)
                    Spacer()
                }
            }
        }
        .frame(width: 300, height: 300)
    }
    
}

struct MyDropDelegate: DropDelegate {
    
    var items: [String]
    var selection: StringSelectionManager
    @Binding var dropTargetIndex: Int?
    var index: Int
    
    func dropEntered(info: DropInfo) {
        dropTargetIndex = index
    }
    
    func dropExited(info: DropInfo) {
        dropTargetIndex = nil
    }
    
    func validateDrop(info: DropInfo) -> Bool {
        // Only allow non-selected Items to be drop targets
        if !selection.isSelected(items[index]) {
            return info.hasItemsConforming(to: [.text])
        } else {
            return false
        }
    }
    
    func dropUpdated(info: DropInfo) -> DropProposal? {
        // Sets the proper DropOperation
        if !selection.isSelected(items[index]) {
            let dragOperation = NSEvent.modifierFlags.contains(NSEvent.ModifierFlags.option) ? DropOperation.copy : DropOperation.move
            return DropProposal(operation: dragOperation)
        } else {
            return DropProposal(operation: .forbidden)
        }
    }
    
    func performDrop(info: DropInfo) -> Bool {
        // Only allows non-selected Items to be drop targets & gets the "operation"
        let dropProposal = dropUpdated(info: info)
        if dropProposal?.operation != .forbidden {
            let dropOperation = dropProposal!.operation == .move ? "Move" : "Copy"
            
            if selection.selection.count > 1 {
                for item in selection.selection {
                    print("\(dropOperation): \(item) Onto: \(items[index])")
                }
            } else {
                // https://stackoverflow.com/a/69325742/899918
                if let item = info.itemProviders(for: ["public.utf8-plain-text"]).first {
                    item.loadItem(forTypeIdentifier: "public.utf8-plain-text", options: nil) { (data, error) in
                        if let data = data as? Data {
                            let item = NSString(data: data, encoding: 4)
                            print("\(dropOperation): \(item ?? "") Onto: \(items[index])")
                        }
                    }
                }
                return true
            }
        }
        return false
    }
    
}

class StringSelectionManager: ObservableObject {
    
    @Published var selection: Set<String> = Set<String>()
    
    let objectWillChange = PassthroughSubject<Void, Never>()

    // Helper for ForEach
    var items: [String] {
        return Array(selection)
    }
    
    func isSelected(_ value: String) -> Bool {
        return selection.contains(value)
    }
    
    func toggle(_ value: String) {
        if isSelected(value) {
            deselect(value)
        } else {
            select(value)
        }
    }
    
    func select(_ value: String?) {
        if let value = value {
            objectWillChange.send()
            selection.insert(value)
        }
    }
    
    func deselect(_ value: String) {
        objectWillChange.send()
        selection.remove(value)
    }
    
}
Chuck H
  • 7,434
  • 4
  • 31
  • 34
  • 1
    What if we want to drag the items outside the app, e.g. to a finder window? E.g. for a single item (that has a `url` property), we can use `.onDrag { NSItemProvider(contentsOf: item.url) }`. How could it work with multiple items? – bzyr Feb 13 '22 at 09:23
  • from my understanding this only works for drag & drop within the app. – Daniel Dec 23 '22 at 03:45
  • That is correct. We're still hoping that Apple provides a more complete D&D solution for SwiftUI in the near future. – Chuck H Dec 24 '22 at 16:35