FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

Compositional Layoutsで1つだけサイズの異なるitemを表示する

こんにちは。GANMA!のiOSアプリを開発している宗像です。

FLINTERSでは会社設立10周年を記念して、全社員で133日間ブログを更新し続けるという企画を実施中です。こちらはその16日目のブログです。

GANMA!ではこれまでオリジナル作品を中心に掲載していましたが、7月からGANMA!オリジナル作品以外の出版社電子書籍の取り扱いをスタートしました。今回はその電子書籍用の画面の開発で学んだCompositional LayoutsのTipsを紹介したいと思います。

電子書籍の作品画面の構成は以下のようになっています。画面上から作品の表紙画像、作品情報、電子書籍単行本のリストと続いていきます。この画面ではUICollectionViewを利用しており、ViewのレイアウトはCompositional Layoutsを使っています。

電子書籍の作品画面(表示している内容はダミーのものです)

今回は表紙画像の部分の実装についての話で、このViewでは最新刊の表紙を大きく、残りの表紙は半分の高さのViewで表示しています。このようなViewをCompositional Layoutsで実装するにはどのようなレイアウトを組めばよいでしょうか。

サンプルコードを動かしてみる

Appleはサンプルコードを公開している場合があり、Compositional Layoutsを使ったサンプルコードもImplementing Modern Collection Views で公開されています。こちらのサンプルコードをビルドして実行してみるとOrthogonal Scroll BehaviorsというViewで今回実装したいものに比較的近い例をみることができます。画像のように横スクロール可能なセクションが複数あり大小のViewが並んでいます。Viewの名前が示す通りこちらは本来はorthogonalScrollingBehaviorに指定する値によって変わるスクロールの挙動をみることができるサンプルコードです。

Appleのサンプルコードをビルドしたアプリの画面

この画面はOrthogonalScrollBehaviorViewController.swiftで実装されておりレイアウトを作っている部分は以下のようになっています。leadingItemが大きいView、trailingItemが小さいViewに対応していてtrailingItem2つで1つのgroup(trailingGroup)を作っており、leadingItemとtrailingGroupをまとめたcontainerGroupを用意してcontainerGroupがitemの数に応じて繰り返し表示されるようになっています。

func createLayout() -> UICollectionViewLayout {

        let config = UICollectionViewCompositionalLayoutConfiguration()
        config.interSectionSpacing = 20

        let layout = UICollectionViewCompositionalLayout(sectionProvider: {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            guard let sectionKind = SectionKind(rawValue: sectionIndex) else { fatalError("unknown section kind") }
                        
            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 orthogonallyScrolls = sectionKind.orthogonalScrollingBehavior() != .none
            let containerGroupFractionalWidth = orthogonallyScrolls ? CGFloat(0.85) : CGFloat(1.0)
            let containerGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(containerGroupFractionalWidth),
                                                  heightDimension: .fractionalHeight(0.4)),
                subitems: [leadingItem, trailingGroup])
            let section = NSCollectionLayoutSection(group: containerGroup)
            section.orthogonalScrollingBehavior = sectionKind.orthogonalScrollingBehavior()

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

        }, configuration: config)
        return layout
    }

レイアウトの作成

しかし、このレイアウトでは大きいViewも合わせて繰り返し表示されてしまいます。小さいViewだけを繰り返し表示するため、以下のようにレイアウトを作成します。大きいViewと小さいView、小さいView2つでGroupを作るところまではサンプルコードとだいたい同じでcontainerGroupの作り方を変えています。サンプルコードではcontainerGroupは3つのitemが入ったGroupでしたが、このレイアウトではlargeItemを1つ、残りはsmallItemが縦に2つ並ぶverticalGroupとしてセクション内のアイテムすべてを含んだGroupを1つ作成します。

extension OrthogonalScrollBehaviorViewController {
    static func sampleLayout(numberOfItemInSection: Int) -> NSCollectionLayoutSection {
        let spacing = 8
        let sectionHeight = 300

        // 1枚目のlargeItem
        let largeImageWidth = 200
        let largeItemSize = NSCollectionLayoutSize(widthDimension: .absolute(CGFloat(largeImageWidth)), heightDimension: .fractionalHeight(1.0))
        let largeItem = NSCollectionLayoutItem(layoutSize: largeItemSize)

        // 2枚目以降smallItem
        let smallItemHeight = (sectionHeight - spacing) / 2
        let smallItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(CGFloat(smallItemHeight)))
        let smallItem = NSCollectionLayoutItem(layoutSize: smallItemSize)

        // smallItemが縦に2つ並ぶGroup
        let verticalGroupWidth = largeImageWidth / 2
        let verticalGroupSize = NSCollectionLayoutSize(widthDimension: .absolute(CGFloat(verticalGroupWidth)), heightDimension: .fractionalHeight(1.0))
        let verticalGroup = NSCollectionLayoutGroup.vertical(layoutSize: verticalGroupSize, subitems: [smallItem])
        verticalGroup.interItemSpacing = .fixed(CGFloat(spacing))

