Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 218
Shoni Fari Avatar asked Nov 06 '20 10:11

Shoni Fari


2 Answers

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 :-/

like image 164
Dad Avatar answered Nov 27 '22 23:11

Dad


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)
    }
    
}
like image 20
Chuck H Avatar answered Nov 27 '22 23:11

Chuck H