269
177

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.

時代の変化に応じて進化するCollectionView ~Compositional LayoutsとDiffable Data Sources~

Last updated at Posted at 2019-06-16
  • 2019/8/7 追記 iOS12以前でもUICollectionViewCompositionalLayoutが使用できるようにするライブラリについて追記しました
  • 2019/8/21 追記 Xcode11 Beta6でNSDiffableDataSourceSnapshotのメソッドがmutatingになったので変数をletからvarに変更しました。

iOS14のアップデートについてはこちら
https://qiita.com/shiz/items/4227accc7d13ae439d1d

WWDC2019ではSwiftUIの登場に注目が浴びていますが
UICollectionView(UITableViewなども含まれる)にも
すごく大きな変化がありました。

iOS13以降ではこのスタイルが主流になるのではないかとも感じており
この機会にまずは何が変わったのかについて
LayoutData Sourceという2つの観点から調べてみました。

Advances in Collection View Layout
https://developer.apple.com/videos/play/wwdc2019/215

Advances in UI Data Sources
https://developer.apple.com/videos/play/wwdc2019/220/

サンプル
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/using_collection_view_compositional_layouts_and_diffable_data_sources


今回はCollectionViewの話を中心にしていますが
TableViewも同じように機能が追加されています。

背景としての時代の変化

これまでのアプリは
今のアプリに比べて表示できる内容に限りがあり
デザインなどももう少しシンプルなものが多くありました。

しかし
ハードウェアの機能やネットワーク通信速度の向上などに合わせて
アプリの構造もどんどん複雑になってきています。

そんな中でCollectionViewで表示する内容もより複雑になり
これまでのAPIでは実装が難しい場面も増えてきました。

UICollectionViewFlowLayout

iOS6で導入されたUICollectionViewFlowLayout
非常にシンプルなUICollectionViewLayoutのサブクラスです。

画一的なレイアウトを構築することは得意ですが
今日のアプリではより複雑なレイアウトを実現するのは
かなり大変です。

例としてApp Storeが出ていました。

スクリーンショット 2019-06-15 17.38.29.png

※ iOS6の頃は下記のような感じでした。
image.png

そういう場合にはUICollectionViewLayoutを継承して
カスタムレイアウトを構築していきます。

ここに関しては
WWDC2018のセッションでも紹介されています。
https://developer.apple.com/videos/play/wwdc2018/225/

過去の記事で内容をまとめさせていただきました。
https://qiita.com/shiz/items/2e9771fe56e39544ff3e

しかし

  • 大量のボイラープレートが生まれる
  • パフォーマンスに配慮する必要がある
  • フッターやヘッダーなどの付加的なViewの実装が難しい
  • サイズの計算が難しい

など多くの問題に立ち向かう必要があります。

スクリーンショット 2019-06-15 17.44.49.png

Compositional Layoutsの登場

そこで今回登場したのが
Compositional Layoutsです。

特徴としては

  • 小さいLayoutをGroup化して構成していく
  • LayoutのそれぞれのGroupでひとまとまりの要素を構成する
  • サブクラスよりも小さいLayoutを組み合わせて使用する

などがあります。

4つのメインの構成要素

下記の4つの要素から主に構成されます。

  • Item
  • Group
  • Section
  • Layout

Item, Group, Section, Layoutは階層構造を構築します。

スクリーンショット 2019-06-15 18.13.34.png

これらの大きさを決定するのがSizeです。

NSCollectionLayoutSize

全てのレイアウトを構成する要素は明示的なサイズを持っています。
それを表すのがNSCollectionLayoutSizeになります。

NSCollectionLayoutSizeは
widthとheight2つのDimensionから構成されます。

widthDimensionとheightDimensionは別々に設定することができ
4つのメソッドが用意されています。


class NSCollectionLayoutDimension {
    class func fractionalWidth(_ fractionalWidth: CGFloat) -> Self
    class func fractionalHeight(_ fractionalHeight: CGFloat) -> Self
    class func absolute(_ absoluteDimension: CGFloat) -> Self
    class func estimated(_ estimatedDimension: CGFloat) -> Self
}

fractionalWidth, fractionalHeight

これはサイズを指定したいレイアウトの
コンテナのサイズに対する割合になります。

fractionalWidth
https://developer.apple.com/documentation/uikit/nscollectionlayoutdimension/3199059-fractionalwidth

fractionalHeight
https://developer.apple.com/documentation/uikit/nscollectionlayoutdimension/3199058-fractionalheight

スクリーンショット 2019-06-15 18.11.12.png

上記の例ではコンテナのwidthの半分の大きさを表しています。
高さも同様に設定が可能です。

スクリーンショット 2019-06-15 18.16.42.png

