swift 快速用户界面|使用onDrag和onDrop对单个LazyGrid中的项进行重新排序?

sxpgvts3  于 2022-11-21  发布在  Swift
关注(0)|答案(6)|浏览(148)

我想知道是否可以使用View.onDragView.onDrop在一个LazyGrid中手动添加拖放重新排序?
虽然我能够使用onDrag使每个Item都可拖动,但我不知道如何实现拖放部分。

下面是我正在试验的代码:

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

谢谢你,谢谢你

nbysray5

nbysray51#

快速用户界面2.0

这是一个简单的演示(没有太多的调整,因为代码增长很快)。

要点是:a)重新排序不假定等待丢弃,因此应当在运行中跟踪; B)为了避免与坐标的跳变,更简单的是处理按网格项目视图放置; c)找到要移动的内容并在数据模型中执行此操作,因此SwiftUI会自动显示视图。
使用Xcode 12 b3/ 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)
    }
}

编辑

以下是如何修复将拖动项拖到任何网格项之外时永不消失的问题:
第一次

5tmbdcev

5tmbdcev2#

以下是我的解决方案(基于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)
                        }
                    }
                )
        }
    }
}

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

最后是实际用法:

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

6mw9ycah3#

还有一些额外的问题提出了上述优秀的解决方案,所以这里是我可以想出1月1日与宿醉(即道歉,因为不善于雄辩):
1.如果拾取网格项并释放它(取消),则不会重置视图
我添加了一个bool来检查视图是否已经被拖动过,如果没有,那么它就不会在第一时间隐藏视图。这是一个小技巧,因为它并没有真正重置,它只是推迟隐藏视图,直到它知道你想拖动它。也就是说,如果你拖动得非常快,你可以在隐藏视图之前看到它。
1.如果将网格项拖到视图之外,则不会重置视图
这个问题已经通过添加dropOutside委托得到了部分解决,但是SwiftUI不会触发它,除非你有一个背景视图(比如一个颜色),我认为这引起了一些混乱。因此我添加了一个灰色背景来说明如何正确触发它。
希望这对任何人都有帮助:

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

k5hmc34c4#

这里是如何实现on drop部分的。但是请记住,如果数据符合UTTypeondrop可以允许从应用外部拖放内容。更多UTTypes。
将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))

创建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
    }
}

此外,performDrop唯一真实的有用的参数是info.location,它是放置位置的CGPoint,将CGPointMap到要替换的视图似乎是不合理的。我认为OnMove是一个更好的选择,它可以使移动数据/视图变得轻而易举。我没有成功地在LazyVGrid中使用OnMove
由于LazyVGrid还在测试阶段,肯定会改变。我会放弃在更复杂的任务中使用。

puruo6ea

puruo6ea5#

目标:重新排序HStack中的项目

我试图弄清楚如何在SwiftUI for macOS中利用这个解决方案来拖动图标以重新排序一组水平项目。感谢@ramzesenok和@Asperi提供的整体解决方案。我在他们的解决方案中添加了一个CGPoint属性来实现所需的行为。请看下面的动画。

定义点

@State private var drugItemLocation: CGPoint?

我在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
}

作为完整的演示,我使用Playgrounds创建了一个gist

2ledvvac

2ledvvac6#

我提出了一种在macOS上运行良好的不同方法。我使用.gesture(DragGesture)和一个helper类和修饰符来代替.onDrag.onDrop

以下是辅助对象(只需将其复制到新文件):

//
//  DraggingManager.swift
//  VirtualMachine
//
//  Created by Damian Dudycz on 18/11/2022.
//

import SwiftUI

// Helper class for dragging objects inside LazyVGrid.
// Grid items must be of the same size
final class DraggingManager<Entry: Identifiable>: ObservableObject {
    
    let coordinateSpaceID = UUID()
    
    private var gridDimensions: CGRect = .zero
    private var numberOfColumns = 0
    private var numberOfRows = 0
    // Positions of entries views in coordinate space
    private var framesOfEntries = [Int: CGRect]()
    
    func setFrameOfEntry(at entryIndex: Int, frame: CGRect) {
        guard draggedEntry == nil else { return }
        framesOfEntries[entryIndex] = frame
    }
    
    var initialEntries: [Entry] = [] {
        didSet {
            entries = initialEntries
            calculateGridDimensions()
        }
    }
    @Published // Currently displayed (while dragging)
    var entries: [Entry]?
    
    // Detected when dragging starts
    var draggedEntry: Entry? {
        didSet { draggedEntryInitialIndex = initialEntries.firstIndex(where: { $0.id == draggedEntry?.id }) }
    }
    var draggedEntryInitialIndex: Int?
    
    // Last index where device was dragged to
    var draggedToIndex: Int? {
        didSet {
            guard let draggedToIndex, let draggedEntryInitialIndex, let draggedEntry else { return }
            var newArray = initialEntries
            newArray.remove(at: draggedEntryInitialIndex)
            newArray.insert(draggedEntry, at: draggedToIndex)
            withAnimation {
                entries = newArray
            }
        }
    }

