はじめに
TableViewで実装していた画面をモダンなCollection Viewに書き換えていく際に、ドラッグ&ドロップで行うセルの順番を入れ替える方法を探したところUITableViewDelegate
のtableView(_:targetIndexPathForMoveFromRowAt:toProposedIndexPath:)
の処理にあたるものが、UICollectionViewDelegate
にはなかったので困りました。
※モダンなCollection Viewに書き換えた場合のUIイメージ(サンプルコードより)
対応方法をまとめていきます。
環境
Xcode 13.2.1
Swift 5.5.2
整理
UITableViewDelegate
のtableView(_:targetIndexPathForMoveFromRowAt:toProposedIndexPath:)
は
iOS2
からあるもののようで、実装が簡単なので使用していました。
UICollectionViewDelegate
には同じようなものがないので、iOS11から使えるようになったUICollectionViewDragDelegate
と
UICollectionViewDropDelegate
を使うのがよさそうです。
iOS11以前では、このあたりを使って実現できたりするようです。
(TableVIew
にもUITableViewDragDelegate
とUITableViewDropDelegate
があり、既存のTableView
のドラッグ&ドロップの入れ替えをこれで実装している場合は、苦労がなさそうです)
実装方法
コレクションビューのドラッグ&ドロップのやり方が丁寧に書いてありました💡
ドキュメント:Supporting Drag and Drop in Collection Views
ドラッグ時の処理
UICollectionViewDragDelegate
の必須メソッドで処理します。
func collectionView(_ collectionView: UICollectionView,
itemsForBeginning session: UIDragSession,
at indexPath: IndexPath) -> [UIDragItem]
コレクションビューから項目をドラッグできるようにするには、このメソッドを実装する必要がある。実装では、指定されたindexPath
にあるアイテムのために1つまたは複数のUIDragItem
オブジェクトを作成する。通常、1つのドラッグアイテムのみを返しますが、条件によって対応が必要。
さらに他のUICollectionViewDragDelegate
メソッドを使用することでドラッグ時の挙動をカスタマイズできる
ドラッグ中の処理
optional func collectionView(_ collectionView: UICollectionView,
dropSessionDidUpdate session: UIDropSession,
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal
ドラッグしている間、コレクションビューはこのメソッドを繰り返し呼び出し、指定した位置でドロップが発生した場合にどのような処理を行うかを決定する。
(繰り返し呼び出されるので重い処理はしない方がよい)
ここではUICollectionViewDropProposal
を返す必要がありますが、2つのパラメータoperation: UIDropOperation
とintent: UICollectionViewDropProposal.Intent
があり、選択肢は以下の通り
operation: UIDropOperation
-
copy
アイテムをコピー -
move
アイテムを移動 -
forbidden
移動またはコピー操作は実行するが、ドロップアクティビティは行わない -
cancel
ドラッグをキャンセル
intent: UICollectionViewDropProposal.Intent
-
insertAtDestinationIndexPath
指定したインデックスパスに挿入 -
insertIntoDestinationIndexPath
指定されたアイテムの中に入れ込む -
unspecified
指定なし
ドロップ時の処理
func collectionView(_ collectionView: UICollectionView,
performDropWith coordinator: UICollectionViewDropCoordinator)
このメソッドを使用して、ドロップされたコンテンツを受け取り、コレクションビューに統合します。実装では、コーディネータオブジェクトのitems
から各UIDragItem
を取得する。コレクションビューのデータソースにデータを組み込み、必要なアイテムを挿入してコレクションビュー自体を更新。アイテムを組み込む際、コーディネータオブジェクトのメソッドを使い、ドラッグアイテムのプレビューからコレクションビューの対応するアイテムへの遷移をアニメーションで表現する。すぐに取り込む項目については、drop(_:to:)
またはdrop(_:toItemAt:)
メソッドを使用してアニメーションを実行することができる。
実装
ベースのコードはこの記事書きながらModern Collection View
で実装したテーブルビュー(見た目)です。
まずは、コレクションビューにdragDelegate
とdropDelegate
を追加
その他、collectionView.isSpringLoaded = true
にすることでドラッグ中にフォルダセルの中身が開くようにします(デフォルトはfalse
なので)
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
に入れて返す処理を加えます。
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
orinsertAt
を分けるようにしました(やり方があっているのかは分からないですが、それっぽくは動いています)
ちなみにこれdragItem.isGroup
は別でデータを見て結果を返す処理を書いています。
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
orinsertAt
を判定してからデータ更新の処理を加えます。
insertAt
の場合は、coordinator.drop(dragItem, toItemAt: destinationIndexPath)
を加えることでアニメーションが効いたので必要そうです。
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("ドラッグ&ドロップに失敗しました。")
}
}
おわりに
ざっくりとしたまとめですが、希望の動きになりました。もっと良い書き方がありそうな気もするので、引き続きドキュメントやサンプルコード見ていきたいと思います。
参考