6
3

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.

DeNA 21新卒×22新卒内定者Advent Calendar 2021

Day 10

DiffableDataSouceとCompositionalLayoutについて

Last updated at Posted at 2021-12-10

こんにちはmoriです。
自分の働いているスタートアップのサービスでは立ち上げ時にiOS13以上をDeploymentTargetにしていたのもあり、DiffableDataSourceとCompositionalLayoutを全面的に採用しました。
導入から1年近く経ったので今回の記事では実際に使ってみた感想や使用法について書いてみます。

DiffableDataSourceについて

iOS13から導入されたUITableView/UICollectionViewのDataSourceです。
DiffableDataSourceはSectionとItemの2つの型を必要とします。

@MainActor class UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable

前者はSectionの識別に使うもので後者は実際にセルに表示したいアイテムを指します。
どちらもHashableに準拠していればよく、自分のチームでは慣れ親しんでいたRxDataSourcesに合わせて以下のような形で定義していました。

enum Section {
  case images
}
enum CellItem: Hashable {
  case image(String)
}

##基本的な使い方
まずはDataSourceを作ってみます。
上記のモデルを使用して宣言すると以下のような形になります。
CellItemのswitchでcellのReuseをして返してあげるだけですね。
Optionalは許されていますがnilを返すとクラッシュするのでforce wrapが減ったくらいに留めておきましょう。

private lazy var dataSource: UICollectionViewDiffableDataSource<Section, CellItem> = {
    .init(
        collectionView: collectionView
    ) {[unowned self] collectionView, indexPath, cellItem -> UICollectionViewCell? in
        switch cellItem {
        case let .image(image):
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath)
            cell.configure(image)
            return cell
        }
    }
}()

アイテムを追加したい時はまずNSDiffableDataSourceSnapshotに追加してapplyします。
この際にSectionの追加を忘れると追加する対象が分からなくてクラッシュするので注意しましょう。

var snapshot = NSDiffableDataSourceSnapshot<Section, CellItem>()
snapshot.appendSections([.images])
snapshot.appendItems(
    [
        "https://hogehoge1",
        "https://hogehoge2",
        "https://hogehoge3"
    ]
        .map(CellItem.image),
    toSection: .main
)
dataSource.apply(snapshot)

基本的には以上の2つだけなのでとっても簡単です。
applyについて少し説明するとDataSource.apply(snapshot: , animatingDifferences: Bool = true, completion: (() -> Void)? = nil)でcompletionがあります。
これはsnapshotを適応してアニメーションが終了した際に呼ばれるクロージャで、更新後に走らせたい処理をより明確に書けるようになりました。
例えばlayoutSubViewsでセルの数の変化を検知して処理を走らせるような場面の置き換えができます。

animationDifferenceについてはapply時のアニメーションの有無です。
基本的にはデフォルトのtrueでいいと思いますが、CollectionView in CollectionViewCellの場合などでapplyの頻度が多い場合はfalseにしておくことでパフォーマンスを改善することができます。

ユニークの評価について

DiffableDataSourceではその名の通り差分更新を行うため渡すアイテムはユニークである必要があります。
ユニークで無い場合は下記のエラーで怒られます。
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Fatal: supplied item identifiers are not unique. Duplicate identifiers

そもそもDiffableを使う必要があるのかという話ではありますが、画像などのユニークでないアイテムを渡したい時はどうするかというと
Appleは下記のような形でUUIDを持たせることで解決しています。

struct Image: Hashable {
  var value: String
  private var uuid = UUID().uuidString
}
var snapshot = NSDiffableDataSourceSnapshot<Section, CellItem>()
snapshot.appendSections([.main])
snapshot.appendItems(
  [
    "hogehoge",
    "hogehoge",
    "hogehoge"
  ]
  .map(Image.init)
  .map(CellItem.uniquedImage)
  ,
  toSection: .main
)
dataSource.apply(snapshot)

ここでふとHashableであるならプロパティでUUIDを持たずともhash(into: Hasher)でUUIDを渡せばいいのでは?と思い下記のように変更してみました。

struct Image: Hashable {
  var value: String
  func hash(into hasher: inout Hasher) {
    hasher.combine(UUID().uuidString)
    hasher.combine(image)
  }
}

結果としては先ほどと同じエラーが吐かれてしまいます。
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Fatal: supplied item identifiers are not unique. Duplicate identifiers
不思議ですね、hashValueの値はきちんとユニークになっています。

