🦋

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

に公開

Drag&Dropでソートする様子

Transferableに準拠させたオブジェクトの配列をコレクション形式で表示している際に、ドラッグ&ドロップでソートする実装をdraggabledropDestinationを用いて行う例です。

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となっていますが、わざと即時クロージャーを用いて現在ドラッグ中のオブジェクトの保持ができるようにしています。
また、ドラッグ中にソートされ続けてほしいのでdropDestinationactionではなくisTargetedの方でソートを実装します。

参考にした記事

Discussion