SwiftUI | Использование onDrag и onDrop для изменения порядка элементов в одной LazyGrid?

Мне было интересно, можно ли использовать View.onDrag а также View.onDrop добавить переупорядочивание перетаскиванием в пределах одного LazyGrid вручную?

Хотя я смог перетащить каждый элемент, используя onDrag, Понятия не имею, как реализовать выпадающую часть.

Вот код, с которым я экспериментировал:

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

Спасибо!

7 ответов

Решение

SwiftUI 2.0

Вот и завершена простая демонстрация возможного подхода (особо не настраивал, потому что код быстро растет как для демонстрации).

Важные моменты: а) переупорядочивание не предполагает ожидания сброса, поэтому его следует отслеживать на лету; б) чтобы избежать танцев с координатами, проще обрабатывать выпадение по элементам сетки; c) найдите, что куда переместить, и сделайте это в модели данных, чтобы SwiftUI самостоятельно анимировал представления.

Протестировано с 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)
    }
}

Вот мое решение (основанное на ответе Аспери) для тех, кто ищет общий подход для ForEachгде я абстрагировался от вида:

      struct ReorderableForEach<Content: View, Item: Identifiable & Equatable>: View {
    let items: [Item]
    let content: (Item) -> Content
    let moveAction: (IndexSet, Int) -> Void
    
    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 ? 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
                    ) { from, to in
                        withAnimation {
                            moveAction(from, to)
                        }
                    }
                )
        }
    }
}

В DragRelocateDelegate в основном остался прежним, хотя я сделал его более общим и безопасным:

      struct DragRelocateDelegate<Item: Equatable>: DropDelegate {
    let item: Item
    var listData: [Item]
    @Binding var current: Item?
    
    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 }
        
        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 {
        self.current = nil
        return true
    }
}

И, наконец, вот фактическое использование:

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

Было несколько дополнительных проблем, связанных с превосходными решениями, указанными выше, поэтому вот что я мог придумать 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))
    }
}

Я пришел с немного другим подходом, который отлично работает на macOS. Вместо использования.onDragи.onDropЯ использую.gesture(DragGesture)с вспомогательным классом и модификаторами.

Вот вспомогательные объекты (просто скопируйте их в новый файл):

      // 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
    private var framesOfEntries = [Int: CGRect]() // Positions of entries views in coordinate space
    
    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]?
    
    var draggedEntry: Entry? { // Detected when dragging starts
        didSet { draggedEntryInitialIndex = initialEntries.firstIndex(where: { $0.id == draggedEntry?.id }) }
    }
    var draggedEntryInitialIndex: Int?
    
    var draggedToIndex: Int? { // Last index where device was dragged to
        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)
    }
        
}

struct Draggable<Entry: Identifiable>: ViewModifier {
    
    @Binding
    var originalEntries: [Entry]
    let draggingManager: DraggingManager<Entry>
    let entry: Entry

    @ViewBuilder
    func body(content: Content) -> some View {
        if let entryIndex = originalEntries.firstIndex(where: { $0.id == entry.id }) {
            let isBeingDragged = entryIndex == draggingManager.draggedEntryInitialIndex
            let scale: CGFloat = isBeingDragged ? 1.1 : 1.0
            content.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,
                    entry: entry,
                    originalEntries: $originalEntries
                )
            )
        }
        else {
            content
        }
    }
    
    func dragGesture<Entry: Identifiable>(draggingManager: DraggingManager<Entry>, entry: Entry, originalEntries: Binding<[Entry]>) -> some Gesture {
        DragGesture(coordinateSpace: .named(draggingManager.coordinateSpaceID))
            .onChanged { value in
                // Detect start of dragging
                if draggingManager.draggedEntry?.id != entry.id {
                    withAnimation {
                        draggingManager.initialEntries = originalEntries.wrappedValue
                        draggingManager.draggedEntry = entry
                    }
                }
                
                let point = draggingManager.indexForPoint(value.location)
                if point != draggingManager.draggedToIndex {
                    draggingManager.draggedToIndex = point
                }
            }
            .onEnded { value in
                withAnimation {
                    originalEntries.wrappedValue = draggingManager.entries!
                    draggingManager.entries = nil
                    draggingManager.draggedEntry = nil
                    draggingManager.draggedToIndex = nil
                }
            }
    }

}

