24
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

UICollectionViewでUITableViewのようなUIを実現する。ただし#available(iOS 14.0, *)

Last updated at Posted at 2020-12-05

はじめに

この記事は2020年のRevCommアドベントカレンダー6日目の記事です。5日目はmojajaさんの「ApolloServerでGraphQLを体験する」でした。

こんにちは。RevCommでスマフォアプリを担当している@sohichiroです。

Twitterを眺めていると、ふと目にしたつぶやきがありました。

どういうことか、ずっと気になっていたで今回調べてみました。
どうやら、UICollectionViewで、従来のUITableViewと同等の機能が実装できるようになっている様子(ただしiOS14+)。

利用できるようになるのは先になるかとは思いますが、UITableViewがUICollectionViewに置き換わる時が来る時に備えて、調べてみました。

UITableViewのおさらい

まずUITableViewを使うために、どのような実装が必要であるのか振り返っておきましょう。
UITableViewを使用する際、以下の2つのメソッドを実装する必要があります。
この二つのメソッドは、UITableViewDataSourceプロトコルで定義されているメソッドです。

  • tableView(_:numberOfRowsInSection:)
  • tableView(_:cellForRowAt:)

それぞれ詳しく見ていきます。

tableView(_:numberOfRowsInSection:)

特定sectionに所属するセルの数を指定します。
セクションというのは、表示するデータをグループ分けするために使用するものです。
セクション数を指定するメソッドであるnumberOfSections(in:_)
を実装しなければ、セクション数は1となり、tableView(_:numberOfRowsInSection:)で指定する数がセルの数になります。
公式ドキュメントはこちら

tableView(_:cellForRowAt:)

表示されるセルは、UITableViewCellオブジェクトとして生成され、セルの表示に利用されます。
このメソッドにおいて、indexPathで指定されたセルにおけるcellインスタンスを生成し、cellインスタンスの設定を行います。
公式ドキュメントはこちら

上記の2つのメソッドを実装したUITableViewDataSourceをUITableViewに設定すれば、TableViewにデータを表示することができます。

UITableViewの実装例

例に使ったコードの全体例は以下の通りです。

LegacyTableViewViewController.swift
class LegacyTableViewViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    
    let data = ["🍇", "🍈", "🍉", "🍊", "🍋"]
    
    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cellIdentifier")
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        tableView.reloadData()
    }
}

extension LegacyTableViewViewController: UITableViewDataSource {    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return data.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cellIdentifier") ?? UITableViewCell()
        cell.textLabel?.text = data[indexPath.row]
        return cell
    }
}

上記のコードでTableViewを表示させることができます。

またUITableViewコンポーネントを利用する際は、効率的にリソースを利用するために、dequeueReusableCell(withIdentifier:for:)
をtableView(_:cellForRowAt:) 内で利用していたり、dequeueReusableCell(withIdentifier:for:)が実行される前に、register(_:forCellReuseIdentifier:) を実行し、UITableViewにcellを登録しておいたりします。

UICollectionViewでUITableViewを再現する

では同様の機能をUICollectionViewで実現してみましょう。

UICollectionViewで、UITableViewのようなUIにするには、以下のオブジェクトを使用します。

  • UICollectionViewCompositionalLayout
  • UICollectionView.CellRegistration
  • NSDiffableDataSourceSnapshot

順番に見ていきましょう。

UICollectionViewCompositionalLayout

UICollectionViewのレイアウトを司るオブジェクトとして、UICollectionViewLayoutがあります。
これはUITableViewにはなかった概念で、このオブジェクトを設定することで、柔軟なセルのレイアウトが実現できます。
UICollectionViewCompositionalLayoutは、UICollectionViewLayoutのサブクラスとして定義されています。
UICollectionViewをUITableViewのように設定するには、UICollectionViewCompositionalLayoutを以下のように設定します。

UICollectionViewCompositionalLayoutExample.swift
let configuration = UICollectionLayoutListConfiguration(appearance: .plain)
let layout = UICollectionViewCompositionalLayout.list(using: configuration)

公式ドキュメントはこちら

UICollectionView.CellRegistration

UICollectionViewにUICollectionViewListCellを登録するオブジェクトです。
UITableViewにおいて、tableView(_:cellForRowAt:)で行っていたcellへのconfigurationを行う動作のみを定義します。

UICollectionView.CellRegistrationExample.swift
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, item in
    
    var contentConfiguration = cell.defaultContentConfiguration()
    contentConfiguration.text = item
    cell.contentConfiguration = contentConfiguration
}

CellRegistrationが定義できれば、CellRegistrationを、 dequeueConfiguredReusableCell(using:for:item:)に設定します。

dequeueConfiguredReusableCellExample.swift
dataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: collectionView) {
    (collectionView: UICollectionView, indexPath: IndexPath, item: String) -> UICollectionViewCell? in
    
    return collectionView.dequeueConfiguredReusableCell(using: cellRegistration,
                                                        for: indexPath,
                                                        item: itemIdentifier)
}

これで、再利用されたセルが、定義した通りに装飾されて、表示されるようになります。
公式ドキュメントはこちら

最後に、UICollectionViewに表示されるデータを供給するオブジェクトを設定します。

NSDiffableDataSourceSnapshot

このオブジェクトは、UICollectionViewに表示するデータを提供します。

この構造体の定義は以下のようになっています

struct NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable

このオブジェクトは、構造体であるため、初期化時は、値型で定義する、すなわち"var"で定義する必要があります。
また、定義にある通り、SectionIdentifierTypeとItemIdentifierTypeは、Hashableである必要があります。
これは、セクションであったり、アイテムであったりを順番に並べるために必要な情報が含まれるようにするためです。

