9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-09-24

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

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

そんなときは、 UICollectionViewDragDelegateUICollectionViewDropDelegate を使いましょう。

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

DiffableDataSourceで作ったUICollectionViewに並び替え機能を追加しました。

環境

  • Xcode 12.0
  • iOS 14.0

教材コード

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

CompositionalLayout + DiffableDataSourceで作ったシンプルなCollectionViewのコード (クリックで開封)
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)
    }
}

1. UICollectionViewDragDelegateとUICollectionViewDropDelegateを有効にする

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

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

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

collectionView(_:itemsForBeginning:at) に以下のコードを足します。

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を見て、内部からのドロップだったら並び替えをし、外部からのドロップならキャンセルする様にしておきます。

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を取得して、それを元にデータソースを更新するだけです。

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()
        }
    }

コード全文

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

CompositionalLayout + DiffableDataSource + DragDelegate + DropDelegateで作った並び替え可能なUICollectionViewコード (クリックで開封)
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()
        }
    }
}
9
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?