上記の例ではwidthとheightの両方にコンテナのwidthの0.25をしています。
そのためwidthとheightの大きさは同じになりaspectRatioは1です。

absolute, estimated

上記では割合で指定をしていましたが
この2つのメソッドはポイントで値を指定します。

absolute
https://developer.apple.com/documentation/uikit/nscollectionlayoutdimension/3199055-absolute

estimated
https://developer.apple.com/documentation/uikit/nscollectionlayoutdimension/3199057-estimated

スクリーンショット 2019-06-15 18.19.42.png

absoluteは名前の通り指定した値にサイズを固定します。

スクリーンショット 2019-06-15 18.21.03.png

estimateは値の指定はするものの
優先される制約がある場合は値は変化します。

NSCollectionLayoutItem

レイアウトを構成するための一番小さい部品です。
初期化時にサイズを指定する必要があります。

ItemはCellやヘッダー、フッターになります。

スクリーンショット 2019-06-16 4.10.32.png

NSCollectionLayoutItem
https://developer.apple.com/documentation/uikit/nscollectionlayoutitem

NSCollectionLayoutSupplementaryItem
https://developer.apple.com/documentation/uikit/nscollectionlayoutsupplementaryitem

NSCollectionLayoutGroup

Itemをまとめてレイアウトの基本単位を構成します。
一つのカラムや一行のアイテム郡を表します。

さらに
クロージャを使ったイニシャライザでは
アイテムごとにレイアウトを構築することもでき
これまでよりもより細かい単位で
レイアウトを構築することができるようになりました。

スクリーンショット 2019-06-16 4.10.42.png

NSCollectionLayoutGroup
https://developer.apple.com/documentation/uikit/nscollectionlayoutgroup

NSCollectionLayoutSection

いわゆるセクションを表すクラスで
Groupを初期化時に設定しそこにさらにInsetsなどを追加できます。

スクリーンショット 2019-06-16 4.12.41.png

NSCollectionLayoutSection
https://developer.apple.com/documentation/uikit/nscollectionlayoutsection

UICollectionViewCompositionalLayout, NSCollectionViewCompositionalLayout

最終的にレンダリングされる単位になります。
セクションクラスを引数に渡して初期化することもできます。
さらにグループと同様に
クロージャ内でセクション単位でレイアウトの設定ができます。

スクリーンショット 2019-06-16 4.22.11.png

UICollectionViewCompositionalLayout
https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout

NSCollectionViewCompositionalLayout
https://developer.apple.com/documentation/appkit/nscollectionviewcompositionallayout

サンプルコード

ここからはサンプルのコードを見ながら
Compositional Layoutsの詳細を見ていきたいと思います。

ListViewController

シンプルなリストのレイアウトです。

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 04.31.22.png

実際の設定方法は下記のようになります。


private func createLayout() -> UICollectionViewLayout {
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                          heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: .absolute(44))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                   subitems: [item])
    
    let section = NSCollectionLayoutSection(group: group)
    
    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}

GridViewController

次にGridのレイアウトが登場します。

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 04.30.44.png


extension GridViewController {
    private func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                             heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        // add for each item allocate same box
        item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: .fractionalWidth(0.2))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                         subitems: [item])

        let section = NSCollectionLayoutSection(group: group)

        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
}

ポイントとしては


let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                      heightDimension: .fractionalHeight(1.0))

で個々のItemのWidthをGroupのWidthの1/5のサイズに設定し


let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: .fractionalWidth(0.2))

でGroupのheightをセクションのWidthの1/5のサイズに設定することで
正方形のGridを構成しています。

また


item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)

を設定することで個々の正方形の外側にスペースを作っています。

TwoColumnViewController

2つのセルで1行を構成します。

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 04.40.44.png


extension TwoColumnViewController {
    func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .absolute(44))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)
        let spacing = CGFloat(10)
        group.interItemSpacing = .fixed(spacing)

        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = spacing
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)

        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
}

ポイントとしては


let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalHeight(1.0))

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)

個々のItemはGroupの100%に設定しているのに対して
countを2に設定することで
1行に表示されるカラムの数を2に制限しています。

また


let spacing = CGFloat(10)
group.interItemSpacing = .fixed(spacing)

で個々のカラムの外側にスペースを設定し


section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)

で画面の両端にスペースを作っています。

ここで使用されているNSDirectionalEdgeInsets
言語の方向に応じて(左から右か右から左か)自動にレイアウトを調整してくれます。

NSDirectionalEdgeInsets
https://developer.apple.com/documentation/uikit/nsdirectionaledgeinsets

DistinctSectionsViewController

セクション単位でレイアウトを変えています。

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 04.55.02.png


extension DistinctSectionsViewController {
    func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int,
            layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

