I was wondering if it is possible to use the View.onDrag
and View.onDrop
to add drag and drop reordering within one LazyGrid
manually?
Though I was able to make every Item draggable using onDrag
, I have no idea how to implement the dropping part.
Here is the code I was experimenting with:
import SwiftUI //MARK: - Data struct Data: Identifiable { let id: Int } //MARK: - Model class Model: ObservableObject { @Published var data: [Data] let columns = [ GridItem(.fixed(160)), GridItem(.fixed(160)) ] init() { data = Array<Data>(repeating: Data(id: 0), count: 100) for i in 0..<data.count { data[i] = Data(id: i) } } } //MARK: - Grid struct ContentView: View { @StateObject private var model = Model() var body: some View { ScrollView { LazyVGrid(columns: model.columns, spacing: 32) { ForEach(model.data) { d in ItemView(d: d) .id(d.id) .frame(width: 160, height: 240) .background(Color.green) .onDrag { return NSItemProvider(object: String(d.id) as NSString) } } } } } } //MARK: - GridItem struct ItemView: View { var d: Data var body: some View { VStack { Text(String(d.id)) .font(.headline) .foregroundColor(.white) } } }
Thank you!
SwiftUI 2.0
Here is completed simple demo of possible approach (did not tune it much, `cause code growing fast as for 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 SwiftUI import UniformTypeIdentifiers struct GridData: Identifiable, Equatable { let id: Int } //MARK: - Model class 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: - Grid struct 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: - GridItem struct 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) } }
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)) } }
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 } }
ReorderableForEach(items: itemsArr) { item in SomeFancyView(for: item) } moveAction: { from, to in itemsArr.move(fromOffsets: from, toOffset: to) }
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With