-7942012077661377080
-6205962773686266410
-5034117499723952161

HashableはEquatableを継承したprotocolであるので今度は==で同一にならないようにしてみました

struct Image: Hashable {
  var value: String
  static func == (lhs: Image, rhs: Image) -> Bool {
    false
  }
}

この場合はエラーは吐かれませんでした。
ということでユニークの判定はhashValueではなく==で行なっているみたいです。
推測にはなりますがSwift5.1でEquatableなコレクションに対して実装された差分抽出を使用してそうですね。

CompositionalLayoutについて

CompositionalLayoutはDiffableDataSourceと同じくiOS13で追加されたCollectionViewのレイアウトです。

Cellに相当するNSCollectionLayoutItem, 複数のCellを一塊とした時のNSCollectionLayoutGroup,
そしてNSCollectionLayoutSectionという3つの単位で構成されていて、それぞれのパーツを組み合わせていくことで様々なレイアウトが可能になります。

またNSCollectionLayoutSection毎にスクロール方向が切り替え可能で途中で横スクロールに変わるといった従来ならCollectionView in CollectionViewしないといけない場面も1つのCollectionViewだけで実装できるようになっています。

ItemとGroupのlayoutSizeで使用するwidthDimension,heightDimensionは以下の4つが指定できます

  • fractionalWidth(CGFloat): Groupの横幅に対しての比
  • fractionalHeight(CGFloat): Groupの縦幅に対しての比
  • absolute(CGFloat): 数値通りの大きさ
  • estimated(CGFloat): autoLayoutのcontentSize

またUICompositionalLayoutのinitは4種類あります。

  • init(section: NSCollectionLayoutSection)
    • どのSectionでも渡したLayoutSection通りのレイアウトになります
  • init(section: NSCollectionLayoutSection, configuration: UICollectionViewCompositionalLayoutConfiguration)
    • 上記に加えてConfigurationを渡せます。 Configurationではデフォルトのスクロール方向、セクション間のinsets、headerの設定が可能です
  • init(sectionProvider: UICollectionViewCompositionalLayoutSectionProvider)
    • (index: Int, environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection?のクロージャでレイアウトを返します。
    • 前者のindexはSectionの番号が渡されて、後者のEnvironmentはダークモードや画面スケールを取得することが可能です。
    • Optionalが許容されていますがnilを返すとクラッシュします。気をつけましょう
  • init(sectionProvider: UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)
    • 上記に加えてConfigurationを渡せます

言葉で書いていてもGroupなどの概念も含めてなかなか想像ができないと思うので簡単な例をコードで示してみます。

##レイアウトの作成

  • 4列グリッド
let item = NSCollectionLayoutItem(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1/4),
        heightDimension: .fractionalHeight(1)
    )
)
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .fractionalWidth(1/4)
    ),
    subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)

図解すると以下のような感じになります

  • 縦方向にestimatedSize
let item = NSCollectionLayoutItem(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .estimated(50)
    )
)
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .estimated(50)
    ),
    subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)

CompositionalLayoutでのestimatedの良い点はestimatedの方向を決められる点です。
従来ならCell側でUIScreen.main.boundsを使って親のCollectionViewがスクリーンの大きさと同じであることを強制したり、外部からCollectionViewのwidthやheightを渡すといった作業が必要になっていました。
その点、CompositionalLayoutではレイアウトを組む際にCell伸びる方向が指定できるので上記のような部分がいらなくなります。

ここまでのレイアウトはFlowLayoutなどでも特に苦労することなく実現できます。
次はFlowLayoutでは少し面倒なタグクラウドのようなレイアウトをやってみたいと思います

let item = NSCollectionLayoutItem(
    layoutSize: .init(
        widthDimension: .estimated(24),
        heightDimension: .absolute(16)
    )
)
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .absolute(16)
    ),
    subitems: [item]
)
group.interItemSpacing = .fixed(4)
let section = NSCollectionLayoutSection(group: group)
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)

たったこれだけで実現できました。従来だと左詰めの為だけに継承したレイアウトを作成しないといけない部分でしたが難なく実現できます。

ここまで複数のレイアウトを紹介しましたが基本となるのはスクロール方向に直交するGroupにItemを詰めていくように組んでいくことです。
もしグリッドのレイアウトでGroup.Verticalを使うと以下のようになります。

