モダンなUICollectionViewでシンプルなリストレイアウト その1 〜 概要
はじめに
この記事は、UICollectionView
のモダンな仕様を使ってシンプルな(それでいて使用頻度の高い)リストレイアウトをUIKitで実現するものです。リストの定義ですがこの記事ではざっくりと「シンプルなものが上下方向に並んでいるもの」とします。
モチベーション
モダン(iOS13 or 14〜)な UICollectionView
は従来のもの(iOS6〜)から大幅に仕様が変わっています。その新しい仕様の一番大事な部分だけを抜粋して記事を書こうと思い立ちました。そのため作成するものもシンプルなリストレイアウトです。
細かい情報
- SwiftUIではありません
- すべてコードで書きます。Storyboardはデフォルトの状態。
- 使用するコードはAppleのサンプルコードの抜粋
-
UICollectionView
にはもっとシンプルな仕組みを提供する〜List〜
というものもありますが、ここでは使用しません -
UICollectionView
のレイアウトの深い部分はやりません
↓Appleのサンプルコード
↓もっとシンプルな仕組みを提供する 〜List〜
というものについて書いたもの
完成品
次のようなものになります。
螺旋状に説明する
一気に説明するとややこしいので、それぞれをちょっとずつ説明して螺旋状に詳しく述べていきます。以下の3段階で書きます。
- 【あ】各工程の簡単な説明
- 【い】ソースコードを見る 仕組み構築編
- 【う】ソースコードを見る 表示の流れ編
【あ】各工程の簡単な説明
【A】構成を設定
コレクションビューにセクションがいくつあって、それぞれのセクションには中身がいくつあるというのが決定するのがここ。
また、セクションの情報とセルの内容の情報(この記事ではネタという)を個数分設定します。
【B】セルの内容を決定する
UICollectionView
からセル情報の要求が来て、UICollectionView
にセル情報を返すデリゲートの働きをします。
セルの作成はリサイクル機能(次に登場)に依頼します。
ここにはセルの内容を決めるクロージャを置きます。もちろんクロージャの中でリサイクル機能に依頼します。
【C】リサイクル機能
セル再利用&セルに内容を設定する機能です。
- リサイクル装置
- 再生機能から上がってきたものをカスタマイズ
の2つの工程があります。
リサイクル装置は生成か再利用のどちらかでセルを確保します。
ここではリサイクル装置から上がってきたものをカスタマイズできるので、そのクロージャを設定します。
この後の説明に必要な概念があと2つあります。
【D】セルの型
UICollectionViewCell
のサブクラスになります。
【E】セルネタの型/セクションネタの型
モダンな UICollectionView
では「セルの表示内容のネタ(情報源)になる情報」という概念が登場します。こんな感じで Hashable
の型をセルネタとして作成して指定します。
struct Model: Hashable {
let title: String
let badgeCount: Int
let identifier = UUID()
func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}
}
わざわざ作成する必要がない場合は、単純に Int
や String
が利用できます。
セクションにも同様に「セクションネタの型」というものもあります。Appleのサンプルではenumがよく使われています。
enum Section {
case main
}
各工程の簡単な説明まとめ
【A】で構成を決定してネタを投入する
コレクションビューから【B】のメソッドを呼ぶ
【B】から【C】に(コレクションビューを通じて)依頼
【C】から【B】に(コレクションビューを通じて)セルを返す
【B】からコレクションビューにセルを返す
【い】ソースコードを見る 仕組み構築編
ソースコードを見ていきましょう。
【C】リサイクル機能
let cellRegistration =
UICollectionView.CellRegistration<ListCell, String> {
(cell, indexPath, identifier) in
cell.label.text = identifier
}
セルの型(ListCell)とセルネタの型(String)を指定してリサイクル機能を作ります。 cellRegistration
が赤色の枠にあたります。作るときにセルのカスタマイズ用のクロージャを渡します。
コードでやっていることは
-
UICollectionView.CellRegistration
のインスタンスを作成 - そのイニシャライザにクロージャを渡す。
です。引数がクロージャだけなのでイニシャライザの ()
が省略されています。
このクロージャはリサイクル装置からセルが上がってきたらセルをカスタマイズするために実行されます。クロージャの中は、cell(セルインスタンス)、indexPath、identifier(セルネタ、ここではString)をもらって、cellにガチャガチャと内容を設定します。
作成されたインスタンス(cellRegistration)はBからCを呼ぶときに使います。
【A】と【B】の仕事は同じインスタンスでする
AとBの機能はデータソースというものが行います。
このデータソースはAppleのコードでは次のように collectionView
ときょうだいの位置で宣言されています。collectionView
の中にデータソースを作るのではありません。このやり方がスタンダードなんだと思います。
var dataSource: UICollectionViewDiffableDataSource<Section, String>! = nil
var collectionView: UICollectionView! = nil
【B】セルの内容を決める
ここが一番重い内容になります。データソースの生成のタイミングで、セルの内容を決めるクロージャの登録も同時に行います。
dataSource =
UICollectionViewDiffableDataSource<Section, String>
(collectionView: collectionView)
{
(collectionView: UICollectionView,
indexPath: IndexPath,
identifier: String) -> UICollectionViewCell? in
return collectionView.dequeueConfiguredReusableCell(
using: cellRegistration,
for: indexPath,
item: identifier)
}
UICollectionViewDiffableDataSource
のインスタンスを作って dataSource
に入れています。
イニシャライザの引数は
- collectionView: collectionView
- クロージャ
の2つです。
1つめにcollectionViewを送っています。このイニシャライザにcollectionViewを送ることで、中でこのcollectionViewに連結した UICollectionViewDiffableDataSource
のインスタンスを作るのだと思います。
2つめのクロージャがtrailing closureの形で )
の外に出ています。このクロージャはセルの内容を決めるときに呼ばれるもの。
クロージャの処理内容は
- コレクションビューインスタンス、IndexPath、String(セルネタ)を引数でもらう
- (collectionViewを通して)リサイクル機能に処理を依頼する
- リサイクル機能から返ってきたものをそのまま返す
です。
データソースはセクションネタの型とセルネタの型が必要なようなのでジェネリクスで与えます。
【う】ソースコードを見る 表示の流れ編
仕組み構築編が終わったので、表示の流れ編です。
実行の流れを見ましょう。
準備工程(仕組み構築編と同じ内容です)
- リサイクル機能を作る。【C】
- collectionViewと紐づいたデータソースを作る
- 2.でセル生成時に走るクロージャを設定【B】
では4以降行きます。
【A】構成を設定
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
snapshot.appendSections([.main])
snapshot.appendItems(prefectures, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false)
NSDiffableDataSourceSnapshot
という型のインスタンスを作ります。このときセクションネタの型とセルネタの型(ここではString)を指定します。構成情報の投入はこの snapshot
に対して行います。
続いてその snapshot
にセクションのネタとセルのネタを必要な個数分投入します。セルネタは今回は String
です。通常ここでセルネタが大量に投入されます。今回は prefectures
というArrayにして放り込んでます。
snapshotの内容をデータソースに送ったらsnapshotの役目は終わりです。
コレクションビューの構成要素が決定したので次はセルを作成する工程になります。
【B】セルの内容を決める
セルの内容を決めなきゃならんので、dataSource
の作成時に渡したクロージャが走ります。
{
(collectionView: UICollectionView,
indexPath: IndexPath,
identifier: String) -> UICollectionViewCell? in
return collectionView.dequeueConfiguredReusableCell(
using: cellRegistration,
for: indexPath,
item: identifier)
}
このクロージャの3つめの引数の identifier: String
はセルネタ(の具体的なインスタンス、snapshot
に投げたやつ)です。
dequeueConfiguredReusableCell
によってリサイクル機能にセルを依頼します。このとき using
でリサイクル機能を指定しています。ここでも3つ目の item: identifier
がセルネタです。
【C】リサイクル機能
リサイクル装置からセルが上がってきたらセルの内容を設定するクロージャが走ります。
{
(cell, indexPath, identifier) in
cell.label.text = identifier
}
ここでも3つめの identifier
引数はセルネタ(具体的なインスタンス、snapshot
に投げたやつ)です。セルネタにいろいろな情報を詰め込む場合、identifier
に詰め込まれているので、ここで情報を引き出して取得できます。
このcellをBに返すところは書く必要がないようです。
このあとはBが呼び出し元にcellを返して終了です。お疲れ様でした。
出てきた型
型をおさらいします。
UICollectionViewDiffableDataSource<セクションの型, セルネタの型>
データソースの型
Diffableっていう単語が控え選手感を出してますが主力選手です。
データの型情報が必要です。
役割は
- セル製造のためのクロージャ
- supplementaryView製造のためのクロージャ(オプション)
- 現在のデータを適用する機能
です。
NSDiffableDataSourceSectionSnapshot<セクションの型, セルネタの型>
スナップショットの型
コレクションビューのアイテムの個数や階層構成を扱うもの
appendメソッドで追加する。追加するときに親も指定することができる。
func append(_ items: [ItemIdentifierType], to parent: ItemIdentifierType? = nil)
親をnilにすると、最後のセクションに追加する。
入れ子はいくらでもできる模様。
CellRegistration<セルの型, セルネタの型>
リサイクル機能の型
データの型
Intでもよい。Hashableであること。
感想
ABCの順番で説明できればわかりやすいのですが、Aの中にデータソースが出てくるし、データソース作成の中にBの内容が出てくるし、Bの内容にはCが出てくるので、処理の流れと説明の順番が逆にしないといけなかった。
コード全体
ViewController全体を下に載せる。
import UIKit
class ViewController: UIViewController {
enum Section {
case main
}
let prefectures: [String] = ["福岡", "佐賀", "長崎", "大分", "熊本", "宮崎", "鹿児島"]
var dataSource: UICollectionViewDiffableDataSource<Section, String>! = nil
var collectionView: UICollectionView! = nil
override func viewDidLoad() {
super.viewDidLoad()
configureHierarchy()
configureDataSource()
}
}
extension ViewController {
func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(44))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 0
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
}
extension ViewController {
func configureHierarchy() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(collectionView)
collectionView.delegate = self //タップ操作へ対応するため
}
func configureDataSource() {
//【C】リサイクル機能
let cellRegistration = UICollectionView.CellRegistration<ListCell, String> { (cell, indexPath, identifier) in
cell.label.text = identifier
}
//データソースの作成
dataSource = UICollectionViewDiffableDataSource<Section, String> (collectionView: collectionView)
{
//【B】セルの内容が決定
(collectionView: UICollectionView, indexPath: IndexPath, identifier: String) -> UICollectionViewCell? in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
}
//【A】構成を決める
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
snapshot.appendSections([.main])
snapshot.appendItems(prefectures, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
extension ViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
}
}
Discussion