简体   繁体   English

SwiftUI | 使用 onDrag 和 onDrop 在一个 LazyGrid 中重新排序项目?

[英]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?我想知道是否可以使用View.onDragView.onDrop在一个LazyGrid手动添加拖放重新排序?

Though I was able to make every Item draggable using onDrag , I have no idea how to implement the dropping part.虽然我能够使用onDrag使每个 Item 可拖动,但我不知道如何实现放置部分。

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 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;要点是: a) 重新排序不假设等待丢弃,因此应动态跟踪; b) to avoid dances with coordinates it is more simple to handle drop by grid item views; b)为了避免与坐标跳舞,处理网格项目视图的拖放更简单; c) find what to where move and do this in data model, so SwiftUI animate views by itself. c) 在数据 model 中找到要移动的内容并执行此操作,因此 SwiftUI 会自行为视图设置动画。

Tested with Xcode 12b3 / iOS 14用 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))
    }
}

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 (ie apologies for being less than eloquent):上面的优秀解决方案还引发了一些额外的问题,所以这是我在 1 月 1 日宿醉时能想到的(即为不够雄辩而道歉):

  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.我添加了一个 bool 来检查视图是否已被拖动,如果还没有,那么它不会首先隐藏视图。 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.这有点像黑客,因为它并没有真正重置,它只是推迟隐藏视图,直到它知道你想要拖动它。 Ie 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.通过添加 dropOutside 代表已经部分解决了这个问题,但是 SwiftUI 不会触发它,除非您有背景视图(如颜色),我认为这会引起一些混乱。 I therefore added a background in grey to illustrate how to properly trigger it.因此,我添加了一个灰色背景来说明如何正确触发它。

Hope this helps anyone:希望这可以帮助任何人:

import SwiftUI
import UniformTypeIdentifiers

struct GridData: Identifiable, Equatable {
    let id: String
}

//MARK: - Model

class 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: - Grid

struct 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: - GridItem

struct 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 :这是我的解决方案(基于 Asperi 的回答),适用于那些为ForEach寻求通用方法的人,我在其中抽象了视图

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: DragRelocateDelegate基本上保持不变,尽管我使它更通用和更安全:

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

Here is how you implement the on drop part.以下是您如何实现 on drop 部分。 But remember the ondrop can allow content to be dropped in from outside the app if the data conforms to the UTType .但是请记住,如果数据符合UTTypeondrop可以允许从应用程序外部放入内容。 More on UTTypes .更多关于UTTypes

Add the onDrop instance to your lazyVGrid.将 onDrop 实例添加到您的lazyVGrid。

           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) }
                }
            }.onDrop(of: ["public.plain-text"], delegate: CardsDropDelegate(listData: $model.data))

Create a DropDelegate to handling dropped content and the drop location with the given view.创建一个 DropDelegate 以使用给定视图处理放置的内容和放置位置。

struct CardsDropDelegate: DropDelegate {
    @Binding var listData: [MyData]

    func performDrop(info: DropInfo) -> Bool {
        // check if data conforms to UTType
        guard info.hasItemsConforming(to: ["public.plain-text"]) else {
            return false
        }
        let items = info.itemProviders(for: ["public.plain-text"])
        for item in items {
            _ = item.loadObject(ofClass: String.self) { data, _ in
                // idea is to reindex data with dropped view
                let index = Int(data!)
                DispatchQueue.main.async {
                        // id of dropped view
                        print("View Id dropped \(index)")
                }
            }
        }
        return true
    }
}

Also the only real useful parameter of performDrop is info.location a CGPoint of the drop location, Mapping a CGPoint to the view you want to replace seems unreasonable. performDrop唯一真正有用的参数是info.location放置位置的 CGPoint,将 CGPoint 映射到要替换的视图似乎不合理。 I would think the OnMove would be a better option and would make moving your data/Views a breeze.我认为OnMove将是一个更好的选择,并且可以轻松移动您的数据/视图。 I was unsuccessful to get OnMove working within a LazyVGrid .我没有成功让OnMoveLazyVGrid中工作。

As LazyVGrid are still in beta and are bound to change.由于LazyVGrid仍处于测试阶段,并且必然会发生变化。 I would abstain from use on more complex tasks.我会避免使用更复杂的任务。

Goal: Reordering Items in HStack目标:重新排序 HStack 中的项目

I was trying to figure out how to leverage this solution in SwiftUI for macOS when dragging icons to re-order a horizontal set of items.在拖动图标以重新排列一组水平项目时,我试图弄清楚如何在 macOS 的 SwiftUI 中利用此解决方案。 Thanks to @ramzesenok and @Asperi for the overall solution.感谢@ramzesenok 和@Asperi 的整体解决方案。 I added a CGPoint property along with their solution to achieve the desired behavior.我添加了一个 CGPoint 属性及其解决方案以实现所需的行为。 See the animation below.请参阅下面的 animation。

在此处输入图像描述

Define the point定义点

 @State private var drugItemLocation: CGPoint?

I used in dropEntered , dropExited , and performDrop DropDelegate functions.我在dropEntereddropExitedperformDrop DropDelegate 函数中使用过。


func dropEntered(info: DropInfo) {
    if current == nil {
        current = item
        drugItemLocation = info.location
    }

    guard item != current, 
          let current = current,
          let from = icons.firstIndex(of: current),
          let toIndex = icons.firstIndex(of: item) else { return }

          hasChangedLocation = true
          drugItemLocation = info.location

    if icons[toIndex] != current {
        icons.move(fromOffsets: IndexSet(integer: from), toOffset: toIndex > from ? toIndex + 1 : toIndex)
    }
}

func dropExited(info: DropInfo) {
    drugItemLocation = nil
}

func performDrop(info: DropInfo) -> Bool {
   hasChangedLocation = false
   drugItemLocation = nil
   current = nil
   return true
}

For a full demo, I created a gist using Playgrounds对于完整的演示,我使用 Playgrounds 创建了一个要点

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM