🦋
SwiftUI: Drag & Dropでソートが可能なCollectionViewの実装

Transferableに準拠させたオブジェクトの配列をコレクション形式で表示している際に、ドラッグ&ドロップでソートする実装をdraggableとdropDestinationを用いて行う例です。
Transferableに準拠したオブジェクトの実装
struct Frame: Identifiable, Hashable {
var imageName: String
var id: UUID
init(imageName: String, id: UUID = UUID()) {
self.imageName = imageName
self.id = id
}
}
extension Frame: Codable {
enum CodingKeys: CodingKey {
case imageName
case id
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
imageName = try container.decode(String.self, forKey: .imageName)
id = try container.decode(UUID.self, forKey: .id)
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(imageName, forKey: .imageName)
try container.encode(id, forKey: .id)
}
}
extension Frame: Transferable {
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(for: Frame.self, contentType: .data)
}
}
上記のオブジェクトを表示するためのViewの実装
struct FrameView: View {
var frame: Frame
var index: Int
var dragging = false
var body: some View {
VStack(alignment: .center, spacing: 4) {
Image(systemName: frame.imageName)
.resizable()
.scaledToFit()
.frame(width: 40, height: 40)
Text(verbatim: index.description)
.opacity(dragging ? 0 : 1)
}
.padding(4)
.contentShape(Rectangle())
}
}
通常の表示とドラッグ中のプレビュー表示を切り替えられるようにしてあります。
ドラッグ&ドロップでソートできるViewの実装
struct SortableCollectionView: View {
@State var frames = [Frame]()
@State var currentFrame: Frame?
let columns = [GridItem](repeating: .init(.flexible(), spacing: 4), count: 5)
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 4) {
ForEach(Array(frames.enumerated()), id: \.element) { offset, frame in
FrameView(frame: frame, index: offset)
.draggable(hook(frame)) {
FrameView(frame: frame, index: offset, dragging: true)
}
.dropDestination(
for: Frame.self,
action: { complete(items: $0, location: $1) },
isTargeted: { move(status: $0, frame: frame, to: offset) }
)
}
}
}
.padding()
.onAppear {
frames = [
"bookmark", "archivebox", "document", "person", "trophy",
"snowflake", "flame", "paperclip", "drop", "magnifyingglass",
"microphone", "bell", "bolt", "hammer",
].map { .init(imageName: "\($0).circle.fill") }
}
}
func hook(_ frame: Frame) -> Frame {
currentFrame = frame
return frame
}
func complete(items: [Frame], location: CGPoint) -> Bool {
currentFrame = nil
return false
}
func move(status: Bool, frame: Frame, to toIndex: Int) {
guard status, let currentFrame, currentFrame != frame else { return }
guard let fromIndex = frames.firstIndex(of: currentFrame) else { return }
withAnimation(.bouncy) {
frames.move(
fromOffsets: IndexSet(integer: fromIndex),
toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex
)
}
}
}
ドラッグの開始を確実に知りたいのでdraggableの引数はautoclosureとなっていますが、わざと即時クロージャーを用いて現在ドラッグ中のオブジェクトの保持ができるようにしています。
また、ドラッグ中にソートされ続けてほしいのでdropDestinationのactionではなくisTargetedの方でソートを実装します。
Discussion