実装例は以下の通りです。

NSDiffableDataSourceSnapshotExample.swift
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
snapshot.appendSections([0])
snapshot.appendItems(["🍇", "🍈", "🍉", "🍊", "🍋"])
dataSource.apply(snapshot, animatingDifferences: true)

NSDiffableDataSourceSnapshotにおいては、全てのデータがいずれかのセクションに含まれていることが前提となっています。
そのため、まずappendSections()で、セクションを作成し、appendItems()で、データを追加しています。
appendItems()においては、セクション指定を行わなければ最後のセクションが指定されます。
今回のケースではセクションが一つのみであるためセクション指定を行っていません。
公式ドキュメントはこちら

これで、UICollectionViewでUITableViewを再現する最低限のパーツは揃ったことになります。
これらを実装していくとUICollectionViewでUITableViewのような画面を表示することができます。
UICollectionViewの実装例

例に使ったコードの全体例は以下の通りです。

ModernCollectionViewViewController.swift
class ModernCollectionViewViewController: UIViewController {
    typealias DataSourceType = UICollectionViewDiffableDataSource<Int, String>
    
    @IBOutlet weak var collectionView: UICollectionView!
    private var dataSource: DataSourceType!
    
    override func viewDidLoad() {
        super.viewDidLoad()

        configureLayout(to: collectionView)
        dataSource = configureCellRegistration(to: collectionView)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        initData()
    }
    
    private func configureLayout(to collectionView: UICollectionView){
        let configuration = UICollectionLayoutListConfiguration(appearance: .plain)
        let layout = UICollectionViewCompositionalLayout.list(using: configuration)
        collectionView.collectionViewLayout = layout
    }

    private func configureCellRegistration(to collectionView: UICollectionView) -> DataSourceType {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, indexPath, item) in
            var content = cell.defaultContentConfiguration()
            content.text = item
            cell.contentConfiguration = content
        }
        
        return DataSourceType(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, item: String) -> UICollectionViewCell? in
                return collectionView.dequeueConfiguredReusableCell(using: cellRegistration,
                                                                    for: indexPath,
                                                                    item: item)
        }
    }
    
    func initData() {
        var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
        snapshot.appendSections([0])
        snapshot.appendItems(["🍇", "🍈", "🍉", "🍊", "🍋"])
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

UICollectionViewで作るUITableViewの違い

UICollectionViewを用いて、UITableViewライクなUIを作ってきました。
従来の実装方法と比較してどのような違いがあるのかを見ていきましょう。

ランタイムエラーが発生しにくくなった

TableViewでは、UITableViewCellのサブクラスを使ったカスタムクラスを利用したりすると、tableView(_:cellForRowAt:)内で、dequeueReusableCell(withIdentifier:for:)をコールする際に、キャストを行う必要があったり、identifierを文字列で指定したりする必要がありましたが、identifierのタイポなどで発生するランタイムエラーを実行前に回避することができませんでした。
CellRegistrationオブジェクトや、UICollectionViewDiffableDataSourceにおいては、使用するカスタムセルのクラス型を定義に利用しているため、コンパイラによる型チェックが行われるため、以前のようなランタイムエラーを実行前に回避することができるようになりました。

セルの生成と、セルの装飾が明確に分離される

UITableViewにおいては、セルの生成とセルの装飾がtableView(_:cellForRowAt:)で行われていました。
またメソッド内において、dequeueReusableCell(withIdentifier:for:)を実行したりするのですが、これは、以前使っていたセルを再利用するためのメソッドのため、初見で、このメソッド内で何が行われているのか?というのはわかりにくかったと思います。

ですが、UICollectionViewにおいては、UICollectionViewDiffableDataSource.CellProviderにおいて、セルの生成が行われ、UICollectionView.CellRegistrationにおいて、セルの装飾が実行されるようになり、役割が明確に分離されるようになりました。

TableViewにおいては、tableView(:cellForRowAt:)が肥大しがちで、コードの見通しがすぐに悪くなってしまうので注意が必要でしたが、これはtableView(:cellForRowAt:)が多くの責務を持っていたため、構造的に肥大しがちなメソッドであったことが原因です。
UICollectionViewにおいては、責務を分割して実装することを要求されるようになるため、今までよりかはコードの見通しが悪くなってしまうケースが減るのではないかと思います。

表示されるデータの数を気にしなくても良くなった

UITableViewにおいては、tableView(_:numberOfRowsInSection:)において表示するデータ数を指定する必要がありました。
表示するデータ数は、変動するため、このメソッドに渡すデータ数もデータに基づいて変更していかなければならないのですが、これが大変になることがしばしばあります(例えば複数要素からデータを作成している場合など)。

ですが、UICollectionViewにおいては、データ数を指定する必要はなく、表示するデータをNSDiffableDataSourceSnapshotに加え(あるいは減らして)、applyをして完了、となります。
データのみが真で、データを指定すれば、それが表示される、という単純な構造になっています。

最後に

いかがだったでしょうか。
これだけUICollectionViewでTableViewライクなUIが作成できるようになるのであれば、将来的にUITableViewは、depricatedになっていく運命なのかな、、と一抹の寂しさを感じつつ、この記事をまとめました。
将来的にはswiftUIが普及し、UIKitで実装することはないのかもしれませんが、今しばらくUIKitで実装していくプロジェクトの参考になれば幸いです。

明日は、@seckieさんの「Adobe XDを用いたワークフローについて」の記事になります。

24
5
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?