How to make a drop target in SwiftUI on iOS

I am writing an app based around DoubleColumnNavigationViewStyle (eg. SplitView).


I would like to drag entities from the MasterList onto entities in the Detail View to form coredata relationships between them.

My models implement NSItemProviderWriting and NSItemProviderReading.

I find I can make a NavigationLink draggable by adding this to it:


.itemProvider { () -> NSItemProvider? in
     return NSItemProvider(object: self.model) }


When a drag is instigated from here, the model's NSItemProviderWriting protocol functions are invoked.


What I cannot work out is how to make a View 'Dropable'.

All of the likely looking ViewModifiers are implemented on MacOS only, as far as I can see.


There must be something that takes a list of supported type identifiers and invokes NSItemProviderReading.


There is also something strange going on ...

Not all of my NavigationLinks have a .itemProvider but once one of them does, they all act draggable, which is not the desired outcome.


Are these known bugs or omissions?

Answered by Jim Dovey in 403641022

There's a `.onInsert(of:perform:)` view modifier on DynamicContentView (to which only ForEach currently conforms) that does this. You pass in an array of accepted type identifiers, and then your block is called. It's passed the index on which the drop occurred (within the content of the ForEach) and an array of NSItemProvider instances.


Here's an iPad app which demonstrates its use:


fileprivate let dataUTI = "data.raw.dragdata"

fileprivate final class DragData: NSObject, NSItemProviderWriting, NSItemProviderReading {
    var text: String
    
    init(text: String) {
        self.text = text
    }
    
    static var writableTypeIdentifiersForItemProvider: [String] { [dataUTI] }
    
    func loadData(
        withTypeIdentifier typeIdentifier: String,
        forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void
    ) -> Progress? {
        let progress = Progress(totalUnitCount: 1)
        
        DispatchQueue.global(qos: .userInitiated).async {
            progress.completedUnitCount = 1
            let data = self.text.data(using: .utf8)
            completionHandler(data, nil)
        }
        
        return progress
    }
    
    static var readableTypeIdentifiersForItemProvider: [String] { [dataUTI] }
    
    static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DragData {
        guard let str = String(data: data, encoding: .utf8) else {
            throw CocoaError(CocoaError.Code.fileReadInapplicableStringEncoding)
        }
        return DragData(text: str)
    }
}

fileprivate func provider(for text: String) -> NSItemProvider {
    NSItemProvider(object: DragData(text: text))
}

struct ContentView: View {
    @State private var leftItems = (1...20).map { "Left \($0)" }
    @State private var rightItems = (1...20).map { "Right \($0)" }
    
    var body: some View {
        HStack {
            ItemListView(items: $leftItems)
            Divider()
            ItemListView(items: $rightItems)
        }
    }
}

struct ItemListView: View {
    @Binding var items: [String]
    
    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
                    .itemProvider { provider(for: item) }
            }
            .onInsert(of: [dataUTI]) { idx, providers in
                self.insert(at: idx, from: providers)
            }
        }
    }
    
    private func insert(at index: Int, from providers: [NSItemProvider]) {
        // simple async load of all items
        let group = DispatchGroup()
        var strings = [String](repeating: "", count: providers.count)
        
        DispatchQueue.concurrentPerform(iterations: providers.count) { idx in
            group.enter()
            providers[idx].loadObject(ofClass: DragData.self) { obj, error in
                if let data = obj as? DragData {
                    strings[idx] = data.text
                } else if let error = error {
                    print("Drop decode error: \(error)")
                }
                group.leave()
            }
        }
        
        group.notify(queue: .main) {
            self.items.insert(contentsOf: strings, at: index)
        }
    }
}
Accepted Answer

There's a `.onInsert(of:perform:)` view modifier on DynamicContentView (to which only ForEach currently conforms) that does this. You pass in an array of accepted type identifiers, and then your block is called. It's passed the index on which the drop occurred (within the content of the ForEach) and an array of NSItemProvider instances.


