List形式のUICollectionViewでセルの並び替えと削除を実装する
先日、List形式のUICollectionView
で並び替えと削除を実装したんですが、
意外と公式ドキュメント以外で詳細に書いている記事が出てこなかったので、書きます。
要件
たとえばミュージックアプリのプレイリストのような、編集可能なリストを実装するとします。
ここでいう編集可能とは、セルの追加・並び替え・削除が可能という意味です。
方針
SwiftUIならList
を使うことになります。
UIKitだと、2つ選択肢があります。
-
UICollectionView
のList形式 UITableView
WWDC20あたりから、Apple推奨はUICollectionView
使った方なので、今回はそっちで進めました。
UICollectionView
の実装
List形式の今回の記事でモダンCollectionViewでリストをどう実装するかまで書くと膨大になってしまうので、前提知識とします。
もしそちらを知りたい方は、下記のチュートリアルあたりを読んでください。
UICollectionViewListCell
を継承したCellをつくる
シンプルなリストだったら、defaultContentConfiguration()
が使えます。
設定画面ぐらいならこれで事足りるでしょう。
ただセルの中身が複雑になると、なかなかこれだけで完結するのは厳しく、結局僕はカスタムセルをつくることになりました。
class CustomListCell: UICollectionViewListCell {
// …
}
accessories
を指定すると色々できる
僕は実装している途中で、なんとなくDelegateメソッドに何かを書くのかなと思っていました。
func collectionView(
_ collectionView: UICollectionView,
reorder cell: UICollectionViewListCell,
forItemAt indexPath: IndexPath
) {
// …
}
みたいなイメージで。
しかし、モダンCollectionViewだと、Delegateメソッドではなく、accessories
を指定します。
どこにどう指定するかが本記事のメインテーマです。
UICellAccessory
というのが、指定する構造体で、公式ドキュメントは下記です。
並び替え・削除以外にも、詳細とか複数選択とか色々できます。
個人的な印象ですが、モダンCollectionViewは、UITableView
より学習コストが高いですが、その分提供してる機能は多い印象です。
リストの編集のUI/UXを考える
具体的な実装に入る前に、リストの編集のUI/UXについて、一度整理しましょう。
普段なんとなくスワイプして削除するとか、編集ボタン押して編集モード入るとか、そういう動作をしています。
敢えて分類するなら、ユーザーが編集することだけを考えた画面と、リスト形式の中に編集アクションが存在する画面とにわけられます。
ここでは便宜上、編集モードと、非編集モードの画面と呼ぶことにします。
ミュージックアプリから当該の画面を探すと、下記のイメージです。
編集モード | 非編集モード |
---|---|
このUIはAppleの標準的なものと考えられます。
Human Interface Guidelinesを見ても、そんなに詳しくは書いてませんが、純正アプリ見て、CollectionViewの挙動から察するに、
- ユーザーがリストの複数セルに対して編集アクションをしたいときは編集画面を出す
- (実装的にはedit modeをオンにする)
- 編集画面への導線は、新規作成と既存リストの編集の2パターンある
- 昔は編集ボタンが目立つところにあった気がするが、今はちょっとネスト深くなったような気がする
- (個人的には編集ボタン押す→編集モードに入る挙動はあまり好きじゃない)
- 単一のセルに対するクイックアクションとして、スワイプ削除やメニューからの削除があるとより便利
- 編集モードのときの削除アイコンはセルの左、並び替えはセルの右
- スワイプ削除だとはセルの右
- 理由はよくわからない。右利きの人間が多いから、セルの左のスワイプアクションはやりづらいから、というだけ?
という暗黙のルールがあるように見えます。
今回の記事は編集モードの画面をつくることがメインですが、スワイプ削除の実装も最後にすこしだけ書きます。
UICellAccessoryの指定をする
前置きが長くなりましたが、いよいよ実装について書きます。
といっても、実装は簡単で、UICollectionViewDiffableDataSource
を指定するところに、ちょろっと指定するだけです。
コード例はこんな感じ。
lazy var dataSource: UICollectionViewDiffableDataSource<Section, Item> = {
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in
let cell = CustomListCell.dequeue(from: collectionView, for: indexPath)
cell.item = item
cell.accessories = [
.reorder(),
.delete() {
// delete action
}
]
return cell
}
dataSource.reorderingHandlers.canReorderItem = { _ in true }
dataSource.reorderingHandlers.didReorder = { [weak self] _ in
// data management after reordering
}
return dataSource
}()
UICollectionViewDiffableDataSource
の指定する際のセル設定で、チョロっとaccessories
を指定します。
させたいアクションによって微妙に指定が違うので、詳しくは公式ドキュメントを見ながら実装してください。
たとえば削除についてはaccessories
に対してクロージャーで指定しますが、並び替えはreorderingHandlers
に対しての指定となります。
各UICellAccessory
がセルの左右どちらに出るかは、デフォルトで決まっていて、削除は左、並び替えは右です。
ここは将来のiOSでどちらが標準になるかよくわからないところなので、デフォルトに従うが吉かなと思います。
編集モードじゃなくてもUICellAccessoryを表示しときたい
このdataSource
に対して、snapshotをapply()
すると、編集可能なリストとなります。
ただこの指定だと、編集モードにしないとaccessories
が表示されません。
collectionViewのisEditing
をtrueにすることで編集モードにできますが、今回僕は編集専用画面としてつくったので、
編集モードに切り替えるのではなく、常時accessories
が表示されていて欲しいと思いました。
またデフォルトだと左右のボタンには余白がほぼない状態で、もう少し余白が欲しいので、それも指定しました。
cell.accessories = [
.reorder(displayed: .always, options: .init(reservedLayoutWidth: .custom(52))),
.delete(displayed: .always, options: .init(reservedLayoutWidth: .custom(52))) {
// delete action
}
]
こんな指定になっています。
この指定だけで、CollectionViewの中で上手いことデータの並び替え・削除をやってくれるので、dataSource.snapshot().itemIdentifiers
から編集後の結果が取得できます。
スワイプアクションで削除を実装する
編集モードにおける操作は以上なんですが、非編集モードのスワイプ操作のコード例も載せます。
UICollectionLayoutListConfiguration
を指定するところでスワイプアクションを指定します。
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
configuration.trailingSwipeActionsConfigurationProvider = { indexPath -> UISwipeActionsConfiguration in
let action = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in
// delete action
}
action.image = Asset.Images.trash.image
return UISwipeActionsConfiguration(actions: [action])
}
let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: environment)
普通のUICollectionViewCompositionalLayout
だと指定できなさそうで、ListConfiguration専用っぽいです。
(追記)UICellAccessory部分の背景色
普通のセルのbackgroundcolorを変えただけだと、アクセサリー部分はシステムデフォルトが適用されるみたいです。
UIBackgroundConfiguration
というのを指定してあげると、アクセサリー部分も変えられました。
参考
Discussion