Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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!

like image 232
Mofawaw Avatar asked Jun 27 '20 06:06

Mofawaw


2 Answers

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

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))     } } 
like image 126
Asperi Avatar answered Oct 02 '22 03:10

Asperi


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) } 
like image 40
ramzesenok Avatar answered Oct 02 '22 03:10

ramzesenok