SwiftUI onDrag. How to provide multiple NSItemProviders?
Asked Answered
T

2

13

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.

Thirtieth answered 6/11, 2020 at 10:32 Comment(7)
I'm dealing with the same challenge, I cannot find any information about it in SwiftUI.Stoppage
.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.Bilyeu
Have you found a way to drag multiple items/files?Absinthe
@user1046037 Have you tried making a single JSON String? Without a 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.Knowledgeable
Are you using a list and what you're trying to drag&drop are list items?Ethiopian
@PierreJanineh Yes I am using a SwiftUI List to drag and drop itemsWagonlit
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.Earpiece
O
2

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://mcmap.net/q/908911/-swiftui-not-getting-dropped-nsstring-value-in-dropdelegate
                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)
    }
    
}
Overstep answered 19/1, 2022 at 20:56 Comment(3)
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?Aureus
from my understanding this only works for drag & drop within the app.Incase
That is correct. We're still hoping that Apple provides a more complete D&D solution for SwiftUI in the near future.Overstep
E
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 :-/

Earpiece answered 13/1, 2022 at 20:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.