6

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
}
wildcard
  • 896
  • 6
  • 16
  • Just curious why you are using VStack with scrollView instead of List or List + ForEach? To move items within the same list you could just implement `onMove(perform action: Optional<(IndexSet, Int) -> Void>) -> some DynamicViewContent` instead of drag and drop – user1046037 May 26 '22 at 10:40
  • List in SwiftUI has some downsides regarding customizations compared to a Scrollview with ForEach. – wildcard May 26 '22 at 12:57
  • Personally I feel List + ForEach is flexible, anyways drag and drop is for a different purpose. I feel `onMove` is more appropriate for what you are trying to achieve – user1046037 May 26 '22 at 16:03
  • If you must use drag and drop then DropDelegate has `dropEntered` and `dropExited` call back functions – user1046037 May 26 '22 at 16:09

4 Answers4

8

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

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 1
    The deinit is called quite late, causing very glitchy behaviour for me. – Ivar van Wooning Jun 13 '22 at 11:11
  • 1
    On macOS, the first created NSItemProvider is not destroyed until _just after_ another is created for a subsequent drag, causing draggedItem to be reset immediately after any subsequent drag. – Giles Sep 21 '22 at 10:25
1

I had same issues and here is my example with solution how to resolve it:

https://github.com/kvyat/DragAndDrop

  • 1
    Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Sep 12 '22 at 13:14
0

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()
    }
}
0

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.

Felix Lieb
  • 414
  • 3
  • 14