        // large/small itemを組み合わせたcontainerGroupを作る
        let numberOfVerticalGroup = ceil((Double(numberOfItemInSection) - 1.0) / 2.0)
        let containerGroupWidth = CGFloat(Int(numberOfVerticalGroup) * (verticalGroupWidth + spacing) + largeImageWidth)

        let containerGroupSize = NSCollectionLayoutSize(
            widthDimension: .absolute(containerGroupWidth),
            heightDimension: .absolute(CGFloat(sectionHeight))
        )
        var subitems: [NSCollectionLayoutItem] = [largeItem]
        subitems.append(contentsOf: Array(repeating: verticalGroup, count: Int(numberOfVerticalGroup)))
        let containerGroup = NSCollectionLayoutGroup.horizontal(layoutSize: containerGroupSize, subitems: subitems)
        containerGroup.interItemSpacing = .fixed(CGFloat(spacing))

        let ls = NSCollectionLayoutSection(group: containerGroup)
        ls.contentInsets = .init(top: 10, leading: 10, bottom: 10, trailing: 10)

        return ls
    }
}

このセクションを表示するため、caseを追加し、createLayoutでsampleセクションのときだけ sampleLayoutメソッドを使ってレイアウトを作ります。

enum SectionKind: Int, CaseIterable {
        case continuous, continuousGroupLeadingBoundary, paging, groupPaging, groupPagingCentered, none, sample
        func orthogonalScrollingBehavior() -> UICollectionLayoutSectionOrthogonalScrollingBehavior {
            switch self {
            case .none:
                return UICollectionLayoutSectionOrthogonalScrollingBehavior.none
            case .continuous:
                return UICollectionLayoutSectionOrthogonalScrollingBehavior.continuous
            case .continuousGroupLeadingBoundary:
                return UICollectionLayoutSectionOrthogonalScrollingBehavior.continuousGroupLeadingBoundary
            case .paging:
                return UICollectionLayoutSectionOrthogonalScrollingBehavior.paging
            case .groupPaging:
                return UICollectionLayoutSectionOrthogonalScrollingBehavior.groupPaging
            case .groupPagingCentered:
                return UICollectionLayoutSectionOrthogonalScrollingBehavior.groupPagingCentered
                        // 今回作ったレイアウトで表示するセクション
            case .sample:
                return UICollectionLayoutSectionOrthogonalScrollingBehavior.continuous
            }
        }
    }
func createLayout() -> UICollectionViewLayout {

        let config = UICollectionViewCompositionalLayoutConfiguration()
        config.interSectionSpacing = 20

        let layout = UICollectionViewCompositionalLayout(sectionProvider: {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            guard let sectionKind = SectionKind(rawValue: sectionIndex) else { fatalError("unknown section kind") }
            let section: NSCollectionLayoutSection
            if case .sample = sectionKind {
                section = OrthogonalScrollBehaviorViewController.sampleLayout(numberOfItemInSection: 18)
            } else {
                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 orthogonallyScrolls = sectionKind.orthogonalScrollingBehavior() != .none
                let containerGroupFractionalWidth = orthogonallyScrolls ? CGFloat(0.85) : CGFloat(1.0)
                let containerGroup = NSCollectionLayoutGroup.horizontal(
                    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(containerGroupFractionalWidth),
                                                      heightDimension: .fractionalHeight(0.4)),
                    subitems: [leadingItem, trailingGroup])
                section = NSCollectionLayoutSection(group: containerGroup)
                
            }
            section.orthogonalScrollingBehavior = sectionKind.orthogonalScrollingBehavior()

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

        }, configuration: config)
        return layout
    }

この実装を加えてビルドすると以下のようになり、小さいViewだけを繰り返し表示することができました。GANMA!ではこれに加えてitemのサイズを最新刊の表紙画像のサイズに合わせていたり、巻数が多い漫画のために追加読み込みに対応していたりします。

サンプルコードのAppに追加したセクション

この方法の注意点はorthogonalScrollingBehaviorに指定するオプションでgroup単位でスクロールの動きを制御するgroupPagingのようなオプションを指定するとうまく動かないことです。セクション全体が1つのGroupになっているため、スクロールすると最後尾のViewまで一気に移動してしまいます。continuousかpagingであれば正しくスクロールができます。今回はcontinuousを指定しています。

まとめ

Compositional Layoutsが発表されてから数年が経ち今では多くのアプリで取り入れられているのではないでしょうか?GANMA!でもいろいろな画面のレイアウトで便利に使っています。Compositional Layoutsでのレイアウトの一例を紹介しました。

参考