            guard let sectionLayoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }
            let columns = sectionLayoutKind.columnCount

            // The `group` auto-calculates the actual item width to make
            // the requested number of `columns` fit, so this `widthDimension` will be ignored.
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

            let groupHeight = columns == 1 ?
                NSCollectionLayoutDimension.absolute(44) :
                NSCollectionLayoutDimension.fractionalWidth(0.2)
            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                   heightDimension: groupHeight)
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columns)

            let section = NSCollectionLayoutSection(group: group)
            section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
            return section
        }
        return layout
    }
}

ポイントは
クロージャの中でレイアウトの構成を設定しています。

セクションのごとにカラム数を変化させています。


class DistinctSectionsViewController: UIViewController {

    enum SectionLayoutKind: Int, CaseIterable {
        case list, grid5, grid3
        var columnCount: Int {
            switch self {
            case .grid3:
                return 3

            case .grid5:
                return 5

            case .list:
                return 1
            }
        }
    }
}

Groupはcountを設定すると
Groupに指定したサイズは無視され
count に合わせて自動でサイズを調整します。


let groupHeight = columns == 1 ?
    NSCollectionLayoutDimension.absolute(44) :
    NSCollectionLayoutDimension.fractionalWidth(0.2)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: groupHeight)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columns)

AdaptiveSectionsViewController

上記の例と似ていますが
画面を回転した際のレイアウトを変化させています。

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 05.03.14.png


extension AdaptiveSectionsViewController {
    func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            guard let layoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }

            let columns = layoutKind.columnCount(for: layoutEnvironment.container.effectiveContentSize.width)

            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                                 heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

            let groupHeight = layoutKind == .list ?
                NSCollectionLayoutDimension.absolute(44) : NSCollectionLayoutDimension.fractionalWidth(0.2)
            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                   heightDimension: groupHeight)
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columns)

            let section = NSCollectionLayoutSection(group: group)
            section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
            return section
        }
        return layout
    }
}

これを実現するために
横幅に応じで1行に表示するカラム数を変えています。


class AdaptiveSectionsViewController: UIViewController {

    enum SectionLayoutKind: Int, CaseIterable {
        case list, grid5, grid3
        func columnCount(for width: CGFloat) -> Int {
            let wideMode = width > 800
            switch self {
            case .grid3:
                return wideMode ? 6 : 3

            case .grid5:
                return wideMode ? 10 : 5

            case .list:
                return wideMode ? 2 : 1
            }
        }
    }
}

バッジ、ヘッダー、フッタ-などの付加的な要素の設定

次にメインのカラム以外の付加的な要素の設定について見ていきます。

ItemBadgeSupplementaryViewController

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 05.20.03.png

NSCollectionLayoutSupplementaryItemを使用して
Anchorを変えることで位置をシンプルに設定することができます。

NSCollectionLayoutSupplementaryItem
https://developer.apple.com/documentation/uikit/nscollectionlayoutsupplementaryitem

NSCollectionLayoutAnchor
https://developer.apple.com/documentation/uikit/nscollectionlayoutanchor

スクリーンショット 2019-06-16 5.14.01.png

extension ItemBadgeSupplementaryViewController {
    func createLayout() -> UICollectionViewLayout {
        let badgeAnchor = NSCollectionLayoutAnchor(edges: [.top, .trailing], fractionalOffset: CGPoint(x: 0.3, y: -0.3))
        let badgeSize = NSCollectionLayoutSize(widthDimension: .absolute(20),
                                              heightDimension: .absolute(20))
        let badge = NSCollectionLayoutSupplementaryItem(
            layoutSize: badgeSize,
            elementKind: ItemBadgeSupplementaryViewController.badgeElementKind,
            containerAnchor: badgeAnchor)

        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.25),
                                              heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [badge])
        item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .fractionalWidth(0.2))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)

        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
}

下記のようにバッジを設定します。


let badgeAnchor = NSCollectionLayoutAnchor(edges: [.top, .trailing], fractionalOffset: CGPoint(x: 0.3, y: -0.3))
let badgeSize = NSCollectionLayoutSize(widthDimension: .absolute(20),
                                       heightDimension: .absolute(20))
let badge = NSCollectionLayoutSupplementaryItem(
    layoutSize: badgeSize,
    elementKind: ItemBadgeSupplementaryViewController.badgeElementKind,
    containerAnchor: badgeAnchor)


let item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [badge])

NSCollectionLayoutSupplementaryItemelementKindには
特定の文字列を指定します。


static let badgeElementKind = "badge-element-kind"

SectionHeadersFootersViewController

ヘッダーとフッターを設定するには
NSCollectionLayoutBoundarySupplementaryItemを使用します。

