1. はじめに
皆様お疲れ様です。「iOS Advent Calendar」の24日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。
まずは僕自身の今年のトピックスとしては、技術書典5で頒布した書籍の商業化・業務では新たな現場での新規iOSアプリ開発を通じてRxSwift・Laravel・Nuxt.jsに触れる機会・新たな書籍の執筆&技術系同人誌イベントの参加・iOSDCからリジェクトコンでの2日連続での登壇...等々と昨年以上に変化とバラエティに富んだ1年ではありましたが、何とか楽しく過ごせておりました。
また今年のWWDC19では、WWDC19で押さえておきたいと思ったセッション10選でもまとめられているように、SwiftUIをはじめとして様々な新機能が紹介されたこともありキャッチアップしたいトピックがたくさんありました。
今回はその中でも僕が特に気になった、
- UICollectionViewCompositionalLayout
- DiffableDataSource
- Combine
の3つのトピックに焦点を当てて、これらを活用した 「UICollectionViewを利用した複雑な画面レイアウトを構成する必要があるUI実装事例」 及び 「Combine+MVVMパターンを利用したAPI通信を利用したデータ取得から画面への反映までの処理の実装事例」 をある程度の形にまとめたUIサンプル実装を通して紹介できればと思います。
【以前登壇した際の発表資料】
今回の内容(主に2.〜 5.のセクションで解説している内容)につきましては、potatotips #66 (iOS/Android開発Tips共有会)にて登壇の際に利用した資料を下記のリンクにて共有しておりますので、こちらも是非ご活用頂けますと幸いです。
【Githubで公開しているサンプルコード】
この記事で紹介しているサンプルについては下記の2つになります。どちらも画面数や機能は多くはありませんが、どちらもiOS13以降の新機能となる、UICollectionViewCompositionalLayout
/ NSDiffableDataSource
/ Combine
を活用して普段の業務で利用しているものに少し近しい形にまとめてみたものになります。
-
UICollectionViewCompositionalLayoutとCombineを利用した複雑な画面構造を持つ画面のUI実装サンプル
- 注) この記事における、2.〜 5.のセクションで解説しているサンプルになります。
-
Pinterestの様なWaterFallLayoutやスクロール最下部到達時の無限スクロールを再現したUI実装サンプル
- 注) この記事における、6.のセクションで解説しているサンプルになります。
※ 「もっとこうした方が良い」というご意見があったり「この実装はあまりよろしくない」等のご意見等が御座いましたらIssueやPullRequest等をお送り頂けますと幸いです!
2. サンプル概要について
本記事では、解説に当たって2種類のサンプルを準備しました。表現しているデザインは異なりますが、アーキテクチャの基本方針は類似した形にしています。
⭐️2-1. サンプル紹介(ComplexCollectionViewStyleExample)
こちらのサンプルについては、
- UICollectionViewCompositionalLayoutを活用した少し複雑なレイアウトへの構築
- 異なるセクションで取り得るセルのデザインや表示データが異なる場合の表示
をテーマとしたサンプルになります。
【画面デザイン】
【利用したライブラリ】
| ライブラリ名 | ライブラリの機能概要 |
|:-----------|:------------|:------------|
|Nuke |画像キャッシュ用のライブラリ |
|FontAwesome.swift |「Font Awesome」アイコンを利用するためのライブラリ |
|ActiveLabel.swift |押下可能なURLリンク・ハッシュタグ・メンション要素等を作りやすくするライブラリ |
⭐️2-2. サンプル紹介(DiffableDataSourceExample)
こちらのサンプルについては、UICollectionViewCompositionalLayout
を利用してPinterestの様なレイアウトを構築する点に加えて、
- UICollectionView及びUITableViewでDiffableDataSourceを利用した実装
- 頻出のPullToRefreshやスクロール最下部到達時の追加読み込み
を実現してみました。
※ 前述のサンプルよりはシンプルな構成となっています。
【画面デザイン】
【利用したライブラリ】
| ライブラリ名 | ライブラリの機能概要 |
|:-----------|:------------|:------------|
|PTCardTabBar |DesignicなTabBarを実現するライブラリ |
|AlamofireImage |画像キャッシュ用ライブラリ |
⭐️2-3. サンプルに関する補足事項
サンプルで利用しているAPIモックサーバーについて:
今回紹介しているサンプルについては、検証用Mockサーバーをnode.js製の「json-server」を利用しています。
※ 動作方法と環境構築方法については各サンプルのREADMEを参照して下さい。
環境やバージョンについて:
- Xcode 11.1
- Swift 5.1
- MacOS Catalina (Ver10.15.1)
3. UICollectionViewCompositionalLayoutを活用してSectionごとにバリエーションの異なるセルのデザインを構築する
UICollectionViewCompositionalLayoutを活用した場合の大きなメリットとしては、UICollectionViewを利用した複雑なレイアウトを構築する際にも、アプローチがしやすい形になった点だと個人的に感じています。
よくお目にかかるのですぐできるのでは?感じるレイアウトであっても、いざUICollectionViewで構築してみるとなかなか一手間加えないと難しかったという経験はあるかと思います(僕もこのような経験をすることはしばしばあります...😅)。しかしUICollectionViewCompositionalLayoutでの実装で置き換えると、従来の実装よりも構築時のイメージがし易くかつシンプルな形で落とし込む事ができる場合も多いと思います。
ここでは、UICollectionViewCompositionalLayoutの実装やレイアウトを考える際に押さえておくと良さそうな点を、実際のレイアウト構築事例を交えながら解説していきます。
⭐️3-1. 1つの画面の中に異なる属性の要素が多数存在する場合を考える
まずはUICollectionViewを利用した実装において、下図のような構造を例に考えてみます。頑張って単一のUICollectionViewとSectionを利用しても実現できるかもしれませんが、各要素毎に複雑なレイアウトの実装が必要な場合やデータ取得先が異なる場合においては、表示要素を小さな単位で切り出すことが多いかと思います。
また、このような画面を構築する際のアプローチの方針の例として、
- UITableView + UICollectionViewの組み合わせで実現するアプローチ
- ContainerView + UICollectionViewの組み合わせで実現するアプローチ
- 「IGListKit」等のライブラリを利用した差分更新と構造管理をするアプローチ
等の選択肢が考えられると思いますが、必要以上に画面を構成するための表示要素が増えると管理が煩雑になってしまう点やレイアウトや表示の整合性を合わせる処理の難易度が上がってしまう場合もありそうです。
このような問題を上手に解決する際のアプローチとしてUICollectionViewCompositionalLayoutを活用するアプローチは今後は主流になっていきそうにも感じています。
⭐️3-2. UICollectionViewCompositionalLayoutにおけるポイントになる部分とレイアウト構築時における考え方
UICollectionViewCompositionalLayoutを利用したUI実装をする場合に、従来までの実装方法と大きく変わる点を簡潔にまとめると、
- UICollectionViewCompositionalLayoutを利用したSection毎に定義したレイアウトを組み立てて適用する処理の実装方法
- UICollectionViewDiffableDataSourceを利用した各種セル表示要素とDataSourceの実装方法
- NSDiffableDataSourceSnapshotを利用した差分更新が考慮された表示要素の反映方法
の3点になります。クラス名も長いので一見すると複雑そうな印象がありますが、実際に表示データやレイアウトを組み立てていく処理を紐解いていくと、個人的な所管にはなりますがセクション毎の構成がつかみやすく、とても美しい構成だと感じています。
【構成要素や概要に関するポイント】
改めて前述した、UICollectionViewCompositionalLayoutを利用したUI実装をする場合において利用するクラスと役割をまとめると下図の様な形になります。
UIに表示するためのデータを格納して管理するNSDiffableDataSourceSnapShot及びデータ反映のためのUICollectionViewDiffableDataSourceについては、セクション毎に表示対象のModelにおいて、データの型が異なる場合でもHashableに適合していれば対応できる形にしています。
この点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)における実装部分の概要をまとめると下記のような形になります。
// MEMO: セクション毎に定義したEnum値
enum MainSection: Int, CaseIterable {
case FeaturedBanners
case FeaturedInterviews
case RecentKeywords
case NewArrivalArticles
case RegularArticles
}
// ① UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
// → Section毎に表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある
private var snapshot: NSDiffableDataSourceSnapshot<MainSection, AnyHashable>!
// ② UICollectionViewを組み立てるためのDataSource
// → Section毎に表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある
private var dataSource: UICollectionViewDiffableDataSource<MainSection, AnyHashable>! = nil
// ③ UICollectionViewCompositionalLayoutの設定
// → Section毎に定義したレイアウトを適用する
private lazy var compositionalLayout: UICollectionViewCompositionalLayout = {
let layout = UICollectionViewCompositionalLayout { [weak self] (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
// MainSection毎に定義したレイアウトを適用する
// → デザインに応じてNSLayoutCollectionを組み立てる
switch sectionIndex {
// MainSection: 0 (FeaturedBanners)
case MainSection.FeaturedBanners.rawValue:
return self?.createFeaturedBannersLayout()
// MainSection: 1 (FeaturedInterviews)
case MainSection.FeaturedInterviews.rawValue:
return self?.createFeaturedInterviewsLayout()
// MainSection: 2 (RecentKeywords)
case MainSection.RecentKeywords.rawValue:
return self?.createRecentKeywordsLayout()
// MainSection: 3 (NewArrivalArticles)
case MainSection.NewArrivalArticles.rawValue:
return self?.createNewArrivalArticles()
// MainSection: 4 (RegularArticles)
case MainSection.RegularArticles.rawValue:
return self?.createRegularArticles()
default:
fatalError()
}
}
return layout
}()
【セル要素・Header・Footer部分の組み立てる場合のポイント】
セル要素を組み立てる処理はUICollectionViewDiffableDataSourceを利用する形になりますが、実際にセルを組み立てる処理についてはクロージャー内にセル要素を組み立てる処理を記載する形となります。
UICollectionViewDiffableDataSource<MainSection, AnyHashable>(collectionView: collectionView) { (collectionView: UICollectionView, indexPath: IndexPath, model: AnyHashable) -> UICollectionViewCell? in ...
// MEMO: この中にセルを組み立てるための処理を記載する
// → Section毎に定義するModelが異なる場合にはModelの型で判定する
// (例) let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! ExampleCollectionViewCell
}
また任意のセクションの中にHeader・Footerが必要な場合には、UICollectionViewDiffableDataSourceのsupplementaryViewProvider
プロパティのクロージャー内にHeader・Footer用のUICollectionReusableViewを継承したView要素を組み立てる処理を記載する形となります。
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, kind: String, indexPath: IndexPath) -> UICollectionReusableView? in
// MEMO: Header・Footerを組み立てるための処理を記載する
// → indexPath.sectionでセクションを判定 & kindでelementKindSectionHeader(Footer)を判定
// (例) let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "Header", for: indexPath) as! ExampleCollectionHeaderView
}
この点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)におけるセル要素を組み立てる処理の概要をまとめると下記のような形になります。
final class MainViewController: UIViewController {
・・・(省略)・・・
// MARK: - Override
override func viewDidLoad() {
super.viewDidLoad()
setupCollectionView()
・・・(省略)・・・
}
・・・(省略)・・・
private func setupCollectionView() {
// このレイアウトで利用するセル要素・Header・Footerの登録
// MainSection: 0 (FeaturedBanner)
collectionView.registerCustomCell(FeaturedCollectionViewCell.self)
// MainSection: 1 (FeaturedInterview)
collectionView.registerCustomCell(FeaturedInterviewCollectionViewCell.self)
// MainSection: 2 (RecentKeyword)
collectionView.registerCustomCell(KeywordCollectionViewCell.self)
collectionView.registerCustomReusableHeaderView(KeywordCollectionHeaderView.self)
collectionView.registerCustomReusableFooterView(KeywordCollectionFooterView.self)
// MainSection: 3 (NewArrivalArticle)
collectionView.registerCustomCell(NewArrivalCollectionViewCell.self)
collectionView.registerCustomCell(PhotoCollectionViewCell.self)
collectionView.registerCustomReusableHeaderView(NewArrivalCollectionHeaderView.self)
// MainSection: 4 (RegularArticle)
collectionView.registerCustomCell(ArticleCollectionViewCell.self)
collectionView.registerCustomReusableHeaderView(ArticleCollectionHeaderView.self)
// UICollectionViewDelegateについては従来通り
collectionView.delegate = self
// UICollectionViewCompositionalLayoutを利用してレイアウトを組み立てる
collectionView.collectionViewLayout = compositionalLayout
// DataSourceはUICollectionViewDiffableDataSourceを利用してUICollectionViewCellを継承したクラスを組み立てる
dataSource = UICollectionViewDiffableDataSource<MainSection, AnyHashable>(collectionView: collectionView) { (collectionView: UICollectionView, indexPath: IndexPath, model: AnyHashable) -> UICollectionViewCell? in
switch model {
// MainSection: 0 (FeaturedBanner)
case let model as FeaturedBanner:
let cell = collectionView.dequeueReusableCustomCell(with: FeaturedCollectionViewCell.self, indexPath: indexPath)
cell.setCell(model)
return cell
// MainSection: 1 (FeaturedInterview)
case let model as FeaturedInterview:
let cell = collectionView.dequeueReusableCustomCell(with: FeaturedInterviewCollectionViewCell.self, indexPath: indexPath)
cell.setCell(model)
return cell
// MainSection: 2 (RecentKeyword)
case let model as Keyword:
let cell = collectionView.dequeueReusableCustomCell(with: KeywordCollectionViewCell.self, indexPath: indexPath)
cell.setCell(model)
return cell
// MainSection: 3 (NewArrivalArticle)
case let model as NewArrival:
// MEMO: 3で割って1余るインデックス値の場合は大きなサイズのセルを適用する
if model.id % 3 == 1 {
let cell = collectionView.dequeueReusableCustomCell(with: NewArrivalCollectionViewCell.self, indexPath: indexPath)
cell.setCell(model, index: indexPath.row + 1)
return cell
} else {
let cell = collectionView.dequeueReusableCustomCell(with: PhotoCollectionViewCell.self, indexPath: indexPath)
cell.setCell(model, index: indexPath.row + 1)
return cell
}
// MainSection: 4 (RegularArticle)
case let model as Article:
let cell = collectionView.dequeueReusableCustomCell(with: ArticleCollectionViewCell.self, indexPath: indexPath)
cell.setCell(model)
return cell
default:
return nil
}
}
// Header・Footerの表記についてもUICollectionViewDiffableDataSourceを利用して組み立てる
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, kind: String, indexPath: IndexPath) -> UICollectionReusableView? in
switch indexPath.section {
// MainSection: 2 (RecentKeyword)
case MainSection.RecentKeywords.rawValue:
if kind == UICollectionView.elementKindSectionHeader {
let header = collectionView.dequeueReusableCustomHeaderView(with: KeywordCollectionHeaderView.self, indexPath: indexPath)
header.setHeader(
title: "最近の「キーワード」をチェック",
description: "テレビ番組で人気のお店や特別な日に使える情報をたくさん掲載しております。気になるキーワードはあるけれども「あのお店なんだっけ?」というのが具体的に思い出せない場面が結構あると思います。最新情報に早めにキャッチアップしたい方におすすめです!"
)
return header
}
if kind == UICollectionView.elementKindSectionFooter {
let footer = collectionView.dequeueReusableCustomFooterView(with: KeywordCollectionFooterView.self, indexPath: indexPath)
return footer
}
// MainSection: 3 (NewArrivalArticle)
case MainSection.NewArrivalArticles.rawValue:
if kind == UICollectionView.elementKindSectionHeader {
let header = collectionView.dequeueReusableCustomHeaderView(with: NewArrivalCollectionHeaderView.self, indexPath: indexPath)
header.setHeader(
title: "新着メニューの紹介",
description: "アプリでご紹介しているお店の新着メニューを紹介しています。新しいお店の発掘やさらなる行きつけのお店の魅力を見つけられるかもしれません。"
)
return header
}
// MainSection: 4 (RegularArticle)
case MainSection.RegularArticles.rawValue:
if kind == UICollectionView.elementKindSectionHeader {
let header = collectionView.dequeueReusableCustomHeaderView(with: ArticleCollectionHeaderView.self, indexPath: indexPath)
header.setHeader(
title: "おすすめ記事一覧",
description: "よく行くお店からこちらで厳選してみました。というつもりです…。でも結構美味しそうなのではないかと思いますよので是非ともご堪能してみてはいかがでしょうか?"
)
return header
}
default:
break
}
return nil
}
・・・(省略)・・・
}
・・・(省略)・・・
}
※ この部分はもっと実装を整理できる余地がある部分かと思います...💦
【UICollectionViewCompositionalLayoutのレイアウト作成時のポイント】
UICollectionViewCompositionalLayoutのレイアウトを組み立てていく際には、レイアウトを構成する4つの要素 「Layout / Section / Group / Item」 の関係に注目して、NSCollectionLayoutSizeを設定していく点がポイントになるかと思います。
また、本サンプル(ComplexCollectionViewStyleExample)で1つのUICollectionViewに配置しているセクション構築のバリエーションは下記のような形になります。従来の実装方法ではレイアウトが複雑な表現がそれぞれのセクションで展開される形はなかなか実現がしんどく感じることが多い場合もありますが、UICollectionViewCompositionalLayoutのおかげで綺麗にまとめやすい形なのは嬉しいですね。
⭐️3-3. (レイアウト例1) Section内の表示セル要素が横方向にスクロールする表現
まずは、バナー表示カルーセルの様なスクロールをするレイアウト及び、キーワード一覧を横に並べてスクロールを伴う形にするレイアウトを実現するためのコードは下記の様な形になります。セクションを構築する際には「Item → Group → Section」という順番でレイアウトを考えていくとイメージがよりしやすいのではないかと思います。スクロールのバリエーションについてもorthogonalScrollingBehavior
プロパティで決定可能である点や、contentInsets
プロパティを利用した間隔調整もItem・Group・ Sectionで可能な点を活用してより柔軟なレイアウトの構成ができます。
// ① バナー表示カルーセル表現をするレイアウト構築例
private func createFeaturedBannersLayout() -> NSCollectionLayoutSection {
// 1. Itemのサイズ設定
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = .zero
// 2. Groupのサイズ設定
// MEMO: 1列に表示するカラム数を1として設定し、itemのサイズがgroupのサイズで決定する形にしている
let groupHeight = UIScreen.main.bounds.width * (3 / 8)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(groupHeight))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 1)
group.contentInsets = .zero
// 3. Sectionのサイズ設定
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
// MEMO: スクロール終了時に水平方向のスクロールが可能で中心位置で止まる
section.orthogonalScrollingBehavior = .groupPagingCentered
return section
}
// ② キーワード一覧を横に並べてスクロールを伴う表現をするレイアウト構築例
private func createRecentKeywordsLayout() -> NSCollectionLayoutSection {
// 1. Itemのサイズ設定
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)
// 2. Groupのサイズ設定
// MEMO: 1列に表示するカラム数を1として設定し、itemのサイズがgroupのサイズで決定する形にしている
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(160), heightDimension: .absolute(40))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 1)
// 3. Sectionのサイズ設定
let section = NSCollectionLayoutSection(group: group)
// MEMO: HeaderとFooterのレイアウトを決定する
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(65.0))
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(28.0))
let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
section.boundarySupplementaryItems = [header, footer]
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 16, trailing: 6)
// MEMO: スクロール終了時に水平方向のスクロールが可能で速度が0になった位置で止まる
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
return section
}
⭐️3-4. (レイアウト例2) Instagramのフィード表示のようなDynamicHeightSizing
次にInstagramのフィード表示のような1行のセル表示でDynamicHeightSizing(高さが可変になる)表現を考えてみます。高さを可変にしたい場合には、ItemとGroupのサイズを設定する際に高さを予測値を一番データ表示が少ない場合の高さを設定すると良いかと思います。
private func createFeaturedInterviewsLayout() -> NSCollectionLayoutSection {
// MEMO: 該当のセルを基準にした高さの予測値を設定する
let estimatedHeight = UIScreen.main.bounds.width + 180.0
// 1. Itemのサイズ設定
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(estimatedHeight))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = .zero
// 2. Groupのサイズ設定
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(estimatedHeight))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
group.contentInsets = .zero
// 3. Sectionのサイズ設定
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
return section
}
⭐️3-5. (レイアウト例3) Instagramの写真表示のようなMosaicLayout
もう一つUICollectionViewの複雑なレイアウトの実装例としてInstagramの写真表示のようなMosaicLayoutの表現を考えてみます。Groupの入れ子構造を組み合わせてレイアウトを組み立てていく点がポイントになります。
private func createNewArrivalArticles() -> NSCollectionLayoutSection {
// 1. Itemのサイズ設定
// MEMO: 全体幅2/3の正方形を作るために左側の幅を.fractionalWidth(0.67)に決める
let twoThirdItemSet = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.67), heightDimension: .fractionalHeight(1.0)))
twoThirdItemSet.contentInsets = NSDirectionalEdgeInsets(top: 0.5, leading: 0.5, bottom: 0.5, trailing: 0.5)
// MEMO: 右側に全体幅1/3の正方形を2つ作るために高さを.fractionalHeight(0.5)に決める
let oneThirdItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.5)))
oneThirdItem.contentInsets = NSDirectionalEdgeInsets(top: 0.5, leading: 0.5, bottom: 0.5, trailing: 0.5)
// MEMO: 1列に表示するカラム数を2として設定し、Group内のアイテムの幅を1/3の正方形とするためにGroup内の幅を.fractionalWidth(0.33)に決める
let oneThirdItemSet = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1.0)), subitem: oneThirdItem, count: 2)
// 2. Groupのサイズ設定
// MEMO: leadingItem(左側へ表示するアイテム1つ)とtrailingGroup(右側へ表示するアイテム2個のグループ1個)を合わせる
let group = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.33)), subitems: [twoThirdItemSet, oneThirdItemSet])
// 3. Sectionのサイズ設定
let section = NSCollectionLayoutSection(group: group)
// MEMO: HeaderとFooterのレイアウトを決定する
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
section.boundarySupplementaryItems = [header]
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
return section
}
4. DiffableDataSourceを利用した表示内容の反映と表示用のModelデータに関する部分について
次にDiffableDataSourceを利用した処理に関する部分にも触れてみます。UICollectionViewにおけるDataSourceの更新を反映する処理はreloadData()をはじめ頻出の実装ではありますが、用件や更新タイミングに関する処理がシビアで複雑な場合は、「データとUIの表示状態の食い違い」に注意が必要でした。
新しく登場したDiffableDataSourceは、従来までのperformBatchUpdatesを利用した処理でも難しかった「データとUIの表示状態の食い違いの防止」を内部で解決してくれる点も大きな魅力の1つかと思います。
※iOS13以降であれば、UITableViewを利用した場合でもNSDiffableDataSourceを利用する事が可能です。
⭐️4-1. NSDiffableDataSourceを利用する際における基本的なデータの更新方法
取得したデータの取得〜データの反映までの流れを簡潔にまとめると、
- NSDiffableDataSourceSnapshotに定義したセクションに該当するデータをセットする
- UICollectionViewDiffableDataSourceのapplyメソッドでDiffableDataSourceSnapshotの内容を反映する
となります。下記は、本サンプル(ComplexCollectionViewStyleExample)におけるセル要素を取得して反映させる処理部分のコードを抜粋したものになります。
// ① NSDiffableDataSourceSnapshotの初期設定
// → Section毎のEnum定義(MainSection)に応じて表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある
snapshot = NSDiffableDataSourceSnapshot<MainSection, AnyHashable>()
snapshot.appendSections(MainSection.allCases)
for mainSection in MainSection.allCases {
snapshot.appendItems([], toSection: mainSection)
}
dataSource.apply(snapshot, animatingDifferences: false)
// ② UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshotの更新
// → APIからのデータ取得ができた際に該当セクションの値を更新してUICollectionViewDiffableDataSource<MainSection, AnyHashable>に反映する
// 補足: 更新時のアニメーション可否はanimatingDifferencesで行う
let featuredInterviews: [FeaturedInterview] = receiverdFeaturedInterviews
snapshot.appendItems(featuredInterviews, toSection: .FeaturedInterviews)
dataSource.apply(snapshot, animatingDifferences: false)
⭐️4-2. NSDiffableDataSourceを利用する際におけるModel作成時におけるポイント
APIモックサーバーを経由して取得するUIに表示するデータについては、JSON経由で取得する想定で作成しているのでDecodableに適合させている点に加えて、NSDiffableDataSourceで利用可能な形にするためにHashableにも適合させる必要があります。
※ 今回は取得したデータをシンプルにUIに反映させるだけの処理なので、IDをハッシュに設定しています。
各セクションで表示データのModel定義及びAPIモックサーバーのエンドポイントは異なりますが、基本的にはDecodable, Hashableに適合した形でJSONの形に合わせた定義としています。下記は、本サンプル(ComplexCollectionViewStyleExample)におけるModel定義の例を抜粋したものになります。
struct FeaturedInterview: Hashable, Decodable {
let id: Int
let profileName: String
let dateString: String
let imageUrl: String
let title: String
let description: String
let tags: String
// MARK: - Enum
private enum Keys: String, CodingKey {
case id
case profileName = "profile_name"
case dateString = "date_string"
case imageUrl = "image_url"
case title
case description
case tags
}
// MARK: - Initializer
init(from decoder: Decoder) throws {
// JSONの配列内の要素を取得する
let container = try decoder.container(keyedBy: Keys.self)
// JSONの配列内の要素にある値をDecodeして初期化する
self.id = try container.decode(Int.self, forKey: .id)
self.profileName = try container.decode(String.self, forKey: .profileName)
self.dateString = try container.decode(String.self, forKey: .dateString)
self.imageUrl = try container.decode(String.self, forKey: .imageUrl)
self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description)
self.tags = try container.decode(String.self, forKey: .tags)
}
// MARK: - Hashable
// MEMO: Hashableプロトコルに適合させるための処理
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: FeaturedInterview, rhs: FeaturedInterview) -> Bool {
return lhs.id == rhs.id
}
}
5. Combineを利用してAPIリクエストをMVVMパターンでハンドリングする部分について
こちらも、iOS13から新しく登場したCombineを利用したAPIリクエストをハンドリングするための実装をしています。今年からの実務では「RxSwift + MVVM + ViewModelへのアクセス時に入力(Input)・出力(Output)を明記する」構成でのiOSアプリ開発に触れる時間が多かったので、RxSwiftの部分をCombineを利用した実装にリプレイスしていく方針を試してみました。
Kickstarter-iOSで利用しているViewModelの設計と実装については、下記の資料も参考にするとより理解が深まるかと思います。
- Introducing ViewModel Inputs/Outputs: a modern approach to MVVM architecture
- Kickstarter-iOSのViewModelの作り方がウマかった
⭐️5-1. API通信処理の部分をCombineを利用した実装解説
API通信処理部分をRxSwiftで実装する場合には、Single<T>
を利用して成功か失敗かのいずれかのイベントを1度だけ流すことを保証するオペレータを活用した実装や、Alamofireをラップしたライブラリの「Moya」を活用する選択をすることが多いかと思いますが、CombineではSingle<T>
と類似した振る舞いをするFuture<Output, Failure>
を利用してAPI通信部分の処理を組み立てています。
ここに加えて、それぞれ異なるModel定義に合致したJSONレスポンスの形にうまく対応させるために、T: Decodable & Hashable
のGenericsにしている点もポイントになります。
これらの点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)におけるAPI通信処理に関する実装をまとめると下記のような形になります。
import Foundation
import Combine
// MARK: - Protocol
enum APIError : Error {
case error(String)
}
protocol APIRequestManagerProtocol {
func getFeaturedBanners() -> Future<[FeaturedBanner], APIError>
func getFeaturedInterviews() -> Future<[FeaturedInterview], APIError>
func getKeywords() -> Future<[Keyword], APIError>
func getNewArrivals() -> Future<[NewArrival], APIError>
func getArticles() -> Future<[Article], APIError>
}
class APIRequestManager {
// MEMO: MockサーバーへのURLに関する情報
private static let host = "http://localhost:3000/api/mock"
private static let version = "v1"
private static let path = "gourmet"
private let session = URLSession.shared
// MARK: - Singleton Instance
static let shared = APIRequestManager()
private init() {}
// MARK: - Enum
private enum EndPoint: String {
case featuredBanner = "featured_banners"
case featuredInterview = "featured_interviews"
case keyword = "keywords"
case newArrival = "new_arrivals"
case article = "articles"
func getBaseUrl() -> String {
return [host, version, path, self.rawValue].joined(separator: "/")
}
}
}
// MARK: - APIRequestManagerProtocol
extension APIRequestManager: APIRequestManagerProtocol {
// MARK: - Function
func getFeaturedBanners() -> Future<[FeaturedBanner], APIError> {
let featuresdBannersAPIRequest = makeUrlForGetRequest(EndPoint.featuredBanner.getBaseUrl())
return handleSessionTask(FeaturedBanner.self, request: featuresdBannersAPIRequest)
}
・・・(以降は同様にAPIリクエストを実行する処理を実施する)・・・
// MARK: - Private Function
private func handleSessionTask<T: Decodable & Hashable>(_ dataType: T.Type, request: URLRequest) -> Future<[T], APIError> {
return Future { promise in
let task = self.session.dataTask(with: request) { data, response, error in
// MEMO: レスポンス形式やステータスコードを元にしたエラーハンドリングをする
if let error = error {
promise(.failure(APIError.error(error.localizedDescription)))
return
}
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
promise(.failure(APIError.error("Error: invalid HTTP response code")))
return
}
guard let data = data else {
promise(.failure(APIError.error("Error: missing response data")))
return
}
// MEMO: 取得できたレスポンスを引数で指定した型の配列に変換して受け取る
do {
let hashableObjects = try JSONDecoder().decode([T].self, from: data)
promise(.success(hashableObjects))
} catch {
promise(.failure(APIError.error(error.localizedDescription)))
}
}
task.resume()
}
}
private func makeUrlForGetRequest(_ urlString: String) -> URLRequest {
guard let url = URL(string: urlString) else {
fatalError()
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "GET"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
return urlRequest
}
}
⭐️5-2. Combineを利用したModel → ViewModel部分の実装解説
次に、Combineを利用したViewModelについて紹介していきます。Input(何らかの処理を発火させるためのトリガー)
とOutput(処理によって取得できた結果を反映させる変数)
の定義を前述したAPI通信処理と組み合わせることによって、
- 処理の実行: mainViewModel.inputs.●●●Trigger.send()
- 結果の反映: mainViewModel.outputs.●●●.subscribe(on: RunLoop.main).sink({ ... })
の流れをつくり、ViewControllerにおけるデータの取得処理・反映処理を繋げられる様な形にしています。
本サンプル(ComplexCollectionViewStyleExample)におけるViewModelの実装をまとめると下記のような形になります。
import Foundation
import Combine
// MARK: - Protocol
protocol MainViewModelInputs {
var fetchFeaturedBannersTrigger: PassthroughSubject<Void, Never> { get }
var fetchFeaturedInterviewsTrigger: PassthroughSubject<Void, Never> { get }
var fetchKeywordsTrigger: PassthroughSubject<Void, Never> { get }
var fetchNewArrivalsTrigger: PassthroughSubject<Void, Never> { get }
var fetchArticlesTrigger: PassthroughSubject<Void, Never> { get }
}
protocol MainViewModelOutputs {
var featuredBanners: AnyPublisher<[FeaturedBanner], Never> { get }
var featuredInterviews: AnyPublisher<[FeaturedInterview], Never> { get }
var keywords: AnyPublisher<[Keyword], Never> { get }
var newArrivals: AnyPublisher<[NewArrival], Never> { get }
var articles: AnyPublisher<[Article], Never> { get }
}
protocol MainViewModelType {
var inputs: MainViewModelInputs { get }
var outputs: MainViewModelOutputs { get }
}
final class MainViewModel: MainViewModelType, MainViewModelInputs, MainViewModelOutputs {
// MARK: - MainViewModelType
var inputs: MainViewModelInputs { return self }
var outputs: MainViewModelOutputs { return self }
// MARK: - MainViewModelInputs
let fetchFeaturedBannersTrigger = PassthroughSubject<Void, Never>()
let fetchFeaturedInterviewsTrigger = PassthroughSubject<Void, Never>()
let fetchKeywordsTrigger = PassthroughSubject<Void, Never>()
let fetchNewArrivalsTrigger = PassthroughSubject<Void, Never>()
let fetchArticlesTrigger = PassthroughSubject<Void, Never>()
// MARK: - MainViewModelOutputs
var featuredBanners: AnyPublisher<[FeaturedBanner], Never> {
return $_featuredBanners.eraseToAnyPublisher()
}
var featuredInterviews: AnyPublisher<[FeaturedInterview], Never> {
return $_featuredInterviews.eraseToAnyPublisher()
}
var keywords: AnyPublisher<[Keyword], Never> {
return $_keywords.eraseToAnyPublisher()
}
var newArrivals: AnyPublisher<[NewArrival], Never> {
return $_newArrivals.eraseToAnyPublisher()
}
var articles: AnyPublisher<[Article], Never> {
return $_articles.eraseToAnyPublisher()
}
private let api: APIRequestManagerProtocol
private var cancellables: [AnyCancellable] = []
// MARK: - @Published
// MEMO: このコードではNSDiffableDataSourceSnapshotの差分更新部分で利用する
@Published private var _featuredBanners: [FeaturedBanner] = []
@Published private var _featuredInterviews: [FeaturedInterview] = []
@Published private var _keywords: [Keyword] = []
@Published private var _newArrivals: [NewArrival] = []
@Published private var _articles: [Article] = []
// MARK: - Initializer
init(api: APIRequestManagerProtocol) {
// MEMO: 適用するAPIリクエスト用の処理
self.api = api
// MEMO: InputTriggerとAPIリクエストをするための処理を結合する
fetchFeaturedBannersTrigger
.sink(
receiveValue: { [weak self] in
self?.fetchFeaturedBanners()
}
)
.store(in: &cancellables)
fetchFeaturedInterviewsTrigger
.sink(
receiveValue: { [weak self] in
self?.fetchFeaturedInterviews()
}
)
.store(in: &cancellables)
fetchKeywordsTrigger
.sink(
receiveValue: { [weak self] in
self?.fetchKeywords()
}
)
.store(in: &cancellables)
fetchNewArrivalsTrigger
.sink(
receiveValue: { [weak self] in
self?.fetchNewArrivals()
}
)
.store(in: &cancellables)
fetchArticlesTrigger
.sink(
receiveValue: { [weak self] in
self?.fetchArticles()
}
)
.store(in: &cancellables)
}
// MARK: - deinit
deinit {
cancellables.forEach { $0.cancel() }
}
// MARK: - Privete Function
private func fetchFeaturedBanners() {
api.getFeaturedBanners()
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { completion in
switch completion {
// MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
case .finished:
print("finished fetchFeaturedBanners(): \(completion)")
// MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
case .failure(let error):
print("error fetchFeaturedBanners(): \(error.localizedDescription)")
}
},
receiveValue: { [weak self] hashableObjects in
print(hashableObjects)
self?._featuredBanners = hashableObjects
}
)
.store(in: &cancellables)
}
private func fetchFeaturedInterviews() {
api.getFeaturedInterviews()
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { completion in
switch completion {
// MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
case .finished:
print("finished fetchFeaturedInterviews(): \(completion)")
// MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
case .failure(let error):
print("error fetchFeaturedInterviews(): \(error.localizedDescription)")
}
},
receiveValue: { [weak self] hashableObjects in
print(hashableObjects)
self?._featuredInterviews = hashableObjects
}
)
.store(in: &cancellables)
}
private func fetchKeywords() {
api.getKeywords()
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { completion in
switch completion {
// MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
case .finished:
print("finished fetchKeywords(): \(completion)")
// MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
case .failure(let error):
print("error fetchKeywords(): \(error.localizedDescription)")
}
},
receiveValue: { [weak self] hashableObjects in
print(hashableObjects)
self?._keywords = hashableObjects
}
)
.store(in: &cancellables)
}
private func fetchNewArrivals() {
api.getNewArrivals()
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { completion in
switch completion {
// MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
case .finished:
print("finished fetchNewArrivals(): \(completion)")
// MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
case .failure(let error):
print("error fetchNewArrivals(): \(error.localizedDescription)")
}
},
receiveValue: { [weak self] hashableObjects in
print(hashableObjects)
self?._newArrivals = hashableObjects
}
)
.store(in: &cancellables)
}
private func fetchArticles() {
api.getArticles()
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { completion in
switch completion {
// MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
case .finished:
print("finished getArticles(): \(completion)")
// MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
case .failure(let error):
print("error getArticles(): \(error.localizedDescription)")
}
},
receiveValue: { [weak self] hashableObjects in
print(hashableObjects)
self?._articles = hashableObjects
}
)
.store(in: &cancellables)
}
}
⭐️5-3. Combineを利用したViewModel → ViewController部分の実装解説
最後に、Combineを利用したViewControllerの実装について紹介していきます。ViewModelのOutput定義におけるreceiveValue:
の中にNSDiffableDataSourceの更新処理を組み合わせることによって、API通信処理と連動したセクション毎に定義したセルのデータ反映をする形にしています。
本サンプル(ComplexCollectionViewStyleExample)におけるViewControllerの実装をまとめると下記のような形になります。
final class MainViewController: UIViewController {
// MARK: - Variables
private var cancellables: [AnyCancellable] = []
// MEMO: API経由の非同期通信からデータを取得するためのViewModel
private let viewModel: MainViewModel = MainViewModel(api: APIRequestManager.shared)
// MEMO: UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
private var snapshot: NSDiffableDataSourceSnapshot<MainSection, AnyHashable>!
// MEMO: UICollectionViewを組み立てるためのDataSource
private var dataSource: UICollectionViewDiffableDataSource<MainSection, AnyHashable>! = nil
・・・(省略)・・・
// MARK: - deinit
deinit {
cancellables.forEach { $0.cancel() }
}
// MARK: - Override
override func viewDidLoad() {
super.viewDidLoad()
・・・(省略)・・・
bindToMainViewModelOutputs()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// MEMO: ViewModelのInputsを経由したAPIでのデータ取得処理を実行する
viewModel.inputs.fetchFeaturedBannersTrigger.send()
viewModel.inputs.fetchFeaturedInterviewsTrigger.send()
viewModel.inputs.fetchKeywordsTrigger.send()
viewModel.inputs.fetchNewArrivalsTrigger.send()
viewModel.inputs.fetchArticlesTrigger.send()
}
・・・(省略)・・・
private func bindToMainViewModelOutputs() {
// 1. ViewModelのOutputsを経由した特集バナーデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
viewModel.outputs.featuredBanners
.subscribe(on: RunLoop.main)
.sink(
receiveValue: { [weak self] featuredBanners in
guard let self = self else { return }
self.snapshot.appendItems(featuredBanners, toSection: .FeaturedBanners)
self.dataSource.apply(self.snapshot, animatingDifferences: false)
}
)
.store(in: &cancellables)
// 2. ViewModelのOutputsを経由した特集インタビューデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
viewModel.outputs.featuredInterviews
.subscribe(on: RunLoop.main)
.sink(
receiveValue: { [weak self] featuredInterviews in
guard let self = self else { return }
self.snapshot.appendItems(featuredInterviews, toSection: .FeaturedInterviews)
self.dataSource.apply(self.snapshot, animatingDifferences: false)
}
)
.store(in: &cancellables)
// 3. ViewModelのOutputsを経由したキーワードデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
viewModel.outputs.keywords
.subscribe(on: RunLoop.main)
.sink(
receiveValue: { [weak self] keywords in
guard let self = self else { return }
self.snapshot.appendItems(keywords, toSection: .RecentKeywords)
self.dataSource.apply(self.snapshot, animatingDifferences: false)
}
)
.store(in: &cancellables)
// 4. ViewModelのOutputsを経由した新着データの取得とNSDiffableDataSourceSnapshotの入れ替え処理
viewModel.outputs.newArrivals
.subscribe(on: RunLoop.main)
.sink(
receiveValue: { [weak self] newArrivals in
guard let self = self else { return }
self.snapshot.appendItems(newArrivals, toSection: .NewArrivalArticles)
self.dataSource.apply(self.snapshot, animatingDifferences: false)
}
)
.store(in: &cancellables)
// 5. ViewModelのOutputsを経由した記事データの取得とNSDiffableDataSourceSnapshotの入れ替え処理
viewModel.outputs.articles
.subscribe(on: RunLoop.main)
.sink(
receiveValue: { [weak self] articles in
guard let self = self else { return }
self.snapshot.appendItems(articles, toSection: .RegularArticles)
self.dataSource.apply(self.snapshot, animatingDifferences: false)
}
)
.store(in: &cancellables)
}
}
⭐️5-4. それぞれの処理における流れの概略図
本サンプル(ComplexCollectionViewStyleExample)で定義しているViewModelにおける内部的な値の関係と引き渡す流れをまとめると下図の様な形になります。
値の中継地点となる変数@Published private var _article: [Article]
を定義しておくことで、BehaviorRelayのような役割を担う形にする点など、RxSwiftのオペレータを利用した場合との細かな相違点はありますが、Combineで提供されている機能を活用することで近しい形のデータフローを作成することができるかと思います。
6. UICollectionViewCompositionalLayout + DiffableDataSource + Combineを利用した無限スクロール&WaterFallLayoutを実現した事例紹介
最後に、これまで紹介してきたサンプル(ComplexCollectionViewStyleExample)とは、別のサンプル(DiffableDataSourceExample)での実装事例を簡単ではありますが紹介していきます。
UICollectionViewCompositionalLayoutを利用した「Pinterestの様なWaterFallLayout」と「Scrollが最下部に達した際に次ページが追加されるような実装とRefreshControl部分」をCombineを利用した実装で実現したUI実装サンプルになります。
⭐️6-1. UICollectionViewCompositionalLayoutを利用したWaterFallLayoutの実装部分を組み立てる
Pinterestの様な、写真の縦横比を維持してかつセルの高さを合わせて変更するような処理については、UICollectionViewを利用する場合においても難しい表現の1つてあると思います。この様な表現をする場合でもUICollectionViewCompositionalLayoutを利用すると、比較的見通しが良い形で実装ができるように思います。
本サンプル(DiffableDataSourceExample)では、JSONのレスポンス内に予めサムネイル画像における縦横サイズを持っている形になっているので、この値を利用することで配置対象セルのサイズを決定することができます。
【レイアウトのサイズと配置に関する計算部分の抜粋】
// UICollectionViewCompositionalLayoutを利用したレイアウトを組み立てる処理
private func createWaterFallLayoutSection() -> NSCollectionLayoutSection {
if snapshot.numberOfItems == 0 {
return applyForNoItemLayoutSection()
} else {
return applyForWaterFallLayoutSection()
}
}
private func applyForNoItemLayoutSection() -> NSCollectionLayoutSection {
// MEMO: .absoluteや.estimatedを設定する場合で0を入れると下記のようなログが出ます。
// → Invalid estimated dimension, must be > 0. NOTE: This will be a hard-assert soon, please update your call site.
// 1. Itemのサイズ設定
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(0.5))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = .zero
// 2. Groupのサイズ設定
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(0.5))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
group.contentInsets = .zero
// 3. Sectionのサイズ設定
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = .zero
return section
}
private func applyForWaterFallLayoutSection() -> NSCollectionLayoutSection {
// MEMO: 表示するアイテムが存在する場合は各セルの高さの適用とそれに基くUICollectionView全体の高さを計算する
// Model内で持っているheightの値を適用することでWaterFallLayoutの様な見た目を実現する
var leadingGroupHeight: CGFloat = 0.0
var trailingGroupHeight: CGFloat = 0.0
var leadingGroupItems: [NSCollectionLayoutItem] = []
var trailingGroupItems: [NSCollectionLayoutItem] = []
let photos = snapshot.itemIdentifiers(inSection: .WaterFallLayout)
let totalHeight = photos.reduce(CGFloat(0)) { $0 + $1.height }
let columnHeight = CGFloat(totalHeight / 2.0)
var runningHeight = CGFloat(0.0)
// 1. Itemのサイズ設定
for index in 0..<snapshot.numberOfItems {
let photo = photos[index]
let isLeading = runningHeight < columnHeight
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(photo.height))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
runningHeight += photo.height
if isLeading {
leadingGroupItems.append(item)
leadingGroupHeight += photo.height
} else {
trailingGroupItems.append(item)
trailingGroupHeight += photo.height
}
}
// 2. Groupのサイズ設定
let leadingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .absolute(leadingGroupHeight))
let leadingGroup = NSCollectionLayoutGroup.vertical(layoutSize: leadingGroupSize, subitems: leadingGroupItems)
let trailingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .absolute(trailingGroupHeight))
let trailingGroup = NSCollectionLayoutGroup.vertical(layoutSize: trailingGroupSize, subitems: trailingGroupItems)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(max(leadingGroupHeight, trailingGroupHeight)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [leadingGroup, trailingGroup])
// 3. Sectionのサイズ設定
let section = NSCollectionLayoutSection(group: group)
return section
}
【JSONで取得したレスポンスをマッピングする部分の抜粋】
import Foundation
import UIKit
// MARK: - Struct (PhotoList)
struct PhotoList: Hashable, Decodable {
private let uuid = UUID()
let page: Int
let photos: [Photo]
let hasNextPage: Bool
// MARK: - Enum
private enum Keys: String, CodingKey {
case page
case photos
case hasNextPage = "has_next_page"
}
// MARK: - Initializer
init(from decoder: Decoder) throws {
// JSONの配列内の要素を取得する
let container = try decoder.container(keyedBy: Keys.self)
// JSONの配列内の要素にある値をDecodeして初期化する
self.page = try container.decode(Int.self, forKey: .page)
self.photos = try container.decode([Photo].self, forKey: .photos)
self.hasNextPage = try container.decode(Bool.self, forKey: .hasNextPage)
}
// MARK: - Hashable
// MEMO: Hashableプロトコルに適合させるための処理
func hash(into hasher: inout Hasher) {
hasher.combine(uuid)
}
static func == (lhs: PhotoList, rhs: PhotoList) -> Bool {
return lhs.uuid == rhs.uuid
}
}
// MARK: - Struct (Photo)
struct Photo: Hashable, Decodable {
let id: Int
let title: String
let summary: String
let image: Image
let gift: Gift
private(set) var height: CGFloat = 0.0
// MARK: - Enum
private enum Keys: String, CodingKey {
case id
case title
case summary
case image
case gift
}
// MARK: - Initializer
init(from decoder: Decoder) throws {
// JSONの配列内の要素を取得する
let container = try decoder.container(keyedBy: Keys.self)
// JSONの配列内の要素にある値をDecodeして初期化する
self.id = try container.decode(Int.self, forKey: .id)
self.title = try container.decode(String.self, forKey: .title)
self.summary = try container.decode(String.self, forKey: .summary)
self.image = try container.decode(Image.self, forKey: .image)
self.gift = try container.decode(Gift.self, forKey: .gift)
// MEMO: 写真のサイズに基づいて算出した縦横比を利用して適用したセルのサイズを算出する
let screenHalfWidth = UIScreen.main.bounds.width * 0.5
let ratio = CGFloat(self.image.height) / CGFloat(self.image.width)
let titleAndSummaryHeight: CGFloat = 90.0
self.height = screenHalfWidth * ratio + titleAndSummaryHeight
}
// MARK: - Hashable
// MEMO: Hashableプロトコルに適合させるための処理
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Photo, rhs: Photo) -> Bool {
return lhs.id == rhs.id
}
}
// MARK: - Photo Extension
extension Photo {
struct Image: Decodable {
let url: String
let width: Int
let height: Int
}
struct Gift: Decodable {
let flag: Bool
let price: Int?
}
}
⭐️6-2. 表示ModelデータのHash値が等しいデータがあった場合は新しいものに上書きする
4章でも軽く触れましたが、表示データのModelはHashableに適合している関係で、データそれぞれにHash値を持っていますが、APIのレスポンスで次ページの内容を取得した際に既に表示したデータが更新のタイミング等で含まれてしまった際にHash値の衝突
が発生してしまいます。
本サンプル(DiffableDataSourceExample)では、既に表示しているデータ次ページの内容を取得した際に、更新された内容を反映する必要があるので下記に示したコードの様な形でデータのHash値を比較して、新しいデータが存在する場合には既存で表示しているものを置き換えるようにしています。
(ここでは一意となるidをHash値作成時に利用しています。)
import Foundation
struct UniqueDataArrayBuilder {
// MARK: - Static Function
// モデル内に定義したハッシュ値の同一性を検証して一意な表示用データ配列を作成する
static func fillDifferenceOfOldAndNewLists<T: Decodable & Hashable>(_ dataType: T.Type, oldDataList: [T], newDataList: [T]) -> [T] {
// 引数より受け取った新しいデータ配列
var newDataList = newDataList
// 返却用の配列
var dataList: [T] = []
// 既存の表示データ配列をループさせて同一のものがある場合は新しいデータへ置き換える
// ここはもっと綺麗に書ける余地がある部分だと思う...
for oldData in oldDataList {
var shouldAppendOldData = true
for (newIndex, newData) in newDataList.enumerated() {
// 同一データの確認(写真表示用のモデルはHashableとしているのでidの一致で判定できるようにしている部分がポイント)
if oldData == newData {
shouldAppendOldData = false
dataList.append(newData)
newDataList.remove(at: newIndex)
break
}
}
if shouldAppendOldData {
dataList.append(oldData)
}
}
// 置き換えたものを除外した新しいデータを後ろへ追加する
for newData in newDataList {
dataList.append(newData)
}
return dataList
}
}
⭐️6-3. APIからのデータ取得から画面表示までの流れに関する実装とUIScrollViewDelegateと連動したUI表現に関するまとめ
本サンプル(DiffableDataSourceExample)では、APIリクエストからデータを反映させる部分についても基本的には、これまでの解説で触れてきた「Combine + MVVM』の構成で実装をしています。
Scrollが最下部に達した際に次ページが追加されるような実装については、UIScrollViewDelegateを利用してコンテンツ表示位置が最下部まで到達した時をトリガーとして、ViewModel側に定義した次のページ表示用のAPIリクエストを実行している点がポイントになります。
また、RefreshControlを伴う表示データのリセット処理についても、ViewModel側に別途Input用のトリガーを準備しておき、これまで表示していた内容を一度リセットしてから1ページ目のAPIリクエストを実行して実現させています。
【該当部分におけるViewModelでの実装】
import Foundation
import Combine
// MARK: - Protocol
protocol PhotoViewModelInputs {
var fetchPhotoTrigger: PassthroughSubject<Void, Never> { get }
var refreshPhotoTrigger: PassthroughSubject<Void, Never> { get }
}
protocol PhotoViewModelOutputs {
var photos: AnyPublisher<[Photo], Never> { get }
var apiRequestStatus: AnyPublisher<APIRequestStatus, Never> { get }
}
protocol PhotoViewModelType {
var inputs: PhotoViewModelInputs { get }
var outputs: PhotoViewModelOutputs { get }
}
final class PhotoViewModel: PhotoViewModelType, PhotoViewModelInputs, PhotoViewModelOutputs {
// MARK: - PhotoViewModelType
var inputs: PhotoViewModelInputs { return self }
var outputs: PhotoViewModelOutputs { return self }
// MARK: - PhotoViewModelInputs
let fetchPhotoTrigger = PassthroughSubject<Void, Never>()
let refreshPhotoTrigger = PassthroughSubject<Void, Never>()
// MARK: - MainViewModelOutputs
var photos: AnyPublisher<[Photo], Never> {
return $_photos.eraseToAnyPublisher()
}
var apiRequestStatus: AnyPublisher<APIRequestStatus, Never> {
return $_apiRequestStatus.eraseToAnyPublisher()
}
private let api: APIRequestManagerProtocol
private var nextPageNumber: Int = 1
private var hasNextPage: Bool = true
private var cancellables: [AnyCancellable] = []
// MARK: - @Published
// MEMO: このコードではNSDiffableDataSourceSnapshotの差分更新部分で利用する
@Published private var _photos: [Photo] = []
@Published private var _apiRequestStatus: APIRequestStatus = .none
// MARK: - Initializer
init(api: APIRequestManagerProtocol) {
// MEMO: 適用するAPIリクエスト用の処理
self.api = api
// MEMO: ページング処理を伴うAPIリクエスト
// → 実行時はViewController側でviewModel.inputs.fetchPhotoTrigger.send()で実行する
fetchPhotoTrigger
.sink(
receiveValue: { [weak self] in
guard let self = self else { return }
// MEMO: 次のページが存在しない場合は以降の処理を実施しないようにする
guard self.hasNextPage else {
return
}
self.fetchPhotoList()
}
)
.store(in: &cancellables)
// MEMO: 現在まで取得したデータのリフレッシュ処理を伴うAPIリクエスト
// → 実行時はViewController側でviewModel.inputs.refreshPhotoTrigger.send()で実行する
refreshPhotoTrigger
.sink(
receiveValue: { [weak self] in
guard let self = self else { return }
self.nextPageNumber = 1
self.hasNextPage = true
self._photos = []
self.fetchPhotoList()
}
)
.store(in: &cancellables)
}
// MARK: - deinit
deinit {
cancellables.forEach { $0.cancel() }
}
// MARK: - Privete Function
private func fetchPhotoList() {
// APIとの通信処理ステータスを「実行中」へ切り替える
_apiRequestStatus = .requesting
// APIとの通信処理を実行する
api.getPhotoList(perPage: nextPageNumber)
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { [weak self] completion in
guard let self = self else { return }
switch completion {
// MEMO: 値取得に成功した場合のハンドリング
case .finished:
// MEMO: APIリクエストの処理結果を成功の状態に更新する
self._apiRequestStatus = .requestSuccess
print("receiveCompletion finished fetchPhotoList(): \(completion)")
// MEMO: 値取得に失敗した場合のハンドリング
case .failure(let error):
// MEMO: APIリクエストの処理結果を失敗の状態に更新する
self._apiRequestStatus = .requestFailure
print("receiveCompletion error fetchPhotoList(): \(error.localizedDescription)")
}
},
receiveValue: { [weak self] hashableObjects in
guard let self = self else { return }
if let photoList = hashableObjects.first {
// MEMO: ViewModel内部処理用の変数を更新する
self.nextPageNumber = photoList.page + 1
self.hasNextPage = photoList.hasNextPage
// MEMO: 表示対象データを差分更新する
self._photos = UniqueDataArrayBuilder.fillDifferenceOfOldAndNewLists(Photo.self, oldDataList: self._photos, newDataList: photoList.photos)
print("receiveValue fetchPhotoList(): \(photoList)")
}
}
)
.store(in: &cancellables)
}
}
【該当部分におけるViewControllerでの実装(抜粋)】
final class MainViewController: UIViewController {
// MARK: - Variables
// UICollectionViewに設置するRefreshControl
private let mainRefrashControl = UIRefreshControl()
// MEMO: API経由の非同期通信からデータを取得するためのViewModel
private let viewModel: PhotoViewModel = PhotoViewModel(api: APIRequestManager.shared)
// MEMO: Cancellableの保持用(※RxSwiftでいうところのDisposeBagの様なイメージ)
private var cancellables: [AnyCancellable] = []
// MEMO: UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
private var snapshot: NSDiffableDataSourceSnapshot<PhotoSection, Photo>!
// MEMO: UICollectionViewを組み立てるためのDataSource
private var dataSource: UICollectionViewDiffableDataSource<PhotoSection, Photo>! = nil
・・・(省略)・・・
// MARK: - Override
override func viewDidLoad() {
super.viewDidLoad()
・・・(省略)・・・
bindToViewModelOutputs()
}
// MARK: - Private Function
// UICollectionViewにおけるPullToRefresh実行時の処理
@objc private func executeRefresh() {
// MEMO: ViewModelに定義した表示データのリフレッシュ処理を実行する
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
self.viewModel.inputs.refreshPhotoTrigger.send()
}
}
// ViewModelのOutputとこのViewControllerでのUIに関する処理をバインドする
private func bindToViewModelOutputs() {
// MEMO: APIへのリクエスト状態に合わせたUI側の表示におけるハンドリングを実行する
viewModel.outputs.apiRequestStatus
.subscribe(on: RunLoop.main)
.sink(
receiveValue: { [weak self] status in
guard let self = self else { return }
switch status {
case .requesting:
self.mainRefrashControl.beginRefreshing()
case .requestFailure:
// MEMO: 通信失敗時はアラート表示 & RefreshControlの状態変更
self.mainRefrashControl.endRefreshing()
self.showAlertWith(completionHandler: nil)
default:
self.mainRefrashControl.endRefreshing()
}
}
)
.store(in: &cancellables)
// MEMO: APIへのリクエスト状態に合わせたUI側の表示におけるハンドリングを実行する
viewModel.outputs.photos
.subscribe(on: RunLoop.main)
.sink(
receiveValue: { [weak self] photos in
guard let self = self else { return }
// MEMO: ID(Identifier)が重複する場合における衝突の回避をする
let beforePhoto = self.snapshot.itemIdentifiers(inSection: .WaterFallLayout)
self.snapshot.deleteItems(beforePhoto)
self.snapshot.appendItems(photos, toSection: .WaterFallLayout)
self.dataSource.apply(self.snapshot, animatingDifferences: false)
}
)
.store(in: &cancellables)
}
・・・(省略)・・・
}
・・・(省略)・・・
extension MainViewController: UIScrollViewDelegate {
// MEMO: NSCollectionLayoutSectionのScroll(section.orthogonalScrollingBehavior)ではUIScrollViewDelegateは呼ばれない
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// MEMO: UIRefreshControl表示時は以降の処理を行わない(※APIリクエストの状態とRefreshControlの状態を連動させている点がポイント)
if mainRefrashControl.isRefreshing {
return
}
// MEMO: UIScrollViewが一番下の状態に達した時にAPIリクエストを実行する
if scrollView.contentOffset.y + scrollView.frame.size.height > scrollView.contentSize.height {
viewModel.inputs.fetchPhotoTrigger.send()
}
}
}
※ 本サンプル(DiffableDataSourceExample)では、UITableViewにおける類似した表現を実装した画面もありますので、是非見ていただけますと幸いです。
7. 今回紹介した実装における参考資料
UICollectionViewCompositionalLayout及びCombineを利用した実装を進めていく際や特徴の理解を進めていく上で僕が参考にした記事を下記にまとめてみました。
本記事で紹介している記事は英語記事であっても、コードを交えた解説がされているものが多いので、比較的読みやすいかと思いますので少しでも参考になれば幸いです。
⭐️7-1. UICollectionViewCompositionalLayoutを利用した今回の実装をする上での参考資料集
参考記事:
- 時代の変化に応じて進化するCollectionView ~Compositional LayoutsとDiffable Data Sources~
- Modern Collection Views with Compositional Layouts
- Move your cells left to right, up and down on iOS 13 — Part 1
- Move your cells left to right, up and down on iOS 13 — Part 2
- UICollectionView Compositional Layout
- All you need to know about UICollectionViewCompositionalLayout
- How to build complex layout using UICollectionViewCompositionalLayout and UICollectionViewDiffableDataSource
- How to use UICollectionViewDiffableDataSource - Flawless iOS - Medium
- Diffable Data Sources & Compositional Layouts Part 1/2
- Diffable Data Sources & Compositional Layouts Part 2
参考コード:
- GitHub - jVirus/compositional-layouts-kit
- GitHub - kishikawakatsumi/AppStore-Clone-CollectionViewCompositionalLayouts
⭐️7-2. Combineを利用した今回の実装をする上での参考資料集
参考記事:
- Problem Solving with Combine Swift
- Fetching Remote Async API with Apple Combine Framework
- Sinks and Completion Handlers in Swift Combine
- Swift, MVVM and Combine
- Getting started with the Combine framework in Swift
- [Swift] HTTP通信部分にCombineを使ってみる #WWDC19
- [Swift] はじめてのCombine | Apple製の非同期フレームワークを使ってみよう
参考コード:
- GitHub - mcichecki/Combine-MVVM
- GitHub - akifumi/mvvm-with-combine-in-uiviewcontroller
- GitHub - marty-suzuki/Ricemill
8. あとがき
結構長い記事になってしまって恐縮ではありますが、今回紹介したサンプル実装や記事の執筆を通して感じたことを簡単ではありますがまとめてみました。
⭐️8-1. UICollectionViewを利用した複雑な画面でも実装の見通しが立てやすくなった
iOS13から登場したUICollectionViewCompositionalLayout & DiffableDataSourceを活用したサンプルUIの実装に触れてみると、UICollectionViewを活用した画面レイアウトにおける複雑な表現がよりシンプルかつ直感的になったと感じています。
従来のUICollectionViewを活用して複雑なレイアウトを実装する方法では、UICollectionViewLayoutを継承したクラスを利用して、LayoutAttributesを調整する必要がある点に難しさがあるかと思いますが、その実装方法と比べてもコンパクトな形にまとめることができるのは大きなメリットではないかと思います。
※具体的な実装の事例を挙げると 「Pinterestの様なWaterFallLayout」 や 「Instagramの様なMosaicLayout」 を実現する場合には、その良さをより実感できるかもしれません。
また、場合によっては画面要素や構成するViewControllerを分割して実装する方針を取る必要がありそうなレイアウトについても単一のUICollectionViewの中に上手にまとめ上げることができる点も注目すべき大きな魅力の1つであるように思います。
※もちろん、UIの用件や仕様によっては従来通りの方法を採用した方が良い場合もあるので、画面設計の際の選択肢の1つしてケースバイケースで取捨選択していく方針でも今のところは良さそうに思います。
特にUICollectionViewCompositionalLayoutを利用したUI実装をする際には、
- NSCollectionLayoutSection / NSCollectionLayoutGroup / NSCollectionLayoutItem を組み合わせて実現するUICollectionViewCompositionalLayout組み立て方
- UICollectionViewDiffableDataSource / NSDiffableDataSourceSnapshotを利用したデータ反映ロジックの構築
の2点に注目すると、より理解がしやすくなるのではないかと感じております。
※iOS13以降では、UITableViewについてもUITableViewDiffableDataSource / NSDiffableDataSourceSnapshotを利用して差分更新のロジックを実現することができます。
⭐️8-2. CombineについてもRxSwift等と比較すると動きのイメージが掴みやすくなる
この記事で解説した2つのサンプルではどちらも「Combine + MVVMパターン」での実装をしていますが、元になっているのは「RxSwift + MVVMパターン」での実装を参考にしています。現段階ではRxSwift等では用意されているものの、Combineでは相当するものがないオペレータやコンポーネントもありますが、比較的シンプルな実装をCombineで置き換えていくような場合には、下記で紹介している 「RxSwiftとCombineを比較したチートシート」 等を参考にしながら進めていくと良いかと思います。
⭐️8-3. iOS12以前でも類似した表現を実現する際に役に立つライブラリのご紹介
今回紹介したUI表現や内部ロジックに関する実装については、iOS13以降で利用可能な機能を活用して実現しているものになりますが、iOS12以前のバージョンもサポートする必要がある場合でも似た様な表現や内部ロジックを実現する必要がある場合には、まずは下記に紹介しているライブラリを活用する形にする方針でも良いかと思います。
様々な要素からなる複雑な画面を差分を考慮して構築する際に役立つライブラリ:
iOS12以前でもCollectionViewCompositionalLayoutの様な構造に対応できるライブラリ:
iOS12以前でもUICollectionViewやUITableViewの差分更新を実現するライブラリ:
⭐️8-4. 最後に
今回は「現在携わっている業務の中で頻出なUI実装を参考にして、iOS13以降の新機能を利用した形にすこしずつ置き換えてみる」というテーマで実装をしたサンプルを元にした解説という形にしました。現在は特にRxSwiftを利用したMVVMパターンやUICollectionViewをフル活用した形の実装に触れる機会が多いということもあったので、慣れ親しんだ実装方法をヒントとして比較しながら実装や検証を進めていくことで、具体的な動きのイメージや構築の流れが掴めてくる実感があったように感じています。
僕自身もiOS13以降から登場した新機能については、まだまだキャッチアップや動作検証が行き届いていない部分も多々あると思いますが、少しでも皆様の参考になれば幸いに思います。