【iOS】DiffableDataSourceで作ったUICollectionViewに並び替え機能を実装する

**UICollectionViewに並び替え機能を実装したい。**

昔ながらのやり方ならば、UICollectionViewDataSourceを使った方法がありますね。
しかし、これからUICollectionViewを作るなら、**UICollectionViewDiffableDataSource** を使って書きたい。

そんなときは、 `UICollectionViewDragDelegate` と `UICollectionViewDropDelegate` を使いましょう。

こんな感じの並び替えUIを、シンプルなコードで実装できます。

<img alt="DiffableDataSourceで作ったUICollectionViewに並び替え機能を追加しました。" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339601/98f879fa-08b5-7fc9-1387-5baecfc9d3d4.gif" width="150">

## 環境
- Xcode 12.0
- iOS 14.0

## 教材コード

今回は、このCollectionViewをドラッグ&ドロップで並び替えできる様にします。

<details><summary><strong>CompositionalLayout + DiffableDataSourceで作ったシンプルなCollectionViewのコード （クリックで開封）</strong></summary><div>

```swift
import UIKit

final class ViewController: UIViewController {

    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

    private enum Section {
        case main
    }

    private struct Item: Hashable {
        let color: UIColor
        let identifier = UUID()
    }

    private var items = [
        Item(color: .red),
        Item(color: .blue),
        Item(color: .green),
        Item(color: .brown)
    ]

    override func viewDidLoad() {
        super.viewDidLoad()

        setupCollectionView()
        setupCollectionViewDataSource()
    }

    private func setupCollectionView() {
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCollectionViewLayout())
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.backgroundColor = .systemGroupedBackground
        view.addSubview(collectionView)
    }

    private func createCollectionViewLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.5))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        let layout = UICollectionViewCompositionalLayout(section: section)

        return layout
    }

    private func setupCollectionViewDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, Item> { cell, indexPath, item in
            cell.backgroundColor = item.color
        }

        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
            (collectionView, indexPath, item) -> UICollectionViewCell? in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
        }

        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        snapshot.appendItems(items)
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}
```

</div></details>

## 1. UICollectionViewDragDelegateとUICollectionViewDropDelegateを有効にする

まず、UICollectionViewのドラッグ&ドロップを有効にするため、以下のコードを追加します。

```swift
collectionView.dropDelegate = self
collectionView.dragDelegate = self
collectionView.dragInteractionEnabled = true
```

## 2. UICollectionViewDragDelegateのcollectionView(_:itemsForBeginning:at)でドラッグを有効にする

[collectionView(_:itemsForBeginning:at)](https://developer.apple.com/documentation/uikit/uicollectionviewdragdelegate/2897389-collectionview) に以下のコードを足します。

```swift
extension ViewController: UICollectionViewDragDelegate {
    func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        let itemIdentifier = items[indexPath.item].identifier.uuidString as NSString
        let itemProvider = NSItemProvider(object: itemIdentifier)
        let dragItem = UIDragItem(itemProvider: itemProvider)
        return [dragItem]
    }
}
```

ちなみに、空の `[UIDragItem]()` を指定するとそのセルはドラッグできなくなります。
特定のセルをドラッグしたくない場合は、indexPathで指定してやると良いでしょう。

## 3. UICollectionViewDropDelegateのcollectionView(_:dropSessionDidUpdate:withDestinationIndexPath:)で、自分のアプリからのドロップだけを有効にする

今回は、外部のアプリからのドロップは受付ず、自分のアプリからのドロップだけ受け付ける様にしておきます。
[localDragSession](https://developer.apple.com/documentation/uikit/uidropsession/2890985-localdragsession)を見て、内部からのドロップだったら並び替えをし、外部からのドロップならキャンセルする様にしておきます。

```swift
extension ViewController: UICollectionViewDropDelegate {
    func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
        if session.localDragSession != nil {
            // 内部からのドロップなら並び替えする
            return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
        } else {
            // 外部からのドロップならキャンセルする
            return UICollectionViewDropProposal(operation: .cancel)
        }
    }
}
```

## 4. UICollectionViewDropDelegateのcollectionView(_:performDropWith:)で、データソースを更新する

あとは、遷移元と遷移先のIndexPathを取得して、それを元にデータソースを更新するだけです。

```swift
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
        switch coordinator.proposal.operation {
        case .move:
            guard
                let destinationIndexPath = coordinator.destinationIndexPath,
                let sourceIndexPath = coordinator.items.first?.sourceIndexPath
            else { return }

            // データソースを更新する
            let sourceItem = items.remove(at: sourceIndexPath.item)
            items.insert(sourceItem, at: destinationIndexPath.item)

            var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
            snapshot.appendSections([.main])
            snapshot.appendItems(items)
            dataSource.apply(snapshot, animatingDifferences: false)
        case .cancel, .forbidden, .copy:
            return
        @unknown default:
            fatalError()
        }
    }
```

## コード全文

コード全文も載せておきます。

<details><summary><strong>CompositionalLayout + DiffableDataSource + DragDelegate + DropDelegateで作った並び替え可能なUICollectionViewコード （クリックで開封）</strong></summary><div>

```swift
import UIKit

final class ViewController: UIViewController {

    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

    private enum Section {
        case main
    }

    private struct Item: Hashable {
        let color: UIColor
        let identifier = UUID()
    }

    private var items = [
        Item(color: .red),
        Item(color: .blue),
        Item(color: .green),
        Item(color: .brown)
    ]

    override func viewDidLoad() {
        super.viewDidLoad()

        setupCollectionView()
        setupCollectionViewDataSource()
    }

    private func setupCollectionView() {
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCollectionViewLayout())
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.dragDelegate = self
        collectionView.dropDelegate = self
        collectionView.dragInteractionEnabled = true
        collectionView.backgroundColor = .systemGroupedBackground
        view.addSubview(collectionView)
    }

    private func createCollectionViewLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.5))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        let layout = UICollectionViewCompositionalLayout(section: section)

        return layout
    }

    private func setupCollectionViewDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, Item> { cell, indexPath, item in
            cell.backgroundColor = item.color
        }

        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
            (collectionView, indexPath, item) -> UICollectionViewCell? in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
        }

        loadDataSource(items: items)
    }

    private func loadDataSource(items: [Item]) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        snapshot.appendItems(items)
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}

extension ViewController: UICollectionViewDragDelegate {
    func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        let itemIdentifier = items[indexPath.item].identifier.uuidString as NSString
        let itemProvider = NSItemProvider(object: itemIdentifier)
        let dragItem = UIDragItem(itemProvider: itemProvider)
        return [dragItem]
    }
}

extension ViewController: UICollectionViewDropDelegate {
    func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
        if session.localDragSession != nil {
            // 内部からのドロップなら並び替えする
            return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
        } else {
            // 外部からのドロップならキャンセルする
            return UICollectionViewDropProposal(operation: .cancel)
        }
    }

    func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
        switch coordinator.proposal.operation {
        case .move:
            guard
                let destinationIndexPath = coordinator.destinationIndexPath,
                let sourceIndexPath = coordinator.items.first?.sourceIndexPath
            else { return }

            // データソースを更新する
            let sourceItem = items.remove(at: sourceIndexPath.item)
            items.insert(sourceItem, at: destinationIndexPath.item)

            loadDataSource(items: items)
        case .cancel, .forbidden, .copy:
            return
        @unknown default:
            fatalError()
        }
    }
}
```

</div></details>