Here's an iPad app which demonstrates its use:


fileprivate let dataUTI = "data.raw.dragdata"

fileprivate final class DragData: NSObject, NSItemProviderWriting, NSItemProviderReading {
    var text: String
    
    init(text: String) {
        self.text = text
    }
    
    static var writableTypeIdentifiersForItemProvider: [String] { [dataUTI] }
    
    func loadData(
        withTypeIdentifier typeIdentifier: String,
        forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void
    ) -> Progress? {
        let progress = Progress(totalUnitCount: 1)
        
        DispatchQueue.global(qos: .userInitiated).async {
            progress.completedUnitCount = 1
            let data = self.text.data(using: .utf8)
            completionHandler(data, nil)
        }
        
        return progress
    }
    
    static var readableTypeIdentifiersForItemProvider: [String] { [dataUTI] }
    
    static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DragData {
        guard let str = String(data: data, encoding: .utf8) else {
            throw CocoaError(CocoaError.Code.fileReadInapplicableStringEncoding)
        }
        return DragData(text: str)
    }
}

fileprivate func provider(for text: String) -> NSItemProvider {
    NSItemProvider(object: DragData(text: text))
}

struct ContentView: View {
    @State private var leftItems = (1...20).map { "Left \($0)" }
    @State private var rightItems = (1...20).map { "Right \($0)" }
    
    var body: some View {
        HStack {
            ItemListView(items: $leftItems)
            Divider()
            ItemListView(items: $rightItems)
        }
    }
}

struct ItemListView: View {
    @Binding var items: [String]
    
    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
                    .itemProvider { provider(for: item) }
            }
            .onInsert(of: [dataUTI]) { idx, providers in
                self.insert(at: idx, from: providers)
            }
        }
    }
    
    private func insert(at index: Int, from providers: [NSItemProvider]) {
        // simple async load of all items
        let group = DispatchGroup()
        var strings = [String](repeating: "", count: providers.count)
        
        DispatchQueue.concurrentPerform(iterations: providers.count) { idx in
            group.enter()
            providers[idx].loadObject(ofClass: DragData.self) { obj, error in
                if let data = obj as? DragData {
                    strings[idx] = data.text
                } else if let error = error {
                    print("Drop decode error: \(error)")
                }
                group.leave()
            }
        }
        
        group.notify(queue: .main) {
            self.items.insert(contentsOf: strings, at: index)
        }
    }
}

Many thanks for your sample Jim, it looks very clear.


Unfortunately, in practise, this will not work for me.


This mechanism only seems to work for items wrapped in List at source and destination and once I had edited my project down to use List and got the Drag and Drop working, it was obvious that (for me) the wrong assumptions are being made.


I need to drag a representation of a child Entity, onto a parent Entity to create a relationship between them (similar to the Files App, dragging a Tag onto a File).


What occurs in practice is that the List makes room to drop the child Entity between two parent Entities, rather than onto one.


It looks like this is designed for move type operations rather then apply type operations.

I'd suspected that might be the case. There's likely a solution for dragging within an app that just uses gestures and anchors to change state when one item is dragged over another. For dragging between apps, I suspect the only way to implement it right now is to wrap things in a UIViewRepresentable to get access to the UIView/UIResponder methods for drag & drop. You may be able to implement this as an overlay to a SwiftUI view; I'll see if I can't figure out a graceful way of doing this.

Looks like you'll get what you need in iOS 13.4: "The

onDrag
and
onDrop
modifiers are now available on iOS" (from the release notes).

Thanks Jim


Both additions are great news ... except, I still do not trust the outstanding issues with Catalina (which I beleive the new Xcode requires?).

One day I hope ...

I was just trying the accepted answer in a new project, as-is, but it doesn't work.
The item looks like it's going to drag, but you can't drop it anywhere -- it just reverts back to its source.

Running Xcode 12.0.1 with iOS 13.x.

Thoughts?
How to make a drop target in SwiftUI on iOS
 
 
Q