iosswiftswiftuidrag-and-drop

`View.onDrag(_:)` gets called again right after `DropDelegate.performDrop(info:)`


I'm trying to implement drag and drop in my VStack. I have implemented a DropDelegate like so:

private struct StandardDropDelegate: DropDelegate {
    
    let destinationItem: StandardReorderedListItem
    @Binding var sourceContent: [StandardReorderedListItem]
    @Binding var draggedItem: StandardReorderedListItem?
    
    func dropUpdated(info: DropInfo) -> DropProposal? {
        return DropProposal(operation: .move)
    }
    
    func performDrop(info: DropInfo) -> Bool {
        print("dropped item: \(String(describing: draggedItem))\n===========================")

        self.draggedItem = nil
        return true
    }
    
    func dropEntered(info: DropInfo) {
        guard let draggedItem,
              let fromIndex = sourceContent.firstIndex(of: draggedItem),
              let toIndex = sourceContent.firstIndex(of: destinationItem),
              fromIndex != toIndex else { return }
        
        withAnimation(.easeInOut) {
            self.sourceContent.move(fromOffsets: IndexSet(integer: fromIndex),
                                    toOffset: (toIndex > fromIndex ? (toIndex + 1) : toIndex))
        }
    }
}

And in the View itself:

public struct StandardReorderedListView: View {
    
    @Binding private var content: [StandardReorderedListItem]
    @State private var draggedItem: StandardReorderedListItem?
    private let type: ReorderedListType = .standard
    
    public init(_ content: Binding<[StandardReorderedListItem]>) {
        self._content = content
    }
    
    @ViewBuilder
    public var body: some View {
        ScrollView(.vertical) {
            VStack {
                ForEach(content) { item in
                    ReorderedListItemView(state: .constant(.active), // Don't care
                                          isDragging: Binding(get: { self.draggedItem?.id == item.id }, set: { _ in }),
                                          label: item.label,
                                          itemType: type,
                                          icon: item.icon,
                                          action: item.action)
                    .onDrag {
                        print("drag item: \(item)\n===========================")
                        self.draggedItem = item
                        return NSItemProvider(object: item.id.uuidString as NSString )
                    }
                    .onDrop(of: [.text],
                            delegate: StandardDropDelegate(destinationItem: item,
                                                           sourceContent: $content,
                                                           draggedItem: $draggedItem))
                }
            }
        }
    }
}

As you can see, I've added some console prints in both onDrag(_:) and performDrop(info:). And the prints are like this:

drag item: StandardReorderedListItem(id: 3BDFE0B4-58DF-42C0-8FAB-109D3BFD191E, label: "Item 1", icon: Optional(SwiftUI.Image(provider: SwiftUI.ImageProviderBox<SwiftUI.Image.NamedImageProvider>)), action: Optional((Function)))
===========================
dropped item: Optional(StandardReorderedListItem(id: 3BDFE0B4-58DF-42C0-8FAB-109D3BFD191E, label: "Item 1", icon: Optional(SwiftUI.Image(provider: SwiftUI.ImageProviderBox<SwiftUI.Image.NamedImageProvider>)), action: Optional((Function))))
===========================
drag item: StandardReorderedListItem(id: 3BDFE0B4-58DF-42C0-8FAB-109D3BFD191E, label: "Item 1", icon: Optional(SwiftUI.Image(provider: SwiftUI.ImageProviderBox<SwiftUI.Image.NamedImageProvider>)), action: Optional((Function)))
===========================

Here you can see that the View is being dragged again right after the drop is performed. Even though it hasn't been touched at all right after I dragged and dropped the View. Can anyone help me to understand and fix what's going on here? Thank you.

PS: I can't use Transferable since I need to support at least iOS 15. So the new way to drag and drop won't be viable.


Solution

  • Based on the answer here: https://stackoverflow.com/a/72387161/4124849, we can trigger something when the ItemProvider is deinit-ed, which will happen when the dragging is finished. So, I made a derived class of NSItemProvider that will do just that like so:

    class ReorderedItemProvider: NSItemProvider {
        var didDeinit: (() -> Void)?
        
        deinit {
            didDeinit?()
        }
    }
    

    So now I add it in the View:

    public struct StandardReorderedListView: View {
        
        @Binding private var content: [StandardReorderedListItem]
        @State private var draggedItem: StandardReorderedListItem?
        private let type: ReorderedListType = .standard
        
        public init(_ content: Binding<[StandardReorderedListItem]>) {
            self._content = content
        }
        
        @ViewBuilder
        public var body: some View {
            ScrollView(.vertical) {
                VStack {
                    ForEach(content) { item in
                        ReorderedListItemView(state: .constant(.active), // Don't care
                                              isDragging: Binding(get: { self.draggedItem?.id == item.id }, set: { _ in }),
                                              label: item.label,
                                              itemType: type,
                                              icon: item.icon,
                                              action: item.action)
                        .onDrag {
                            print("drag item: \(item)\n===========================")
                            self.draggedItem = item
                            let provider = ReorderedItemProvider(object: item.id.uuidString as NSString)
                            //-- Added this
                            provider.didDeinit = {
                                //-- Use Task to sleep for 100 ms just to be safe and to make sure we reset the draggedItem AFTER the last faulty `onDrag()`
                                Task {
                                    try? await Task.sleep(nanoseconds: 100000000)
                                    self.draggedItem = nil
                                }
                            }
                            return provider
                        }
                        .onDrop(of: [.text],
                                delegate: StandardDropDelegate(destinationItem: item,
                                                               sourceContent: $content,
                                                               draggedItem: $draggedItem))
                    }
                }
            }
        }
    }