NSCollectionLayoutBoundarySupplementaryItem
https://developer.apple.com/documentation/uikit/nscollectionlayoutboundarysupplementaryitem

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 05.20.59.png


extension SectionHeadersFootersViewController {
    func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                             heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: .absolute(44))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 5
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)

        let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                     heightDimension: .estimated(44))
        let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: headerFooterSize,
            elementKind: SectionHeadersFootersViewController.sectionHeaderElementKind, alignment: .top)
        let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: headerFooterSize,
            elementKind: SectionHeadersFootersViewController.sectionFooterElementKind, alignment: .bottom)
        section.boundarySupplementaryItems = [sectionHeader, sectionFooter]

        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
}

SectionのboundarySupplementaryItemsに
ヘッダーとフッターを設定します。


let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: headerFooterSize,
    elementKind: SectionHeadersFootersViewController.sectionHeaderElementKind, alignment: .top)
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: headerFooterSize,
    elementKind: SectionHeadersFootersViewController.sectionFooterElementKind, alignment: .bottom)
section.boundarySupplementaryItems = [sectionHeader, sectionFooter]

こちらも特定の文字列を指定します。


static let sectionHeaderElementKind = "section-header-element-kind"
static let sectionFooterElementKind = "section-footer-element-kind"

SectionDecorationViewController

セクション単位に背景の設定などのカスタマイズをすることができます。

NSCollectionLayoutDecorationItemを使用します。

NSCollectionLayoutDecorationItem
https://developer.apple.com/documentation/uikit/nscollectionlayoutdecorationitem

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 05.30.45.png


extension SectionDecorationViewController {
    func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .absolute(44))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 5
        section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

        let sectionBackgroundDecoration = NSCollectionLayoutDecorationItem.background(
            elementKind: SectionDecorationViewController.sectionBackgroundDecorationElementKind)
        sectionBackgroundDecoration.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
        section.decorationItems = [sectionBackgroundDecoration]

        let layout = UICollectionViewCompositionalLayout(section: section)
        layout.register(
            SectionBackgroundDecorationView.self,
            forDecorationViewOfKind: SectionDecorationViewController.sectionBackgroundDecorationElementKind)
        return layout
    }
}

NSCollectionLayoutDecorationItem
Layoutにregisterすることで設定が可能です。


let sectionBackgroundDecoration = NSCollectionLayoutDecorationItem.background(
    elementKind: SectionDecorationViewController.sectionBackgroundDecorationElementKind)
sectionBackgroundDecoration.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
section.decorationItems = [sectionBackgroundDecoration]

let layout = UICollectionViewCompositionalLayout(section: section)
layout.register(
    SectionBackgroundDecorationView.self,
    forDecorationViewOfKind: SectionDecorationViewController.sectionBackgroundDecorationElementKind)

今回の例では背景色を付けています。


extension SectionBackgroundDecorationView {
    func configure() {
        backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
        layer.borderColor = UIColor.black.cgColor
        layer.borderWidth = 1
        layer.cornerRadius = 12
    }
}

ここでも特定の文字列を指定します。


static let sectionBackgroundDecorationElementKind = "section-background-element-kind"

PinnedSectionHeaderFooterViewController

上記の例に似ていますが
ヘッダーが上部に固定されるようになっています。

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 05.25.01.png


extension PinnedSectionHeaderFooterViewController {
    func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .absolute(44))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 5
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)

        let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .estimated(44)),
            elementKind: PinnedSectionHeaderFooterViewController.sectionHeaderElementKind,
            alignment: .top)
        let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .estimated(44)),
            elementKind: PinnedSectionHeaderFooterViewController.sectionFooterElementKind,
            alignment: .bottom)
        sectionHeader.pinToVisibleBounds = true
        sectionHeader.zIndex = 2
        section.boundarySupplementaryItems = [sectionHeader, sectionFooter]

        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
}

ポイントは


sectionHeader.pinToVisibleBounds = true

を設定するだけでヘッダーを固定させることができます。

ネストしたGroup

ここからはさらに複雑なレイアウトの設定方法について見ていきます。

NestedGroupsViewController

1行の中に2つの異なるレイアウトを設定しています。

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 05.40.46.png


extension NestedGroupsViewController {
   func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

            let leadingItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                                   heightDimension: .fractionalHeight(1.0)))
            leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

            let trailingItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                   heightDimension: .fractionalHeight(0.3)))
            trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            let trailingGroup = NSCollectionLayoutGroup.vertical(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                                   heightDimension: .fractionalHeight(1.0)),
                subitem: trailingItem, count: 2)

            let nestedGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                   heightDimension: .fractionalHeight(0.4)),
                subitems: [leadingItem, trailingGroup])
            let section = NSCollectionLayoutSection(group: nestedGroup)
            return section

        }
        return layout
    }
}