Itemは最初のGroupから順に入っていくのでIndexPathが見た目と大きくズレてしまうことや縦のGroupに入るItemの個数をViewModelなどに保持したデータから計算しないといけません。

複数のレイアウト例でFlowLayoutより簡単にさまざまなレイアウトが実現できることをお見せできたと思いますが、重要なのはこれらが簡単に再利用できることです。
最初の方に紹介したUICollectionViewCompositionalLayout.initの中にSectionProviderというのがあります。
これは以下のようにSectionのナンバーで分岐させることができるので
ある画面では estimated -> grid -> hashtag, 別の画面では grid -> estimated というような形のレイアウトにしたい場合でもNSCollectionLayoutSectionを作り出すメソッドを切り離しておけば使い回すだけで実現することができます。

例えば紹介した3つのレイアウトはセルの数などを参照しないのでLayoutFactoryのような形で保持してあげれば以下のように使いまわせますね。

final class LayoutFactory {
    static func createGrid(raw: Int) -> NSCollectionLayoutSection {}
    static func createEstimatedHight() -> NSCollectionLayoutSection {}
    static func createTagCloud() -> NSCollectionLayoutSection {}
}
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout { sectionIndex, environment -> NSCollectionLayoutSection? in
    switch sectionIndex {
    case 0:
       return LayoutFactory.createGrid(raw: 4)
    case 1:
       return LayoutFactory.createEstimatedHight()
    case 2:
       return LayoutFactory.createTagCloud()
    default:
        return nil
    }
}

より複雑なレイアウトの作成

更に複雑なレイアウトを実現してみます。
例えばこのようなそれぞれのCellの高さが違うようなレイアウトを作成したいときはどうしたらいいでしょう

試しに以下のようなレイアウトを組んでみましょう

var items: [NSCollectionLayoutItem] = []
for index in 0..<6 {
    let item = NSCollectionLayoutItem(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1/3),
            heightDimension: .fractionalHeight(
                .init([0.3, 0.6, 0.9].randomElement() ?? 0.3)
            )
        )
    )
    items.append(item)
}
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .absolute(160)
    ),
    subitems: items
)
let section = NSCollectionLayoutSection(group: group)
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)

groupに対して6個のランダムな比率のitemを追加するレイアウトですね
実行してみると

残念ながら思ってたのとは違う結果になりました。
これはHorizontalなGroupはそれぞれのItemの高さに合わせられないため、以下のようなレイアウトになっているからです。

となると高さがバラバラなのは実現できないのかというと実現できます。
NSCollectionLayoutGroupはItemだけではなくGroupからも生成することができるのでそれぞれの列でVerticalなGroupを作成することで実現が可能です。

ということで以下のように縦方向のGroupを3つ並べるように手直ししてみると

var groups: [NSCollectionLayoutGroup] = []
for _ in 0..<3 {
    let itemHeight = [0.3, 0.5, 0.7].randomElement() ?? 0.3
    let firstItem = NSCollectionLayoutItem(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1),
            heightDimension: .fractionalHeight(itemHeight)
        )
    )
    let secondItem = NSCollectionLayoutItem(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1),
            heightDimension: .fractionalHeight(1-itemHeight)
        )
    )
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1/3),
            heightDimension: .fractionalHeight(1)
        ),
        subitems: [firstItem, secondItem]
    )
    groups.append(group)
}
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .absolute(320)
    ),
    subitems: groups
)

空白が開かずにちゃんと詰められましたね。
GroupからGroupを作るのは複雑なレイアウトを達成する上で必須のテクニックになっているのでなんとなく記憶の片隅に留めておくと便利かもしれません。

#終わりに
ここまでCompositionalLayoutの良い部分や複雑なレイアウトの実現方法を紹介しましたが運用していく中では微妙な点もありました。
例えば、didEndScrollが取れなかったりスクロール量の変化といった部分に関しては独自のUICollectionViewLayuoutの方が優れている側面もあります。
また切り出しておくことで対処はできますが、どうしても記述量が増えてしまうのでグリッドのような簡単なレイアウトに関しては逆に面倒に感じる場面もありました。

iOS13以降ではSwiftUIが注目されがちですが、まだ知見が浅く今まで通りの開発スピードを出せるかどうか不安な時は橋渡しとして進化したUIKitを活用してみるのもいいと思います。

6
3
0

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
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?