Drag and Drop
概要
iOS/iPadOS におけるドラッグ&ドロップは、思いの外奥が深いので、まとめていく。
Drag/Drop Delegate の基本
DragDelegate
DropDelegate
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)
}
ドロップ
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 が存在しないので、アプリ外からのドラッグ&ドロップであることがわかる
// ...割愛...
}
}
}
ドラッグ中のアニメーションのカスタマイズ (Drop Proposal)
UIDropProposal を利用すると、ユーザが指を離してドロップを実行する前の、ドラッグ中の振る舞いをシステムに伝えることができる。システムは Collection View や TableView の上でドラッグされている間中頻繁に Drop Proposal を取得し、提案された振る舞いを見せる。 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)
}
}
ドロップ時のアニメーションのカスタマイズ
TODO
Placeholder
1. drop(_:to:) で、プレースホルダーを CollectionView/TableView に追加する この時、Placeholder Context を取得する
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