Groupの中にGroupを設定することで
実現することができます。


let nestedGroup = NSCollectionLayoutGroup.horizontal(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: .fractionalHeight(0.4)),
    subitems: [leadingItem, trailingGroup])

OrthogonalScrollingViewController

上記の例と似ていますが
個々のネストしているGroupの中で
横へのスクロールをできるようにしています。

UICollectionLayoutSectionOrthogonalScrollingBehavior を使用します。

UICollectionLayoutSectionOrthogonalScrollingBehavior
https://developer.apple.com/documentation/uikit/uicollectionlayoutsectionorthogonalscrollingbehavior

※ 実際の動きはサンプルをご参照ください。

Simulator Screen Shot - iPhone Xʀ - 2019-06-16 at 05.46.02.png


extension OrthogonalScrollingViewController {
    func createLayout() -> UICollectionViewLayout {
        ...

        section.orthogonalScrollingBehavior = .continuous
        
        ...
    }
}

これまではこの動きを実現するのに
結構な量のコードを書く必要がありましたが
たった1行で可能になります。

サンプルでは他の設定の際の動きも見れますので
ぜひ一度試してみてください。

Compositional Layoutsまとめ

  • シンプルで統一的で宣言的なAPI
  • フレームワークに任せる部分が増え、実装がより簡単に
  • 小さな機能を組み合わせてレイアウトのカスタマイズが可能に
  • 小さな単位で開発することでUI開発のイテレーションをより短時間で可能に

Data Source

今使われているUICollectionViewDataSource
非常にシンプルな設計となっています。

ジェネリックであるため
どんな型からもCollectionViewを構築することが可能です。

UICollectionViewDataSource
https://developer.apple.com/documentation/uikit/uicollectionviewdatasource

これまでは1つの情報源から取得できる配列を元にして
CollectionViewを構築することが多く
現在のUICollectionViewDataSourceの形が大変有効でした。

しかし
時代の変化に応じて
複数のウェブサービスや端末内のデータベースなどから
情報を取得する必要が出てきたり
非同期通信を行う関係でタイミングも
バラバラに取得されるようになることが増えてきました。

セッションの中でも出てきていましたが

UIからデータソースへ必要な情報を要求し
セルをレンダリングする時にデータが設定されたセルを要求します。

スクリーンショット 2019-06-15 12.19.08.png

しかし
外部からのデータの取得を元にUIの更新を
Controller側から行おうとすると
ControllerからUIへデータの更新を通知する必要が出てくるため
Controllerの保持しているデータとUIの表示状態に解離が発生するリスクが生まれます。

そしてController内のデータとViewの表示状態の同期を取る必要が出てきます。

スクリーンショット 2019-06-15 12.21.49.png

WWDC2018のCollectionViewのセッションで扱っていたのが
まさにこの問題が生じるperformBatchUpdatesメソッドで
2つの更新の同期が取れていないとNSInternalInconsistencyExceptionという
一見すると何が起きているのかがわかりづらい問題が起きます。

performBatchUpdates
https://developer.apple.com/documentation/uikit/uicollectionview/1618045-performbatchupdates

スクリーンショット 2019-06-15 12.23.43.png

Diffable Data Sourceの登場

そこで登場してきたのがDiffable Data Sourceです。

UIの更新時にperformBatchUpdatesではなく
applyというメソッドを使うことで
フレームワーク内で不整合が生じないようにUIの更新をしてくれます。

スクリーンショット 2019-06-15 12.30.06.png

3つのDiffable Data Source

UICollectionViewDiffableDataSource
https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasourcereference

UITableViewDiffableDataSource
https://developer.apple.com/documentation/uikit/uitableviewdiffabledatasource

NSCollectionViewDiffableDataSourceReference
https://developer.apple.com/documentation/appkit/nscollectionviewdiffabledatasourcereference

NSDiffableDataSourceSnapshot

もう一つ
Diffable Data Sourceの中心的存在がSnapshot

**UIの状態を表す唯一の存在(Single Source of Truth)**で
これまで使用していたIndexPathを使わずに
関連したCollectionView内のセクションやアイテム間で一意となる
Identifierを使用します。

NSDiffableDataSourceSnapshot
https://developer.apple.com/documentation/uikit/nsdiffabledatasourcesnapshot

Diffable Data Sourcesの3ステップ

  1. 新しいSnapshot(UIの状態)を生成
  2. 更新したい情報の追加
  3. 現在のSnapshot(UIの状態)の適用(applyメソッドの呼び出し)

と考えるとイメージしやすいかもしれません。

スクリーンショット 2019-06-15 12.47.16.png

