UICollectionViewに並び替え機能を実装したい。
昔ながらのやり方ならば、UICollectionViewDataSourceを使った方法がありますね。
しかし、これからUICollectionViewを作るなら、UICollectionViewDiffableDataSource を使って書きたい。
そんなときは、 UICollectionViewDragDelegate
と UICollectionViewDropDelegate
を使いましょう。
こんな感じの並び替えUIを、シンプルなコードで実装できます。
環境
- 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()
}
}
}