skip to Main Content

I implemented a simple drag and drop for reordering items in a VStack/Scrollview
according to this Solution

I store the currently dragged item in a property called draggingItem and set the opacity to 0 depending if it is nil or not.
When performDrop in the DropDelegate gets called I set draggingItem back to nil to make the corresponding item visible again.

There are two scenarios where performDrop seems not to get called:

  1. When the item was onDrag and then released in place without moving.

  2. When the item does get released slightly offset the actual droparea.

This is causing that the item does not get visible again because draggingItem does not get set to nil again.

Any Ideas for a better place for setting draggingItem back to nil?

enter image description here

View:

struct ReorderingTestsView: View {
    
    @State var draggingItem: BookItem?
    @State var items: [BookItem] = [
        BookItem(name: "Harry Potter"),
        BookItem(name: "Lord of the Rings"),
        BookItem(name: "War and Peace"),
        BookItem(name: "Peter Pane")
    ]
    
    var body: some View {
        VStack{
            ScrollView{
                VStack(spacing: 10){
                    ForEach(items){ item in
                        VStack{
                            Text(item.name)
                                .padding(8)
                                .frame(maxWidth: .infinity)
                        }
                        .background(Color.gray)
                        .cornerRadius(8)
                        .opacity(item.id == draggingItem?.id ? 0.01 : 1) // <- HERE
                        .onDrag {
                            draggingItem = item
                            return NSItemProvider(contentsOf: URL(string: "(item.id)"))!
                        }
                        .onDrop(of: [.item], delegate: DropViewDelegate(currentItem: item, items: $items, draggingItem: $draggingItem))
                    }
                }
                .animation(.default, value: items)
            }
        }
        .padding(.horizontal)
    }
}

DropViewDelegate:

struct DropViewDelegate: DropDelegate {
    
    var currentItem: BookItem
    var items: Binding<[BookItem]>
    var draggingItem: Binding<BookItem?>

    func performDrop(info: DropInfo) -> Bool {
        draggingItem.wrappedValue = nil // <- HERE
        return true
    }
    
    func dropEntered(info: DropInfo) {
        if currentItem.id != draggingItem.wrappedValue?.id {
            let from = items.wrappedValue.firstIndex(of: draggingItem.wrappedValue!)!
            let to = items.wrappedValue.firstIndex(of: currentItem)!
            if items[to].id != draggingItem.wrappedValue?.id {
                items.wrappedValue.move(fromOffsets: IndexSet(integer: from),
                    toOffset: to > from ? to + 1 : to)
            }
        }
    }
    
    func dropUpdated(info: DropInfo) -> DropProposal? {
       return DropProposal(operation: .move)
    }
}

TestItem:

struct BookItem: Identifiable, Equatable {
    var id = UUID()
    var name: String
}

4

Answers


  1. I investigated a problem 1) and proposed solution in https://stackoverflow.com/a/72181964/12299030

    The problem 2) can be solved with help of custom overridden item provider and action on deinit, `cause provider is destroyed when drag session is canceled.

    Tested with Xcode 13.4 / iOS 15.5

    demo

    Main part:

        // for demo simplicity, a convenient init can be created instead
        class MYItemProvider: NSItemProvider {
            var didEnd: (() -> Void)?
            deinit {
                didEnd?()     // << here !!
            }
        }
    
    // ...
    
        let provider = MYItemProvider(contentsOf: URL(string: "(item.id)"))!
        provider.didEnd = {
            DispatchQueue.main.async {
                draggingItem = nil      // << here !!
            }
        }
    

    Complete test module is here

    Login or Signup to reply.
  2. For iOS you can add .onDrop to the fullscreen view and catch performDrop there.

    For macOS I could not find any solution with DropDelegate. For that reason you can use NSApplication.shared.currentEvent?.type == .leftMouseUp
    something like this

    Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { timer in
        if (NSApplication.shared.currentEvent?.type == .leftMouseUp) {
           //perform your endDrop action                
           timer.invalidate()
        }
    }
    
    Login or Signup to reply.
  3. The undetected object-release, when the item is "onDrag", is caused by the dragged view’s opacity being set to 0. SwiftUI ignores interaction with views with opacity of 0.

    When you drag over a another draggable item, dropEntered of that item is called and the reordering takes place. After the reordering, the drag is now over "itself". But since the opacity is set to 0, SwiftUI ignores the view, hence the drag no longer is over a drop-target. Due to that, on touch-up, the drop is canceled and performDrop is not being called.

    If you still want the item to be "invisible", you can use a very low non-zero-value for opacity, like 0.001 and it will work. I found it looked quite nice, when using an opacity of 0.3 or 0.5.

    Login or Signup to reply.
  4. I had same issues and here is my example with solution how to resolve it:

    https://github.com/kvyat/DragAndDrop

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search