【UIKit】TableViewのドラッグ&ドロップをCollectionViewで実装する方法

# はじめに
TableViewで実装していた画面をモダンなCollection Viewに書き換えていく際に、ドラッグ&ドロップで行うセルの順番を入れ替える方法を探したところ`UITableViewDelegate`の`tableView(_:targetIndexPathForMoveFromRowAt:toProposedIndexPath:)`の処理にあたるものが、`UICollectionViewDelegate`にはなかったので困りました。

※モダンなCollection Viewに書き換えた場合のUIイメージ（サンプルコードより）
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/f465ad21-c1a9-3538-5dd3-95b95684188a.png">
対応方法をまとめていきます。

# 環境
Xcode 13.2.1
Swift 5.5.2

# 整理
`UITableViewDelegate`の`tableView(_:targetIndexPathForMoveFromRowAt:toProposedIndexPath:)`![スクリーンショット 2022-03-06 14.18.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/3106361f-60df-20c1-ab1b-873d839d25c6.png)は`iOS2`からあるもののようで、実装が簡単なので使用していました。

[`UICollectionViewDelegate`](https://developer.apple.com/documentation/uikit/uicollectionviewdelegate "UICollectionViewDelegateのドキュメント")には同じようなものがないので、iOS11から使えるようになった[`UICollectionViewDragDelegate`](https://developer.apple.com/documentation/uikit/uicollectionviewdragdelegate "UICollectionViewDragDelegateのドキュメント")![スクリーンショット 2022-03-06 14.49.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/b69ee240-228f-4301-053d-2f99486108fb.png)と[`UICollectionViewDropDelegate`](https://developer.apple.com/documentation/uikit/uicollectionviewdropdelegate "UICollectionViewDropDelegateのドキュメント")![スクリーンショット 2022-03-06 14.49.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/a54852a5-d868-f152-fe37-1d52e9ada7c7.png)を使うのがよさそうです。

iOS11以前では、![スクリーンショット 2022-03-06 14.46.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/b4ac7308-afe1-9077-f236-d5f54736d309.png)このあたりを使って実現できたりするようです。


（`TableVIew`にも`UITableViewDragDelegate`と`UITableViewDropDelegate`があり、既存の`TableView`のドラッグ&ドロップの入れ替えをこれで実装している場合は、苦労がなさそうです）

# 実装方法
##### コレクションビューのドラッグ&ドロップのやり方が丁寧に書いてありました💡
ドキュメント：[Supporting Drag and Drop in Collection Views](https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/supporting_drag_and_drop_in_collection_views)

### ドラッグ時の処理
`UICollectionViewDragDelegate`の必須メソッドで処理します。
```swift
func collectionView(_ collectionView: UICollectionView, 
  itemsForBeginning session: UIDragSession, 
                 at indexPath: IndexPath) -> [UIDragItem]
```
コレクションビューから項目をドラッグできるようにするには、このメソッドを実装する必要がある。実装では、指定された`indexPath`にあるアイテムのために1つまたは複数の`UIDragItem`オブジェクトを作成する。通常、1つのドラッグアイテムのみを返しますが、条件によって対応が必要。

さらに他の`UICollectionViewDragDelegate`メソッドを使用することでドラッグ時の挙動をカスタマイズできる

### ドラッグ中の処理

```swift
optional func collectionView(_ collectionView: UICollectionView, 
        dropSessionDidUpdate session: UIDropSession, 
    withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal
```

ドラッグしている間、コレクションビューはこのメソッドを繰り返し呼び出し、指定した位置でドロップが発生した場合にどのような処理を行うかを決定する。
（繰り返し呼び出されるので重い処理はしない方がよい）

ここでは`UICollectionViewDropProposal`を返す必要がありますが、２つのパラメータ`operation: UIDropOperation`と`intent: UICollectionViewDropProposal.Intent`があり、選択肢は以下の通り

##### `operation: UIDropOperation`
- `copy`
アイテムをコピー
- `move`
アイテムを移動
- `forbidden`
移動またはコピー操作は実行するが、ドロップアクティビティは行わない
- `cancel`
ドラッグをキャンセル

##### `intent: UICollectionViewDropProposal.Intent`

- `insertAtDestinationIndexPath`
指定したインデックスパスに挿入
- `insertIntoDestinationIndexPath`
指定されたアイテムの中に入れ込む
- `unspecified`
指定なし

### ドロップ時の処理

```swift
func collectionView(_ collectionView: UICollectionView, 
    performDropWith coordinator: UICollectionViewDropCoordinator)
```

このメソッドを使用して、ドロップされたコンテンツを受け取り、コレクションビューに統合します。実装では、コーディネータオブジェクトの`items`から各`UIDragItem`を取得する。コレクションビューのデータソースにデータを組み込み、必要なアイテムを挿入してコレクションビュー自体を更新。アイテムを組み込む際、コーディネータオブジェクトのメソッドを使い、ドラッグアイテムのプレビューからコレクションビューの対応するアイテムへの遷移をアニメーションで表現する。すぐに取り込む項目については、`drop(_:to:)` または`drop(_:toItemAt:)`メソッドを使用してアニメーションを実行することができる。

# 実装
**ベースのコードはこの記事書きながら`Modern Collection View`で実装したテーブルビュー（見た目）です。**

https://qiita.com/yoshitaka/items/ea965d4ff5caf5eea4ed


まずは、コレクションビューに`dragDelegate`と`dropDelegate`を追加
その他、`collectionView.isSpringLoaded = true`にすることでドラッグ中にフォルダセルの中身が開くようにします（デフォルトは`false`なので）


```swift:LessonDataViewController
extension LessonDataViewController: UICollectionViewDelegate {
    func configureCollectionView() {
        let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: generateLayout())
        view.addSubview(collectionView)
        collectionView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
        collectionView.backgroundColor = .systemGroupedBackground
        self.modernCollectionView = collectionView
        collectionView.delegate = self

        collectionView.dragDelegate = self   👈 追加
        collectionView.dropDelegate = self   👈 追加
        collectionView.isSpringLoaded = true 👈 追加      
    }

    func generateLayout() -> UICollectionViewLayout {
        let listConfiguration = UICollectionLayoutListConfiguration(appearance: .sidebar)
        let layout = UICollectionViewCompositionalLayout.list(using: listConfiguration)
        return layout
    }
}

👇 追加
extension LessonDataViewController: UICollectionViewDragDelegate, UICollectionViewDropDelegate {
}
```

**それでは、記事のテーマであるドラッグ＆ドロップの処理を実装します。**
##### ドラッグ時
`dataSource.itemIdentifier(for: indexPath)`でドラッグしたセルの情報を取得、`dragItem.localObject`に入れて返す処理を加えます。
```swift:LessonDataViewController
    func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        guard let lessonItem = self.dataSource.itemIdentifier(for: indexPath) else { return [] }
        let itemProvider = NSItemProvider(object: "\(indexPath)" as NSString)
        let dragItem = UIDragItem(itemProvider: itemProvider)
        dragItem.localObject = lessonItem
        return [dragItem]
    }
```

##### ドラッグ中
ドラッグしたものがグループなら`insertAt`、アイテムならドロップ先を見てグループorアイテムで`insertInto`or`insertAt`を分けるようにしました（やり方があっているのかは分からないですが、それっぽくは動いています）
ちなみにこれ`dragItem.isGroup`は別でデータを見て結果を返す処理を書いています。

```swift:LessonDataViewController
    func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
        if collectionView.hasActiveDrag {
            if let destinationIndex = destinationIndexPath, let destinationItem = dataSource.itemIdentifier(for: destinationIndex) {
                if let dragItem = session.items.first?.localObject as? LessonItem, dragItem.isGroup {
                    // ドラッグしたアイテムがグループ
                    return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
                } else {
                    // ドラッグしたアイテムがアイテム
                    return UICollectionViewDropProposal(operation: .move, intent: destinationItem.isGroup ? .insertIntoDestinationIndexPath : .insertAtDestinationIndexPath)
                }
            }
        }
        return UICollectionViewDropProposal(operation: .forbidden)
    }
```

##### ドロップ時
ここでは`insertInto`or`insertAt`を判定してからデータ更新の処理を加えます。
`insertAt`の場合は、`coordinator.drop(dragItem, toItemAt: destinationIndexPath)`を加えることでアニメーションが効いたので必要そうです。

```swift:LessonDataViewController
    func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
        if let destinationIndexPath = coordinator.destinationIndexPath, 
            let destinationItem = dataSource.itemIdentifier(for: destinationIndexPath), 
            let sourceIndexPath = coordinator.items.first?.sourceIndexPath, 
            let dragItem = coordinator.items.first?.dragItem {
            
            guard let dragLessonItem = dragItem.localObject as? LessonItem else { return }
            if coordinator.proposal.intent == .insertIntoDestinationIndexPath {
                // insertIntoの場合
                // ここにデータの処理ロジックを入れる　👈
            } else {
                // insertAtの場合
                coordinator.drop(dragItem, toItemAt: destinationIndexPath)
                // ここにデータの処理ロジックを入れる　👈
            }
        } else {
            assertionFailure("ドラッグ&ドロップに失敗しました。")
        }
    }
```

# おわりに
ざっくりとしたまとめですが、希望の動きになりました。もっと良い書き方がありそうな気もするので、引き続きドキュメントやサンプルコード見ていきたいと思います。

# 参考
https://qiita.com/eKushida/items/78d53d365bb69c7e7e0e

https://qiita.com/onizine/items/ce51cc5f43db8fe7b35d

https://qiita.com/fumi2mi/items/91ed25ca7058ef415ea3