Search code examples
swiftmacosswiftuidrag

SwiftUI onDrag. How to provide multiple NSItemProviders?


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.


Solution

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