1
/
5

iOS13からのUICollectionViewを使って詳細画面を組み立てる

はじめまして.iOSエンジニアのk-koheyです.詳細画面をUICollectionViewを使って書き直しており,そのレポートを書きました.初歩的な内容を多く含みますが,興味が有る方はぜひ見てください🙌

はじめに

アプリ内で扱う特定のモデルを表示する詳細画面は一般的に画面内に多くのViewが詰め込まれます.このような多くのViewを抱える画面をUIStackViewを用いて組み立てると,UIStackViewのパフォーマンスの問題から表示されるまでしばらく待たされます.よって,そのような場合はUIStackViewを使うのは適していません.そこで,今回はUICollectionViewを使った詳細画面の組み立て方法を説明します.また,実装にはiOS13以降のAPIを用いたので勘所なども併せて説明します.

対象

iOSアプリ版Wantedlyのストーリ詳細画面です.下記のような画面をUICollectionViewを使って組み立ててみます.

実装方針

実装方針は単純明快で,下記画像中の赤枠で囲っている部分をそれぞれCellとして定義しCollectionViewに表示していきます.また,下端までスクロールした際には追加でコンテンツを読み込みCollectionViewを更新する必要がありますが,この際にreloadData()を使って全更新するとカクつきの要因となります.そこで,差分更新を簡単に行うためにUICollectionViewDiffableDataSourceを使用します.

実装

詳細画面はコンテンツの表示以外にもハートボタンやシェアボタンのタップなどのインタラクションの実装も必要です.それらの処理と分離するために,コンテンツだけを表示するStoryDetailCollectionViewController(下記のコードを参照)を作りました.このViewControllerは外部から埋め込まれ,必要なタイミングでupdate(_:,in:)メソッドが呼ばれることを想定しています.その呼び出しによってCollectionViewにCellが追加・消去される仕組みです.

enum Section: Int, CaseIterable {
    case contents
    case company
    case stories
}

enum SectionItem: Hashable {
    case block(PostContentBlock)
    case companyInfo(Company)
    case story(Story)
}

final class StoryDetailCollectionViewController: UIViewController {
    private let collectionView: UICollectionView
    private let cellProvider: CellProiver
    private var dataSource: UICollectionViewDiffableDataSource<Section, SectionItem>

    init() {
        cellProvider = CellProiver()
       
        collectionView = UICollectionView(
            frame: .zero,
            collectionViewLayout: MyCollectionViewLayout.story
        )

        dataSource = UICollectionViewDiffableDataSource<Section, SectionItem>(
            collectionView: collectionView,
            cellProvider: cellProvider.provideCell(for:indexPath:item:)
        )

        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view = collectionView
    }

    func update(_ items: [SectionItem], in section: Section) {
        var resource = NSDiffableDataSourceSnapshot<Section, SectionItem>()

        resource.appendSections(PostSection.allCases)
        resource.appendItems(items, toSection: section)

        let priviousResource = dataSource.snapshot()
        if priviousResource.numberOfItems > 0 {
            for otherSection in PostSection.allCases.filter({ $0 != section }) {
                let items = priviousResource.itemIdentifiers(inSection: otherSection)
                resource.appendItems(items, toSection: otherSection)
            }
        }

        dataSource.apply(resource)
    }
}

セクションとアイテムの定義

UICollectionViewDiffableDataSourceは2つの型パラメータを持っており,それぞれセクションとそのセクションの内部で表示するアイテムの型を渡してあげる必要があります.ここで1つ問題があります.それは,この画面は将来的には複数の型で表現されたアイテムを表示する必要があることです.この画面ではストーリー本文に加えて,下の方のCellでは会社情報の表示や関連するストーリーを表示します.しかし,それらを同じ型で表現するのは非合理的です.そのためUICollectionViewDiffableDataSourceに渡す型パラメータに工夫が必要となってきます.

自分が調べた限りでは下記の2通りの解決策がありました.(1)の場合では,どこかのタイミングでAnyHashableを自分が扱いたい型にキャストする必要がありそうだったので,キャストをするのを嫌い(2)を選ぶことにしました.

(1)AnyHashableを型パラメータにわたす https://www.slideshare.net/fumiyasakai37/uicollectionviewcompositionallayout-combine