コードから動きを追っていく

ではここからは公式に提供されているサンプルから
実装の詳細を見てみたいと思います。

MountainsViewController(基本的な使い方)

最初の例は山の名前のリストで
検索窓にキーワードを入力してフィルターをかけます。

スクリーンショット 2019-06-15 14.32.13.png


extension MountainsViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        performQuery(with: searchText)
    }
}

ここで呼ばれているperformQueryでUIの更新を行っています。


extension MountainsViewController {

    func performQuery(with filter: String?) {
        let mountains = mountainsController.filteredMountains(with: filter).sorted { $0.name < $1.name }

        // 1. 新しいSnapshotの生成
        var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()

        // 2. 更新したい情報の追加
        snapshot.appendSections([.main])
        snapshot.appendItems(mountains)

        // 3. applyの呼び出し
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

たったこれだけでUIの更新が可能になります。

ではより詳細に見ていきます。


let snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()

Snapshotはジェネリクスとなっています。


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

指定するのはSectionIdentifierTypeItemIdentifierType
いずれもHashableに適合する必要があります。

今回のケースですと

SectionTypeにはSection


class MountainsViewController: UIViewController {
    enum Section: CaseIterable {
        case main
    }
}

SwiftのenumHashableに適合しています。

ItemIdentifierTypeにはMountainという
Hashableに適合したstructを使用しています。


class MountainsController {

    struct Mountain: Hashable {
        let name: String
        let height: Int
        let identifier = UUID()
        func hash(into hasher: inout Hasher) {
            hasher.combine(identifier)
        }
        static func == (lhs: Mountain, rhs: Mountain) -> Bool {
            return lhs.identifier == rhs.identifier
        }
        func contains(_ filter: String?) -> Bool {
            guard let filterText = filter else { return true }
            if filterText.isEmpty { return true }
            let lowercasedFilter = filterText.lowercased()
            return name.lowercased().contains(lowercasedFilter)
        }
    }
}

WWDCのセッションではSnapshotはハッシュ値を使って個々を識別していると言っています。
セクションやアイテムは一意になるようにハッシュ値を生成する必要があります。

デモでは下記のようなidentifierを使用しています。


let identifier = UUID()

そしてidentifierを使ってハッシュ値の生成や等価判定を行っています。


func hash(into hasher: inout Hasher) {
    hasher.combine(identifier)
}
static func == (lhs: Mountain, rhs: Mountain) -> Bool {
    return lhs.identifier == rhs.identifier
}

次にData Sourceの設定を行う部分を見ていきます。


extension MountainsViewController {
    func configureDataSource() {
        dataSource = UICollectionViewDiffableDataSource
            <Section, MountainsController.Mountain>(collectionView: mountainsCollectionView) {
                (collectionView: UICollectionView, indexPath: IndexPath,
                mountain: MountainsController.Mountain) -> UICollectionViewCell? in
            guard let mountainCell = collectionView.dequeueReusableCell(
                withReuseIdentifier: LabelCell.reuseIdentifier, for: indexPath) as? LabelCell else {
                    fatalError("Cannot create new cell") }
            mountainCell.label.text = mountain.name
            return mountainCell
        }
    }
}

これまでcellForItem(at:)で行っていたセルへの設定方法の指定を
Data Sourceの初期化の引数(クロージャ)として渡しています。

ポイントとしては
内部で関連したアイテム(ここではMountain)を特定してくれているため
IndexPathを使う必要がなくなり
不整合を生じるリスクがありません。

WiFiSettingsViewController(2つの異なるセクション、複数の動的なUIの切り替え手段)

次にもう少し複雑な例です。

スクリーンショット 2019-06-15 14.31.41.png

2つの異なるセッションがあります。
そして下のセッションはWi-Fiトグルボタンの設定で
表示非表示の切り替えができます。

さらに下のセッションのリストは
定期的にリストの内容が更新されます。

表示の更新は下記のメソッドで行っています。


extension WiFiSettingsViewController {

    func updateUI(animated: Bool = true) {
        guard let controller = self.wifiController else { return }

        let configItems = configurationItems.filter { !($0.type == .currentNetwork && !controller.wifiEnabled) }

        currentSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()

        currentSnapshot.appendSections([.config])
        currentSnapshot.appendItems(configItems, toSection: .config)

        if controller.wifiEnabled {
            let sortedNetworks = controller.availableNetworks.sorted { $0.name < $1.name }
            let networkItems = sortedNetworks.map { Item(network: $0) }
            currentSnapshot.appendSections([.networks])
            currentSnapshot.appendItems(networkItems, toSection: .networks)
        }

        self.dataSource.apply(currentSnapshot, animatingDifferences: animated)
    }
}

基本的な設定方法は最初の例と変わりません。

注目する点としては


currentSnapshot.appendSections([.config])
currentSnapshot.appendItems(configItems, toSection: .config)


currentSnapshot.appendSections([.networks])
currentSnapshot.appendItems(networkItems, toSection: .networks)

でそれぞれのセクションに値を設定しています。

そしてwifiEnabledの値でnetworkセクションは
表示するかしないかの判定を行っています。

ItemIdentifierTypeで使用しているItemについて見てみます。


class WiFiSettingsViewController: UIViewController {

    enum Section: CaseIterable {
        case config, networks
    }

    enum ItemType {
        case wifiEnabled, currentNetwork, availableNetwork
    }

    struct Item: Hashable {
        let title: String
        let type: ItemType
        let network: WIFIController.Network?

        init(title: String, type: ItemType) {
            self.title = title
            self.type = type
            self.network = nil
            self.identifier = UUID()
        }
        init(network: WIFIController.Network) {
            self.title = network.name
            self.type = .availableNetwork
            self.network = network
            self.identifier = network.identifier
        }
        var isConfig: Bool {
            let configItems: [ItemType] = [.currentNetwork, .wifiEnabled]
            return configItems.contains(type)
        }
        var isNetwork: Bool {
            return type == .availableNetwork
        }

        private let identifier: UUID
        func hash(into hasher: inout Hasher) {
            hasher.combine(self.identifier)
        }
    }
}

今回ですとWi-Fiセルや使用可能なセルなど異なるセルを
まとめて扱う必要があるため少し複雑になっていますが
Hashableへの適合方法などは変わりません。

次にデータソースの設定を見ていきます。


func configureDataSource() {
    self.dataSource = UITableViewDiffableDataSource
        <Section, Item>(tableView: tableView) { [weak self]
            (tableView: UITableView, indexPath: IndexPath, item: Item) -> UITableViewCell? in
         ...
    }
    self.dataSource.defaultRowAnimation = .fade
}

今回はUITableViewDiffableDataSourceを使用していましたが
CollectionViewの時と変わらないため割愛させていただきました。

一つ異なる点としてdefaultRowAnimationを設定して
UI更新時のアニメーションの設定を変更しています。

defaultRowAnimation
https://developer.apple.com/documentation/uikit/uitableviewdiffabledatasource/3255148-defaultrowanimation

InsertionSortViewController(部分更新の例)

最後にこれまでとは異なる形の更新例を見ていきます。

更新ある部分のみが更新されるようにしています。

スクリーンショット 2019-06-15 15.00.37.png


実際の動きはセッションを見てください。


extension InsertionSortViewController {

    func performSortStep() {
        if !isSorting {
            return
        }

        var sectionCountNeedingSort = 0

        // 空のSnapshotを新しく生成するのではなく、現在のSnapshotを取得
        var updatedSnapshot = dataSource.snapshot()

        // 各セクションごとに必要な場合はソートをかける
        updatedSnapshot.sectionIdentifiers.forEach {
            let section = $0
            if !section.isSorted {

                // ソート
                section.sortNext()
                let items = section.values

                // 現在のセクションのアイテムを削除してソート後のアイテムと入れ替える
                updatedSnapshot.deleteItems(items)
                updatedSnapshot.appendItems(items, toSection: section)

                sectionCountNeedingSort += 1
            }
        }

        var shouldReset = false
        var delay = 125
        if sectionCountNeedingSort > 0 {
            dataSource.apply(updatedSnapshot)
        } else {
            delay = 1000
            shouldReset = true
        }
        let bounds = insertionCollectionView.bounds
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delay)) {
            if shouldReset {
                let snapshot = self.randomizedSnapshot(for: bounds)
                self.dataSource.apply(snapshot, animatingDifferences: false)
            }
            self.performSortStep()
        }
    }
}