    func indexForPoint(_ point: CGPoint) -> Int {
        let x = max(0, min(Int((point.x - gridDimensions.origin.x) / gridDimensions.size.width), numberOfColumns - 1))
        let y = max(0, min(Int((point.y - gridDimensions.origin.y) / gridDimensions.size.height), numberOfRows - 1))
        return max(0, min(y * numberOfColumns + x, initialEntries.count - 1))
    }

    private func calculateGridDimensions() {
        let allFrames = framesOfEntries.values
        let rows = Dictionary(grouping: allFrames) { frame in
            frame.origin.y
        }
        numberOfRows = rows.count
        numberOfColumns = rows.values.map(\.count).max() ?? 0
        let minX = allFrames.map(\.minX).min() ?? 0
        let maxX = allFrames.map(\.maxX).max() ?? 0
        let minY = allFrames.map(\.minY).min() ?? 0
        let maxY = allFrames.map(\.maxY).max() ?? 0
        let width = (maxX - minX) / CGFloat(numberOfColumns)
        let height = (maxY - minY) / CGFloat(numberOfRows)
        let origin = CGPoint(x: minX, y: minY)
        let size = CGSize(width: width, height: height)
        gridDimensions = CGRect(origin: origin, size: size)
    }
        
}

extension View {
    
    // Allows item in LazyVGrid to be dragged between other items.
    func draggable(draggingManager: DraggingManager<VMDeviceCategory>, entry: VMDeviceCategory, originalEntries: [VMDeviceCategory], updateOnFinish: @escaping ([VMDeviceCategory]) -> Void) -> some View {
        let entryIndex = originalEntries.firstIndex(where: { $0.id == entry.id })!
        let isBeingDragged = entryIndex == draggingManager.draggedEntryInitialIndex
        let scale: CGFloat = isBeingDragged ? 1.1 : 1.0
        return background(
            GeometryReader { geometry -> Color in
                draggingManager.setFrameOfEntry(at: entryIndex, frame: geometry.frame(in: .named(draggingManager.coordinateSpaceID)))
                return .clear
            }
        )
        .scaleEffect(x: scale, y: scale)
        .gesture(
            dragGesture(
                draggingManager: draggingManager,
                device: entry,
                originalEntries: originalEntries,
                updateOnFinish: updateOnFinish
            )
        )
    }
    
    private func dragGesture(draggingManager: DraggingManager<VMDeviceCategory>, device: VMDeviceCategory, originalEntries: [VMDeviceCategory], updateOnFinish: @escaping ([VMDeviceCategory]) -> Void) -> some Gesture {
        DragGesture(coordinateSpace: .named(draggingManager.coordinateSpaceID))
            .onChanged { value in
                // Detect start of dragging
                if draggingManager.draggedEntry?.id != device.id {
                    withAnimation {
                        draggingManager.initialEntries = originalEntries
                        draggingManager.draggedEntry = device
                    }
                }
                
                let point = draggingManager.indexForPoint(value.location)
                if point != draggingManager.draggedToIndex {
                    draggingManager.draggedToIndex = point
                }
            }
            .onEnded { value in
                withAnimation {
                    updateOnFinish(draggingManager.entries!)
                    draggingManager.entries = nil
                    draggingManager.draggedEntry = nil
                    draggingManager.draggedToIndex = nil
                }
            }
    }
    
}

现在,要在视图中使用它,您必须执行以下几项操作:

  • 创建一个StateObject的draggingManager
  • 创建一个var,该var公开您正在使用的真实的数组或draggingManager在拖动过程中使用的临时数组。
  • coordinateSpace从draggingManager应用到容器(LazyVGrid)这样,draggingManager只在过程中修改数组的副本,您可以在拖动完成后更新原始数组。
@StateObject
  private var devicesDraggingManager = DraggingManager<VMDeviceCategory>()
  // Currently displaying devices - different during dragging.
  private var displayedDevices: [VMDeviceCategory] { devicesDraggingManager.entries ?? vmEntity.config.devices }

  var body: some View {
      Section("Devices") {
          LazyVGrid(columns: [.init(.adaptive(minimum: 64, maximum: 64))], alignment: .leading, spacing: 20) {
              Group {
                  ForEach(displayedDevices) { device in
                      Button(action: { }) {
                          device.label
                              .draggable(
                                  draggingManager: devicesDraggingManager,
                                  entry: device,
                                  originalEntries: vmEntity.config.devices,
                                  updateOnFinish: { vmEntity.config.devices = $0 }
                              )
                      }
                  }
                  Button(action: { }, label: { Label("Add device", systemImage: "plus") })
              }
              .frame(width: 64)
              .labelStyle(VerticalLabelStyle())
          }
          .coordinateSpace(name: devicesDraggingManager.coordinateSpaceID)
          .frame(maxWidth: .infinity, maxHeight: .infinity)
          .buttonStyle(.plain)
      }
  }

相关问题