(2)ItemをEnumで表現し,Itemの型をassosiated valueの型で表現する https://stackoverflow.com/questions/57497461/diffabledatasource-with-multiple-cell-types

enum Section: Int, CaseIterable {
    case contents
    case company
    case stories
}

enum SectionItem: Hashable {
    case block(PostContentBlock)
    case companyInfo(Company)
    case story(Story)
}

DataSourceへアイテムを追加する

DataSourceへアイテムを追加するにはNSDiffableDataSourceSnapshotを使います.このSnapshotへ現在表示すべきSectionやItemの情報を詰め込み,それをDataSourceへ渡します.そうすると,内部で勝手に差分計算を行ってくれるため,必要なところのみにCellの更新がトリガーされます.

注意点としては,新しいSnapshotをDataSourceに渡すと現在のSnapshotが新しいものに上書きされる(当たり前ですが)ため,それを避けるために現在のSnapshotを考慮して新しくSnapshotを作る必要があります.今回作ったupdate(_:,in:)メソッドはセクション毎にアイテムを追加するインタフェースにしたかったため,それ以外のセクションは現在のSnapshotから情報を引き継ぐように設計しました.なお,現在のSnapshotはsnapshot()メソッドから取得できます.

func update(_ items: [SectionItem], in section: Section) {
      var resource = NSDiffableDataSourceSnapshot<Section, SectionItem>()

      resource.appendSections(Section.allCases)
      resource.appendItems(items, toSection: section)

      let priviousResource = dataSource.snapshot()
      if priviousResource.numberOfItems > 0 {
          for otherSection in PostSection.allCases.filter({ $0 != section }) {
              let items = priviousResource.itemIdentifiers(inSection: otherSection)
              resource.appendItems(items, toSection: otherSection)
          }
      }

      dataSource.apply(resource)
}

DataSourceに追加されたアイテム情報をCellへ反映させる

DataSourceへ新しいSnapshotを渡すとUICollectionViewDiffableDataSourceのinitializerの第2引数に渡した下記の型のクロージャが呼び出されます.このItemIdentifierTypeはUICollectionViewDiffableDataSourceの2つめの型パラメータに該当するので,今回はSectionItemとなります.

(UICollectionView, IndexPath, ItemIdentifierType) -> UICollectionViewCell?

このクロージャ内では次の2つの事を行います.

  1. SectionItemからどのCellをdequeueするか判断する
  2. cellをdequeueし,そのcellへSectionItemの情報を反映させる.

詳細画面ではかなりの多い数のSectionItemが存在し,かつ増えていく可能性が高いため,この処理をViewControllerの中に書くとかなりViewControllerが太ります.さらに,ViewControllerと関心を分けられそうだったため,CellProviderと呼ばれる先述した2つの手続きを行うクラスを新たに作成しました.

CellProviderに下記のようなメソッドを実装させ,それをUICollectionViewDiffableDataSourceのinitializerの第2引数に渡します.これでかなりViewControllerがスッキリしました.(実際にはCellProviderをSection毎に用意し,入れ子にしています)

func provideCell(
        for collectionView: UICollectionView, indexPath: IndexPath, block: PostContentBlock
    ) -> UICollectionViewCell {
        switch block {
        case let block as PostContentBlock.Header2:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.Header3:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.Unstyled:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.OrderedListItem:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.UnorderedListItem:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.Code:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.Quote:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.HorizontalRule:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.Image:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.EmbedIframe:
            return dequeue(block, using: collectionView, at: indexPath)
        case let block as PostContentBlock.EmbedOther:
            return dequeue(block, using: collectionView, at: indexPath)
        
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~  

        }
    }
dataSource = UICollectionViewDiffableDataSource<Section, SectionItem>(
    collectionView: collectionView,
    cellProvider: cellProvider.provideCell(for:indexPath:item:)
)

おわりに

詳細画面をUICollectionViewを使った実装を使うにあたっての工夫点やiOS13からのCollectionViewのリソース管理について簡単に説明しました.この記事を書きながら,まともに2020年のWWDCを見ていない事に気づいたので僕は今から2020年へ行ってきます.それでは.

Wantedly, Inc.では一緒に働く仲間を募集しています
13 いいね!
13 いいね!
今週のランキング
Wantedly, Inc.からお誘い
この話題に共感したら、メンバーと話してみませんか?