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 января с похмельем (то есть извинения за то, что был менее красноречивым):
- Если вы выберете элемент сетки и отпустите его (для отмены), то представление не сбрасывается.
Я добавил bool, который проверяет, было ли представление перетащено, и если это не так, оно не скрывает представление в первую очередь. Это немного похоже на взлом, потому что на самом деле он не сбрасывается, он просто откладывает скрытие представления, пока он не узнает, что вы хотите его перетащить. Т.е. если вы перетаскиваете очень быстро, вы можете ненадолго увидеть вид, прежде чем он будет скрыт.
- Если вы отбрасываете сетку за пределы представления, представление не сбрасывается.
Этот вопрос уже был частично решен путем добавления делегата 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.location
CGPoint места перетаскивания, отображение CGPoint на представление, которое вы хотите заменить, кажется неразумным. Я бы подумалOnMove
был бы лучшим вариантом и упростил бы перемещение ваших данных / представлений. Мне не удалось получитьOnMove
работая в LazyVGrid
.
В качестве LazyVGrid
все еще находятся в стадии бета-тестирования и обязательно изменятся. Я бы воздержался от использования в более сложных задачах.
Действительно здорово, что ответ на первом этаже здесь немного изменился от меня.
-
overlay(dragging?.id == d.id ? Color.white.opacity(0.8) : Color.clear)
если вы хотите скрыть фон под перемещенным элементом, используйте вместо этого код ниже
.opacity(dragging?.id == d.id ? 0 : 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