Drag and Drop
概要
iOS/iPadOS におけるドラッグ&ドロップは、思いの外奥が深いので、まとめていく。
Drag and Drop with Collection and Table View - WWDC 2017
Drag/Drop Delegate の基本
UICollectionView 及び UITableView は、いずれも dragDelegate と dropDelegate を保持でき、各々ドラッグもしくはドロップについてのカスタマイズが行えるようになっている。
UICollectionView
dragDelegate
dropDelegate
UITableView
dragDelegate
dropDelegate
DragDelegate
UICollectionView の dragDelegate、すなわち UICollectionViewDragDelegate に着目してみる。この protocol の required なメソッドは collectionView(_:itemsForBeginning:at:) のみになる。ドラッグの開始時に呼び出され、ドラッグ対象の item を生成し返す責務を持つ。空配列を返した場合、ドラッグは無視される。
また、iOS では、ドラッグ中に他の View やセルをタップすると、ドラッグ対象を逐一ドラッグ対象に追加できる という体験を実現できる。このように、ドラッグ対象を追加する際に呼び出されるのは collectionView(_:itemsForAddingTo:at:point:) になる。
DropDelegate
UICollectionViewDropDelegate に着目してみる。この protocol の required なメソッドも、 dragDelegate と同様1つのみで、collectionView(_:performDropWith:) のみになる。ユーザがアイテムをドラッグし、指を離した時に呼び出される。引数で受け渡される Drop Coordinator によって Drop をハンドリングするのが責務になる。
Drop Coordinator、すなわち UICollectionViewDropCoordinator には、Drop をハンドリングするための情報を取得するための I/F が生えている。items ではドロップされたアイテム群にアクセスできる。各種 drop メソッドでは CollectionView の更新方法を指定できる。
Drag&Drop におけるデータの伝播
ドラッグ
Drag&Drop では、アプリ内でのデータ移動のみでなく、アプリ間のデータ移動 もサポートできるため、プロセス間でのデータ移動のためのオブジェクトラッパーである NSItemProvider を利用してデータを包み込む。下記コードは、Diffable DataSources を利用した Collection View を想定している。NSItemProvider に直接包めるように、Item は NSItemProviderWriting に適合させておいても良いし、別途ラッパーを設けるでも良いはず。
code:swift
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> UIDragItem {
guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return [] }
let provider = NSItemProvider(object: item)
return UIDragItem(itemProvider: itemProvider)
}
ドロップ
ドロップは、アプリ内でドラッグ開始したアイテムをドロップしたケース と、他アプリからドラッグ開始したアイテムをドロップしたケース の2種類が考えられ、いずれの場合も collectionView(_:performDropWith) が呼び出される。
code:swift
func collectionView(_ collectionView: UICollecitonView, performDropWith coordinator: UICollectionViewDropCoordinator) {
let destination = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)
for item in coordinator.items {
if let source = item.sourceIndexPath {
// sourceIndexPath が存在しているので、アプリ内のドラッグ&ドロップであることがわかる
self.collectionView.performBatchUpdates {
// source と destination で、CollectionViewを更新する
}
// ドロップアニメーションを再生する
coordinator.drop(item.dragItem, toRowAt: destination)
} else {
// sourceIndexPath が存在しないので、アプリ外からのドラッグ&ドロップであることがわかる
// ...割愛...
}
}
}
Data Delivery with Drag and Drop - Apple Developer
ドラッグ中のアニメーションのカスタマイズ (Drop Proposal)
UIDropProposal を利用すると、ユーザが指を離してドロップを実行する前の、ドラッグ中の振る舞いをシステムに伝えることができる。システムは Collection View や TableView の上でドラッグされている間中頻繁に Drop Proposal を取得し、提案された振る舞いを見せる。
CollectionView には UICollectionViewDropProposal、TableView には UITableViewDropProposal というサブクラスが各々定義されており、その双方に operation と intent というプロパティが存在している。
operation はドロップ時に何が発生するか?を伝えるプロパティであり、UIDropOperation が格納される。以下の種類がある。
cancel
データの移動は行われず、ドラッグ操作がキャンセルされる
forbidden
ドロップ可能な範囲を逸脱してドラッグしている場合等、ドロップが拒否される
copy
ドロップ対象のViewにアイテムがコピーされる
move
ドロップ対象のViewにアイテムが移動する
intent は、ドロップが何を意図しているか?を示すプロパティであり、この意図によって TableView/CollectionView の外観が適切にアニメーションする仕組みになっている。以下のような種類がある。
unspecified
未定義。この場合、ドラッグしても TableView/CollectionView はアニメーションしない
insertAtDestinationIndexPath
ドラッグ先の IndexPath への挿入。この場合、TableView/CollectionView 上をドラッグすると、ドラッグしたアイテム直下の IndexPath にスペースが空けられるようなアニメーションが発生する
insertIntoDestinationIndexPath
ドラッグ先の IndexPath のアイテムへの挿入。ドラッグしたアイテム直下の アイテム に、ドラッグ中のアイテムを挿入するような意図となる。そのため、ドラッグしたアイテム直下のアイテムがハイライトされるようなアニメーションが発生する
automatic
TableView のみ。ドラッグ箇所によって、insertAtDestinationIndexPath, insertIntoDestinationIndexPath が切り替わる
Drop Proposal を提供する場合、下記の delegate メソッドを実装する。このメソッドは頻繁に呼び出されるため、パフォーマンスに注意する。また、destinationIndexPath は、セルが存在しない場所にドラッグしようとした場合等は nil になりえる点、現在アイテムが存在していない場所の indexPath が渡される可能性がある点 に注意する。
code:swift
func collectionView(_ collectionView: UICollectionView,
dropSessionDidUpdate session: UIDropSession,
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
// アプリ内のドラッグかどうか判定する
//
// 他の判定方法としては、collectionView.hasActiveDrag で、
// collectionView がドラッグ中かどうか調べる、などもある
if session.localDragSession != nil {
// アプリ内のドラッグである
return .init(operation: .move, intent: .insertAtDestinationIndexPath)
} else {
// アプリ外からのドラッグである
return .init(operation: .copy, intent: .insertAtDestinationIndexPath)
}
}
UIDropProposal - Apple Developer
Data Delivery with Drag and Drop - Apple Developer
ドロップ時のアニメーションのカスタマイズ
TODO
基本的には、collectionView(_:performDropWith:) ないでアニメーションを記述する
Placeholder
ドロップをするときに非同期にデータを読み込みたい場合、データのロード中に UI が止まってしまうと困る。その場合は Placeholder を追加しておき、後から差し替えることができる。やること自体はとても単純で、collectionView(_:performDropWith:) にてドロップをハンドリングする際に、
1. drop(_:to:) で、プレースホルダーを CollectionView/TableView に追加する
この時、Placeholder Context を取得する
2. NSItemProvider 等からロード対象のデータを非同期にロードする
3. ロードが完了したタイミングで、Placeholder Context の commitInsertion(dataSourceUpdates:) を呼び出し、Placeholder を差し替える
ロードに失敗した場合は、deletePlaceholder() を呼び出し、Placeholder を削除する
code:swift
func collectionView(_ collectionView: UICollecitonView,
performDropWith coordinator: UICollectionViewDropCoordinator) {
let destination = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)
for item in coordinator.items {
let provider = item.dragItem.itemProvider
if !provider.canLoadObject(ofClass: UIImage.self) { continue }
// 1. Placeholderの追加
let context = coordinator.drop(item.dragItem,
toPlaceholderInsertedAt: destination,
withReuseIdentifier: "PlaceholderCell") { _ in }
// 2. データのロード
provider.loadObject(ofClass: UIImage.self) { object, error in
DispatchQueue.main.async {
guard let image = object as? UIImage else {
// 3. 読み込みに失敗したら、Placeholderを削除
context.deletePlaceholder()
return
}
// 3. 成功したら、更新
// indexPath は、必ず引数で渡ってくる indexPath を利用する
// 読み込み完了時に Placeholder の indexPath が変更されている可能性があるため
context.commitInsertion { indexPath in
self.insertImage(image, at: indexPath.item)
}
}
}
}
// ドロップに時間がかかる場合の indicator 表示はなしにする
// Placeholderを利用している場合、各 Placeholder 毎に indicator がインラインで表示されるため
coordinator.session.progressIndicatorStyle = .none
}
Reordering Cadence
Spring Loading
Drag State
DragPreviewParameter