フューチャー技術ブログ

Swift Compositional Layouts入門:複雑なCollectionViewをシンプルに実装する

春の入門祭りの第13弾です。

はじめに

TIG メディアユニットの福谷(ふくや)です。

お仕事では主にサーバーサイド領域で開発していますが、趣味でiOSアプリを開発しており、春の入門祭りの社内アナウンスがあったので書いてみようと思います。

iOSアプリで記事や写真などを一覧表示させたい場合、必ずと言っていいほどCollectionView(あるいはTableView)が採用されると思います。

iPhoneが発売された当初のデザインは、縦にコンテンツが並ぶだけのレイアウトでしたが、昨今はコンテンツの一覧性・視認性をより高めるために、縦にも横にもスクロールできるCollectionViewが一般的になってきています。

Compositional Layouts

そこでCompositional Layoutsの登場です。

Compositional LayoutsはWWDC2019に発表された複雑なレイアウトをシンプルに実装するための考え方です。CollectionViewにおいてはUICollectionViewCompositionalLayoutクラス 6を利用します。

Compositional Layoutsの詳しい解説はWWDC2019の動画 2を見るか、それを元に解説した記事 3もあるのでそちらを参照してください。また公式サンプルコード 7もかなり参考になるためおすすめです。

※公式ではiOS13でサポートされていますが、iOS13以前でも利用可能にするためのライブラリ 1がでています。

アプリを作る

以下のようなFuture Tech Blogリーダーを作ってみようと思います。

ソース全量はこちら
※以降の解説はCompositional Layoutsの実装部分に焦点を当てて解説していきます。

item・group・sectionの構成を決める

Compositional Layoutは item・group・section、そしてsectionを内包するlayoutにより構成されます。

今回作るアプリのUIを例に、item・group・sectionをどう構成するかについて示したのが下記の画像です。

それではLayoutを書いていきましょう。

Compositional Layoutで実装する

まず大枠のSectionから書いていきます。

func createLayout() -> UICollectionViewLayout {
let sectionProvider = { (sectionIndex: Int,
layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = sectionKind.scrollingBehavior()

//①
section.interGroupSpacing = 10
//②
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 15, bottom: 0, trailing: 15)

// section headerの定義
let sectionHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: sectionHeaderSize, elementKind: "header", alignment: .top)
section.boundarySupplementaryItems = [sectionHeader]

return section
}

let config = UICollectionViewCompositionalLayoutConfiguration()
//③
config.interSectionSpacing = 30

let layout = UICollectionViewCompositionalLayout(sectionProvider: sectionProvider, configuration: config)
return layout
}

sectionheaderSizewidthDimensionに引数として渡しているfractionalWidth(1.0)は、sectionの横幅と同じ比率でheaderの横幅を定義することを意味します。 4

また、heightDimensionに引数として渡しているestimated(44)は、44で高さを指定するものの最終的なレイアウトはレンダリング時に決定します(=「弱い定義」と勝手に呼んでいます)。 5


ソースコード中の余白定義①②③はそれぞれUI上の下記のポイントに対応しています。

続いてitem・groupのレイアウトを定義していきます。

func createLayout() -> UICollectionViewLayout {
let sectionProvider = { (sectionIndex: Int,
layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
// セクションのenum
guard let sectionKind = SectionLayoutKind(rawValue: sectionIndex) else { fatalError("unknown section kind") }

// itemの定義
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)

// groupの定義
let groupWidth = layoutEnvironment.container.effectiveContentSize.width - 15 * 2 - 5
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth),
heightDimension: .absolute(150))
let group: NSCollectionLayoutGroup
if sectionKind == .recommend {
group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)
} else {
group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 2)
}
group.interItemSpacing = NSCollectionLayoutSpacing.fixed(10)

// sectionの定義
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = sectionKind.scrollingBehavior()
section.interGroupSpacing = 10
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 15, bottom: 0, trailing: 15)

let sectionHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: sectionHeaderSize, elementKind: "header", alignment: .top)
section.boundarySupplementaryItems = [sectionHeader]
return section
}

let config = UICollectionViewCompositionalLayoutConfiguration()
config.interSectionSpacing = 30

let layout = UICollectionViewCompositionalLayout(sectionProvider: sectionProvider, configuration: config)
return layout
}

SectionLayoutKind

SectionLayoutKindはアプリ中のおすすめ春の入門祭りなどのセクションをenumで定義したもので、
下記の通り定義しています。

enum SectionLayoutKind: Int {
case recommend, springEntry, goTips, serverless
func scrollingBehavior() -> UICollectionLayoutSectionOrthogonalScrollingBehavior {
switch self {
case .recommend:
return .continuous
default:
return .groupPaging
}
}
}

もし固定的にセクションを管理するならenumで定義しておくのが良いと思います。

一方でenumで定義するとenum内でセクションごとのレイアウト情報をいろいろ管理したくなりますが、createLayout()内にもレイアウト定義をしているので、見通しを良くするためにも最低限のレイアウト情報のみenum内で定義すべきだと思います。(この場合水平スクロールの挙動)

groupのレイアウト定義

let group: NSCollectionLayoutGroup
if sectionKind == .recommend {
// ①おすすめセクションのgroup定義
group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)
} else {
// ②おすすめセクション以外ののgroup定義
group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 2)
}
// item間の空白の定義
group.interItemSpacing = NSCollectionLayoutSpacing.fixed(10)

②おすすめセクション以外ののgroup定義

NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 2)
verticalcountを”2”にすることによって垂直にitemを2つスタックしています。

①おすすめセクションのgroup定義

おすすめの記事は画像を大きくして目立たせたいので水平にitemを2つスタックしています。
NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)

groupSizeの定義

let groupWidth = layoutEnvironment.container.effectiveContentSize.width - 15 * 2
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(150))

layoutEnvironment.container.effectiveContentSizeはcollectionViewの描画領域を意味しています。今回は対応していませんが、例えばスマホの向きによってアプリのレイアウトを変えたい場合はこの値を使って分岐処理を書けば対応できます。

おわりに

以上が今回のアプリのレイアウト部分に関する解説になります。コメントを除いて38行で書けました。

同じレイアウトをUICollectionViewLayoutのカスタムクラスで実現しようとしたらかなりの行数・難易度になるのではないでしょうか。item・group・sectionの理解さえすれば私のような初学者でも簡易かつシンプルに実装できるので、今後のiOSアプリ開発でどんどん採用されていくのではと思います。

その際の参考になれば幸いです!