extension View {
    // Allows item in LazyVGrid to be dragged between other items.
    func draggable<Entry: Identifiable>(draggingManager: DraggingManager<Entry>, entry: Entry, originalEntries: Binding<[Entry]>) -> some View {
        self.modifier(Draggable(originalEntries: originalEntries, draggingManager: draggingManager, entry: entry))
    }
}

Теперь, чтобы использовать его в поле зрения, вам нужно сделать несколько вещей:

  • Создайте draggingManager, который являетсяStateObject

  • Создайте var, который предоставляет либо реальный массив, который вы используете, либо временный массив, используемый draggingManager во время перетаскивания.

  • ПрименятьcoordinateSpaceиз draggingManager в контейнер (LazyVGrid). Таким образом, draggingManager изменяет только свою копию массива во время процесса, и вы можете обновить оригинал после завершения перетаскивания.

    структура VirtualMachineSettingsDevicesView: View {

              @ObservedObject
      var vmEntity: VMEntity
    
      @StateObject
      private var devicesDraggingManager = DraggingManager<VMDeviceInfo>()
      // Currently displaying devices - different during dragging.
      private var displayedDevices: [VMDeviceInfo] { 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: { configureDevice = device }) {
                              device.label
                                  .draggable(
                                      draggingManager: devicesDraggingManager,
                                      entry: device,
                                      originalEntries: $vmEntity.config.devices
                                  )
                          }
                      }
                      Button(action: { configureNewDevice = true }, label: { Label("Add device", systemImage: "plus") })
                  }
                  .labelStyle(IconLabelStyle())
              }
              .coordinateSpace(name: devicesDraggingManager.coordinateSpaceID)
              .frame(maxWidth: .infinity, maxHeight: .infinity)
              .buttonStyle(.plain)
          }
    

    }

Цель: изменение порядка элементов в HStack

Я пытался понять, как использовать это решение в SwiftUI для macOS при перетаскивании значков для изменения порядка горизонтального набора элементов. Спасибо @ramzesenok и @Asperi за общее решение. Я добавил свойство CGPoint вместе с их решением для достижения желаемого поведения. Смотрите анимацию ниже.

Определите точку

       @State private var drugItemLocation: CGPoint?

я использовал в dropEntered, dropExited, а также performDropФункции 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 .

Вот как вы реализуете часть "on drop". Но помнитеondrop может разрешить добавление контента извне приложения, если данные соответствуют UTType. Подробнее о 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.locationCGPoint места перетаскивания, отображение CGPoint на представление, которое вы хотите заменить, кажется неразумным. Я бы подумалOnMoveбыл бы лучшим вариантом и упростил бы перемещение ваших данных / представлений. Мне не удалось получитьOnMove работая в LazyVGrid.

В качестве LazyVGridвсе еще находятся в стадии бета-тестирования и обязательно изменятся. Я бы воздержался от использования в более сложных задачах.

Действительно здорово, что ответ на первом этаже здесь немного изменился от меня.

  1. overlay(dragging?.id == d.id ? Color.white.opacity(0.8) : Color.clear)

если вы хотите скрыть фон под перемещенным элементом, используйте вместо этого код ниже

      .opacity(dragging?.id == d.id ? 0 : 1)
  1. если вы не хотите отображать зеленый значок плюса при перетаскивании, используйте этот класс, чтобы заменить ответ на Asperi
      struct DropOutsideDelegate: DropDelegate { 
    @Binding var current: GridData?  
        
    func performDrop(info: DropInfo) -> Bool {
        current = nil
        return true
    }

    // must add this function to hide the green plus icon when dragging
    func dropUpdated(info: DropInfo) -> DropProposal? {
        return DropProposal(operation: .move)
    }
 }

наконец-то, большое спасибо Asperi

Другие вопросы по тегам