Grid内のImageをDrag&Dropで移動させる方法


今回紹介したい内容

今回はLazyVGridやLazyHGridで配置したImageをdrag&dropで移動させる方法を試しに作っていきたいと思います。

環境

・ macOS: Monterey
・ Xcode: 13.3
・ iOS: 15.4

実装

準備

初期の状態として以下のViewを用意します。
Grid表示については以前に以下のような記事を書きましたので参考にしていただけると幸いです。

https://zenn.dev/oka_yuuji/articles/a0978c0476d4ba
struct GridTestView: View {
    @ObservedObject var viewMdoel = ItemViewModel()
    let columns = Array(repeating: GridItem(.flexible(), spacing: 20), count: 2)
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 20) {
                ForEach(viewMdoel.items) { index in
                    ZStack {
                        Image(index.item)
                            .resizable()
                            .frame(width: 150, height: 150)
                    }
                }
            }
        }
    }
}

Model側にIdentifiableに準拠したstructを用意します。

struct Item: Identifiable {
    var id = UUID().uuidString
    var item: String
}

ViewModel側には以下を用意します。

class ItemViewModel: ObservableObject {
    @Published var cuurentItem: Item?
    @Published var items = [
        Item(item: "1"),
        Item(item: "2"),
        Item(item: "3"),
        Item(item: "4"),
        Item(item: "5"),
        Item(item: "6"),
        Item(item: "7"),
        Item(item: "8"),
        Item(item: "9"),
        Item(item: "10"),
        Item(item: "11"),
        Item(item: "12"),
        Item(item: "13")
    ]
}

Assetsに1〜13の名前でImageを入れています。

(適当にO-DANなどで用意していください)

完成イメージ

DropDelegateの追加

struct DropViewDelegate: DropDelegate {
    var item: Item
    var viewModel: ItemViewModel
    
    func performDrop(info: DropInfo) -> Bool {
        return true
    }
    
    func dropEntered(info: DropInfo) {
        //from
        let fromIndex = viewModel.items.firstIndex { (item) -> Bool in
            return item.id == viewModel.cuurentItem?.id
        } ?? 0
        
        //to
        let toIndex = viewModel.items.firstIndex { (item) -> Bool in
            return item.id == self.item.id
        } ?? 0
        
        if fromIndex != toIndex {
            withAnimation(.default) {
                let formPage = viewModel.items[fromIndex]
                viewModel.items[fromIndex] = viewModel.items[toIndex]
                viewModel.items[toIndex] = formPage
            }
        }
    }
    
    func dropUpdated(info: DropInfo) -> DropProposal? {
        return DropProposal(operation: .move)
    }
}

それぞれ何をしているメソッドなのか以下のドキュメントを見ていただければわかると思いますのでリンク貼っておきます。

https://developer.apple.com/documentation/swiftui/dropdelegate

一番重要な部分はdropEnteredの部分かと個人的に思っていますので少し説明しておきます。
ここではドロップ操作がされ終えた事をデリゲートに通知します。(筆者はそう解釈しています)その通知のタイミングでfromIndexとtoIndexを用意して位置を変更しています。後に記述しますが、ここを変更する事でGrid表示されているImageを移動させた際に、他のImageの移動の仕方が変わります。

これでドロップした際にImageを移動させることができるのでViewにonDragとonDropを反映していきます。
Viewの全体のコードは以下のようになります。

struct GridTestView: View {
    @ObservedObject var viewMdoel = ItemViewModel()
    let columns = Array(repeating: GridItem(.flexible(), spacing: 20), count: 2)
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 20) {
                ForEach(viewMdoel.items) { index in
                    ZStack {
                        Image(index.item)
                            .resizable()
                            .frame(width: 150, height: 150)
                    }
                    .onDrag {
                        viewMdoel.cuurentItem = index
                        return NSItemProvider(contentsOf: URL(string: "\(index.id)")!)!
                    }
                    .onDrop(of: [.url], delegate:
                                DropViewDelegate(item: index,
                                                 viewModel: viewMdoel))
                }
            }
        }
    }
}

onDragではドラッグアンドドロップまたはコピー/貼り付けアクティビティ中にプロセス間で、またはホストアプリからアプリ拡張機能にデータまたはファイルを伝達するためのアイテムプロバイダーとしてNSItemProviderを使用します。
こちらもドキュメントリンクを貼っておきます。

https://developer.apple.com/documentation/foundation/nsitemprovider

その他Imageの移動の仕方を変更する

以下のようにその他のImageの移動の仕方を変更することも簡単にできます。

dropEntered内にある以下の部分を

let formPage = viewModel.items[fromIndex]
viewModel.items[fromIndex] = viewModel.items[toIndex]
viewModel.items[toIndex] = formPage

以下のように変更するだけです。

viewModel.items.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)

まとめ

後は細かい部分でDragのタイミングとDropのタイミングで移動させるImageのopacityを変更するなどするともっと滑らかな表現ができるのかなと感じました。
今回の記事で参考にしたドキュメントは以下に貼っておきます。Dragジェスチャーを利用してハーフモーダルなどは作ったりはしてきましたが、今回DropDelegateやNSItemProviderを初めて使用し、バイトストリームを内部で行なっていることにも少し触れることができて良かったです。
またもっとライトに試すのであればImageではなく適当に図形用意して、試すこともできますので気になる方は是非試してみてください。

参考

https://developer.apple.com/documentation/swift/identifiable

https://developer.apple.com/documentation/foundation/uuid

https://developer.apple.com/documentation/swiftui/foreach

https://developer.apple.com/documentation/swiftui/dropdelegate