🌽

モダンなUICollectionViewでシンプルなリストレイアウト その1 〜 概要

2023/09/21に公開

はじめに

この記事は、UICollectionView のモダンな仕様を使ってシンプルな(それでいて使用頻度の高い)リストレイアウトをUIKitで実現するものです。リストの定義ですがこの記事ではざっくりと「シンプルなものが上下方向に並んでいるもの」とします。

モチベーション

モダン(iOS13 or 14〜)な UICollectionView は従来のもの(iOS6〜)から大幅に仕様が変わっています。その新しい仕様の一番大事な部分だけを抜粋して記事を書こうと思い立ちました。そのため作成するものもシンプルなリストレイアウトです。

細かい情報

  • SwiftUIではありません
  • すべてコードで書きます。Storyboardはデフォルトの状態。
  • 使用するコードはAppleのサンプルコードの抜粋
  • UICollectionView にはもっとシンプルな仕組みを提供する 〜List〜 というものもありますが、ここでは使用しません
  • UICollectionView のレイアウトの深い部分はやりません

↓Appleのサンプルコード
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

↓もっとシンプルな仕組みを提供する 〜List〜 というものについて書いたもの
https://zenn.dev/samekard_dev/articles/2cbb0788915f01

完成品

次のようなものになります。

螺旋状に説明する

一気に説明するとややこしいので、それぞれをちょっとずつ説明して螺旋状に詳しく述べていきます。以下の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)
        }
    }

わざわざ作成する必要がない場合は、単純に IntString が利用できます。

セクションにも同様に「セクションネタの型」というものもあります。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を通して)リサイクル機能に処理を依頼する
  • リサイクル機能から返ってきたものをそのまま返す

です。

データソースはセクションネタの型とセルネタの型が必要なようなのでジェネリクスで与えます。

【う】ソースコードを見る 表示の流れ編

仕組み構築編が終わったので、表示の流れ編です。
実行の流れを見ましょう。

準備工程(仕組み構築編と同じ内容です)

  1. リサイクル機能を作る。【C】
  2. collectionViewと紐づいたデータソースを作る
  3. 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が出てくるので、処理の流れと説明の順番が逆にしないといけなかった。

コード全体

https://github.com/samekard-dev/UICollectionViewSimpleList1

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