まず注目したい点として


var updatedSnapshot = dataSource.snapshot()

では
新しいSnapshotではなく現在のSnapshotを取得しています。

そうすることで現在の各セクションの状態から
必要なセクションにだけ処理を実行するようにできます。

さらに
処理の中ではdeleteItemsメソッドを使用しています。
他にも使えるメソッドはたくさんあります。

今回の例でもIndexPathは一切しておらず
アイテムやセルの特定はIdentifierを通してフレームワークが全て行ってくれます。

データソースの設定に関してはこれまでの例と同様なので
割愛させていただきます。

より深掘りして考えてみる

これまで見てきた例などから改めてこの新しいAPIの特徴を考えてみます。

すべてがapplyメソッドで更新するように統一されている

以前のように複数の手段がなくわかりやすくなっています。

スクリーンショット 2019-06-15 15.40.01.png

空のSnapshotと現在のUIのSnapshotの2パターンから更新ができる

必要な部分にだけ処理を実行するといったこともできます。

スクリーンショット 2019-06-15 15.40.08.png

Snapshotの状態を取得したり設定をするための便利なメソッドがある

今回出てきていないメソッドもたくさんあるため
ドキュメントを一通り見てみるのも良いかもしれません。
https://developer.apple.com/documentation/uikit/nsdiffabledatasourcesnapshot

