SwiftUI | Using onDrag and onDrop to reorder Items within one single LazyGrid? SwiftUI | Using onDrag and onDrop to reorder Items within one single LazyGrid? swift swift

SwiftUI | Using onDrag and onDrop to reorder Items within one single LazyGrid?


SwiftUI 2.0

Here is completed simple demo of possible approach (did not tune it much, `cause code growing fast as for demo).

demo

Important points are: a) reordering does not suppose waiting for drop, so should be tracked on the fly; b) to avoid dances with coordinates it is more simple to handle drop by grid item views; c) find what to where move and do this in data model, so SwiftUI animate views by itself.

Tested with Xcode 12b3 / iOS 14

import SwiftUIimport UniformTypeIdentifiersstruct GridData: Identifiable, Equatable {    let id: Int}//MARK: - Modelclass Model: ObservableObject {    @Published var data: [GridData]    let columns = [        GridItem(.fixed(160)),        GridItem(.fixed(160))    ]    init() {        data = Array(repeating: GridData(id: 0), count: 100)        for i in 0..<data.count {            data[i] = GridData(id: i)        }    }}//MARK: - Gridstruct DemoDragRelocateView: View {    @StateObject private var model = Model()    @State private var dragging: GridData?    var body: some View {        ScrollView {           LazyVGrid(columns: model.columns, spacing: 32) {                ForEach(model.data) { d in                    GridItemView(d: d)                        .overlay(dragging?.id == d.id ? Color.white.opacity(0.8) : Color.clear)                        .onDrag {                            self.dragging = d                            return NSItemProvider(object: String(d.id) as NSString)                        }                        .onDrop(of: [UTType.text], delegate: DragRelocateDelegate(item: d, listData: $model.data, current: $dragging))                }            }.animation(.default, value: model.data)        }    }}struct DragRelocateDelegate: DropDelegate {    let item: GridData    @Binding var listData: [GridData]    @Binding var current: GridData?    func dropEntered(info: DropInfo) {        if item != current {            let from = listData.firstIndex(of: current!)!            let to = listData.firstIndex(of: item)!            if listData[to].id != current!.id {                listData.move(fromOffsets: IndexSet(integer: from),                    toOffset: to > from ? to + 1 : to)            }        }    }    func dropUpdated(info: DropInfo) -> DropProposal? {        return DropProposal(operation: .move)    }    func performDrop(info: DropInfo) -> Bool {        self.current = nil        return true    }}//MARK: - GridItemstruct GridItemView: View {    var d: GridData    var body: some View {        VStack {            Text(String(d.id))                .font(.headline)                .foregroundColor(.white)        }        .frame(width: 160, height: 240)        .background(Color.green)    }}

Edit

Here is how to fix the never disappearing drag item when dropped outside of any grid item:

struct DropOutsideDelegate: DropDelegate {     @Binding var current: GridData?              func performDrop(info: DropInfo) -> Bool {        current = nil        return true    }}
struct DemoDragRelocateView: View {    ...    var body: some View {        ScrollView {            ...        }        .onDrop(of: [UTType.text], delegate: DropOutsideDelegate(current: $dragging))    }}


There was a few additional issues raised to the excellent solutions above, so here's what I could come up with on Jan 1st with a hangover (i.e. apologies for being less than eloquent):

  1. If you pick a griditem and release it (to cancel), then the view is not reset

I added a bool that checks if the view had been dragged yet, and if it hasn't then it doesn't hide the view in the first place. It's a bit of a hack, because it doesn't really reset, it just postpones hiding the view until it knows that you want to drag it. I.e. if you drag really fast, you can see the view briefly before it's hidden.

  1. If you drop a griditem outside the view, then the view is not reset

This one was partially addressed already, by adding the dropOutside delegate, but SwiftUI doesn't trigger it unless you have a background view (like a color), which I think caused some confusion. I therefore added a background in grey to illustrate how to properly trigger it.

Hope this helps anyone:

import SwiftUIimport UniformTypeIdentifiersstruct GridData: Identifiable, Equatable {    let id: String}//MARK: - Modelclass Model: ObservableObject {    @Published var data: [GridData]    let columns = [        GridItem(.flexible(minimum: 60, maximum: 60))    ]    init() {        data = Array(repeating: GridData(id: "0"), count: 50)        for i in 0..<data.count {            data[i] = GridData(id: String("\(i)"))        }    }}//MARK: - Gridstruct DemoDragRelocateView: View {    @StateObject private var model = Model()    @State private var dragging: GridData? // I can't reset this when user drops view ins ame location as drag started    @State private var changedView: Bool = false    var body: some View {        VStack {            ScrollView(.vertical) {               LazyVGrid(columns: model.columns, spacing: 5) {                    ForEach(model.data) { d in                        GridItemView(d: d)                            .opacity(dragging?.id == d.id && changedView ? 0 : 1)                            .onDrag {                                self.dragging = d                                changedView = false                                return NSItemProvider(object: String(d.id) as NSString)                            }                            .onDrop(of: [UTType.text], delegate: DragRelocateDelegate(item: d, listData: $model.data, current: $dragging, changedView: $changedView))                                                }                }.animation(.default, value: model.data)            }        }        .frame(maxWidth:.infinity, maxHeight: .infinity)        .background(Color.gray.edgesIgnoringSafeArea(.all))        .onDrop(of: [UTType.text], delegate: DropOutsideDelegate(current: $dragging, changedView: $changedView))    }}struct DragRelocateDelegate: DropDelegate {    let item: GridData    @Binding var listData: [GridData]    @Binding var current: GridData?    @Binding var changedView: Bool        func dropEntered(info: DropInfo) {                if current == nil { current = item }                changedView = true                if item != current {            let from = listData.firstIndex(of: current!)!            let to = listData.firstIndex(of: item)!            if listData[to].id != current!.id {                listData.move(fromOffsets: IndexSet(integer: from),                    toOffset: to > from ? to + 1 : to)            }        }    }    func dropUpdated(info: DropInfo) -> DropProposal? {        return DropProposal(operation: .move)    }    func performDrop(info: DropInfo) -> Bool {        changedView = false        self.current = nil        return true    }    }struct DropOutsideDelegate: DropDelegate {    @Binding var current: GridData?    @Binding var changedView: Bool            func dropEntered(info: DropInfo) {        changedView = true    }    func performDrop(info: DropInfo) -> Bool {        changedView = false        current = nil        return true    }}//MARK: - GridItemstruct GridItemView: View {    var d: GridData    var body: some View {        VStack {            Text(String(d.id))                .font(.headline)                .foregroundColor(.white)        }        .frame(width: 60, height: 60)        .background(Circle().fill(Color.green))    }}


Here's my solution (based on Asperi's answer) for those who seek for a generic approach for ForEach where I abstracted the view away:

struct ReorderableForEach<Content: View, Item: Identifiable & Equatable>: View {    let items: [Item]    let content: (Item) -> Content    let moveAction: (IndexSet, Int) -> Void        // A little hack that is needed in order to make view back opaque    // if the drag and drop hasn't ever changed the position    // Without this hack the item remains semi-transparent    @State private var hasChangedLocation: Bool = false    init(        items: [Item],        @ViewBuilder content: @escaping (Item) -> Content,        moveAction: @escaping (IndexSet, Int) -> Void    ) {        self.items = items        self.content = content        self.moveAction = moveAction    }        @State private var draggingItem: Item?        var body: some View {        ForEach(items) { item in            content(item)                .overlay(draggingItem == item && hasChangedLocation ? Color.white.opacity(0.8) : Color.clear)                .onDrag {                    draggingItem = item                    return NSItemProvider(object: "\(item.id)" as NSString)                }                .onDrop(                    of: [UTType.text],                    delegate: DragRelocateDelegate(                        item: item,                        listData: items,                        current: $draggingItem,                        hasChangedLocation: $hasChangedLocation                    ) { from, to in                        withAnimation {                            moveAction(from, to)                        }                    }                )        }    }}

The DragRelocateDelegate basically stayed the same, although I made it a bit more generic and safer:

struct DragRelocateDelegate<Item: Equatable>: DropDelegate {    let item: Item    var listData: [Item]    @Binding var current: Item?    @Binding var hasChangedLocation: Bool    var moveAction: (IndexSet, Int) -> Void    func dropEntered(info: DropInfo) {        guard item != current, let current = current else { return }        guard let from = listData.firstIndex(of: current), let to = listData.firstIndex(of: item) else { return }                hasChangedLocation = true        if listData[to] != current {            moveAction(IndexSet(integer: from), to > from ? to + 1 : to)        }    }        func dropUpdated(info: DropInfo) -> DropProposal? {        DropProposal(operation: .move)    }        func performDrop(info: DropInfo) -> Bool {        hasChangedLocation = false        current = nil        return true    }}

And finally here is the actual usage:

ReorderableForEach(items: itemsArr) { item in    SomeFancyView(for: item)} moveAction: { from, to in    itemsArr.move(fromOffsets: from, toOffset: to)}