Identifiers

セクションやアイテム間でユニークである必要があり
UUIDなどで一意性を確保しておくことを推めています。

さらにHashableに適合する必要がありますが
Swiftではenumなど自動でHashableに適合している型も多くあるため
簡単に色々な型をIdentifierにすることができます。

またセルに多くの情報を表示させる場合
何かしらのDataModelを使用することもあるかと思いますが
これもHashableに適合させることでIdentifierとして活用できます。

例の中にも出てきたように
下記のような形でIdentifierを作成するのが良いと言っていました。


struct MyModel: Hashable {
    let identifier = UUID()
    func hash(into hasher: inout Hasher) {
        hasher.combine(identifier)
    }
    static func == (lhs: MyModel, rhs: MyModel) -> Bool {
        return lhs.identifier == rhs.identifier
    }
}

これまでのIndexPathを使用したメソッドとも一緒に使用できる

indexPathとIdentifierは内部で管理されており
IndexPathからItemIdentifierTypeを取得することができます。

後の処理はIdentifierを使用する形で実行できます。


func collectionView(_ collectionView: UICollectionView,
                    didSelectItemAt indexPath: IndexPath) {
    if let identifier = dataSource.itemIdentifier(for: indexPath) {
        // Do something
    }
}

逆の変換も可能です。
https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasourcereference/3182922-indexpath


変換にかかる時間は一定なので
パフォーマンスは悪くはないとセッションでは言っていました。

全体としてのパフォーマンス

セッションでは
ローレベルでの処理を実装しているため良いと言っています。

またapplyメソッドは
メインスレッド以外のスレッドやバックグラウンドで計算を実行し
結果はメインスレッドで返すことをフレームワーク側で保証しているようです。

何か不整合が生じそうな場合はフレームワーク側でログを出力したり
アサーションが走るとのことでした。

とは言うものの
開発時の継続的なパフォーマンスの計測の必要性も挙げています。

Diffable Data Sourcesまとめ

  • シンプルで統一的で宣言的なAPI
  • フレームワークに任せる部分が増え、実装がより簡単に
  • Identifierを使うことによるData SourceとUIの不整合を防止
  • 差分更新によるパフォーマンスの向上

全体のまとめ

今回は
新しく登場した
Compositional Layoutsと
Diffable Data Sourcesについて見てきました。

全体を通して
シンプルに統一的な方法で書けるのがすごい良いと思いました。
コードの見通しも良く読みやすさも格段に上がっていると思います。

またサンプルの動きがすごいスムーズで
パフォーマンスも非常に良いと感じています。

まだ触れたことがない方は
まずはサンプルを見てみることで
基本的な動きが把握できるのでオススメです:smiley:

間違いなどございましたらご指摘頂けますと幸いです:bow_tone1:

(雑記)SwiftUIとの関係を考えてみる🤔

※ Swiftで新規開発をするならばという前提で話を進めます。

完全に個人的な意見ですが
SwiftUIはシンプルなデザインのViewの構築を
素早く簡単に型安全に実現できるものだと思っています。

一方で
今回登場したCompositional LayoutsやDiffable Data Sourcesは
複雑化するアプリという時代の流れに対応するためにできたという経緯もあるように
画一的なレイアウトでは対応できない画面や
パフォーマンスへの配慮が必要になる画面などの
適所で使っていくようになるのかと感じています。

CollectionViewの
直接代替となるようなものをSwiftUIは提供していません。

なので全体のレイアウトとしてはSwiftUIで構築し
よりカスタマイズが必要な画面に関しては
CollectionViewなどを活用していくのかなというのが現在の印象です。

※ 使われる所が限定的になると言っているのではなく
むしろアプリの複雑化は今後も進んでいき
使用される機会は現在よりも増えていくのではないかと思っています。

まだプロダクションでの使用経験もなく印象レベルでの話です。
ぜひここら辺に関してご意見があればお聞きしたいです:bow_tone1:

IBPCollectionViewCompositionalLayout

@kishikawakatsumi さんが作成している
iOS12以前でもUICollectionViewCompositionalLayoutが使用できるようにするライブラリがあります。
こちらもぜひ参考にしてみてください。

DiffableDataSources

Diffable Data Sourcesに関しても同様のライブラリがあります。

269
177
1

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
269
177

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?