CompositionalLayoutを用いた横スクロールのレイアウト改修で直面した問題と解決方法

こんにちは。WEAR部iOSチームの小野寺です。

先日CollectionViewで実装しているトップページを改修しました。

改修はトップページに並べていたコンテンツを1つにまとめて、横スクロール(手動 / 自動)によってコンテンツを切り替え可能にしました。

CompositionalLayoutで横スクロール

横スクロールによってコンテンツを切り替える仕様なので、CompositionalLayoutで実装しました。

上記の方針で進めていく中で、困難な実装に直面したので紹介します。

セクション全体への装飾

最初に直面した問題が、セクション全体に対するViewの装飾です。今回画像の赤枠部分について、次のように改修が必要になりました。

トップページ改修の要件

  1. 横スクロールで、コンテンツを切り替えられるレイアウトに変更
  2. その上に固定で表示され続けるViewを被せる(画像の赤枠)
  3. 固定で表示するViewの高さはタグ名の長さによって変わる
改修前 改修後
トップページ改修前 DecorationViewの固定表示

レイアウト調整にコストがかかる

固定で表示され続けるViewは、UICollectionReusableView, NSCollectionLayoutBoundarySupplementaryItemを使用しました。

func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~

let decorationViewHeight = 150

let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                elementKind: “tag-container-element-kind”,
                                                                alignment: .bottom,
                                                                absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]

~~ 省略 ~~
}

追加したViewのy座標を、セクションの乗せたい位置(decorationViewHeight)までずらすことで、セクション全体へかかるようにしています。

ここではまだ、高さが変わることを考慮していないので、内容によってはレイアウトが崩れてしまいます。

CompositionalLayoutのフッターViewのレイアウトの差分

Viewの高さを計算する対応を追加し、コンテンツの表示に必要な高さを確保します。

class DecorationView: UICollectionReusableView {
~~ 省略 ~~
    
    static func calcViewHeight(title: String, containerSizeOfWidth: CGFloat) -> CGFloat {
        let decorationView = calculationBaseView
        decorationView.label.text = title
        decorationView.setNeedsDisplay()
        decorationView.layoutIfNeeded()
        let layoutViewSize = layoutView.systemLayoutSizeFitting(CGSize(width: containerSizeOfWidth, height: 0),
                                                                withHorizontalFittingPriority: .required,
                                                                verticalFittingPriority: .fittingSizeLevel)
        return layoutViewSize.height
    }
    
~~ 省略 ~~    
}

追加したcalcViewHeight()をdecorationViewHeightへ反映させます。

func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~

// let decorationViewHeight = 150
let title = "冬がはじまるよ"
let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))!
let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width)

let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                elementKind: “tag-container-element-kind”,
                                                                alignment: .bottom,
                                                                absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]

~~ 省略 ~~
}

コンテンツの内容によって高さを取得する必要があるのでレイアウト調整にコストがかかっています。

OSによってはフッターが非表示になる

さらに、iOS14.5未満のOSバージョンで、フッターがセルの裏に隠れてしまう問題もありました。

iOS14.5未満でのフッターViewのレイヤー

この事象に対しては、UICollectionReusableViewを使った対応が取れませんでした。

今回は後にターゲットOSを上げる予定があった為、暫定対応としてUICollectionReusableViewに直接実装したコンテンツをカスタムUIViewとして切り出しました。

事象が発生するバージョンではUICollectionReusableViewは使用せず、このカスタムUIViewをCollectionViewのSubViewとして扱う改修で対応しました。

カスタムUIViewとして扱う

コンテンツの自動スクロール

レイアウトの改修の次は、自動でコンテンツをスクロールさせる機能の追加です。

自動スクロール機能の要件

  1. 一定時間に画面の操作がない場合に、次のセルを表示
  2. 最後のセルまで表示させたら、先頭に戻る

carouselの自動スクロール成功例

自動スクロール中に意図しないスクロールの発生

自動スクロールの対応は、はじめに以下の方針で検討しました。

  1. 一定時間でスクロールできるように、Timerを追加して定周期でスクロール処理を呼び出す。
  2. スクロール処理は、scrollToItem(at:at:animated:)をデフォルトのアニメーションを有効にして、コンテンツをスクロールさせる。

Timer側からの呼び出し

Timer.scheduledTimer(
    withTimeInterval: 3.0,
    repeats: true,
    block: { [weak self] _ in
        guard let self = self else { return }
        let toItem = displayContentIndexPathItem + 1
        
        self.scrollToItemForCarousel(at: .init(item: toItem, section: 0))
    }
)

定周期で呼び出すスクロール処理

func scrollToItemForCarousel(at indexPath: IndexPath) {
    collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}

実際動作させてみたところcollectionView.contentOffset.yが、アニメーションの度に改修したセクションのデフォルトの位置へと、引き戻される問題が発生しました。

carouselの自動スクロール失敗例

原因は、scrollToItem(at:at:animated:) へ第1引数で渡しているindexPathにありました。スクロール先の指定にindexPathが使用されることで、x座標、y座標それぞれに対してscrollToItemが作用してしまいます。

これによりCollectionViewの垂直方向が、少しでもスクロールされた状況下の場合に、事象が発生してしまいました。

自作の自動スクロールのアニメーションを追加

問題を解消するために、今回はscrollToItem(at:at:animated:)のデフォルトのアニメーションは使わず、次のような自作アニメーションを実装しました。

  1. 自動スクロール前のcontentOffsetの位置を保持する
  2. scrollToItem(at:at:animated:)でスクロール位置を更新
  3. y座標を調整前の値で更新する
  4. 2と3を1つのアニメーションとして扱う
func scrollToItemForCarousel(at indexPath: IndexPath) {
    let currentOffset = collectionView.contentOffset
    
    UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { [weak self] in
        guard let collectionView = self?.collectionView else { return }
        
        collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
        collectionView.contentOffset.y = currentOffset.y
    }
}

scrollToItemでのindexPathの更新とy座標をもとに戻す対応を1つのアニメーションとして実装することで、引き戻される問題を回避しています。

先頭へ戻るアニメーションにコストがかかる

ここまでの対応で、自動で次のコンテンツへ切り替える対応ができました。次に最後のセルまで表示させたら、先頭に戻るアニメーションが必要です。

戻る処理についてもscrollToItem(at:at:animated:)のアニメーションをそのまま使用できれば、簡潔に対応可能です。

(第1引数へ先頭のIndexPathを指定することで、コンテンツが最終位置にいる状態から、アニメーション付きで一気に先頭へ戻す挙動を実現できます)

しかし自作アニメーションを使用する場合は、次のような変更が必要になりました。

  1. UIView.animateのcompletion内で再度自作アニメーションを呼ぶ処理を追加
  2. 引数needsRepeateを追加し、先頭のセルに戻ってくるまでこの値を有効にする
  3. needsRepeateの値が有効の間、自作アニメーションを繰り返す

アニメーション処理を繰り返すことで、最後尾から先頭までのコンテンツの移動を、1つのアニメーションのように見せます。

この際durationの値を調整して、scrollToItem(at:at:animated:)のアニメーションを使った場合の挙動に近づけています。

func scrollToItemForCarousel(at indexPath: IndexPath, needsRepeate: Bool) {
    let currentOffset = collectionView.contentOffse
    let duration: TimeInterval = needsRepeate ? 0.05 : 0.3

    UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut) { [weak self] in
        guard let collectionView = self?.collectionView else { return }

        collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
        collectionView.contentOffset.y = currentOffset.y
    } completion: { [weak self] _ in
        let previousIndex = indexPath.item - 1
        
        if needsRepeate {
            self?.scrollToItemForCarousel(at: .init(item: previousIndex, section: indexPath.section),
                                          needsRepeate: previousIndex >= 0)
        }
    }
}

CompostionalLayoutを使った実装は、レイアウトを組むまでは容易でしたが「セクション全体への装飾」と「コンテンツの自動スクロール」の実装が複雑になり大変でした。

CompositionalLayoutを使わない解決策

これらの実装をCompostionalLayoutを使わず、CollectionViewを中に入れたセル(ContentCollectionViewInCell)を使う方法で考えてみます。

CellにCollectionViewを載せる1

ContentCollectionViewInCellは、横スクロールで切り替え可能なコンテンツの表示に使用するCollectionViewと装飾に使用するViewを配置したもので考えてみます。

ContentCollectionViewInCell.xib CellにCollectionViewを載せる2

ContentCollectionViewInCell.swift

class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource {
    @IBOutlet var collectionView: UICollectionView!
    @IBOutlet var decorationView: DecorationView!
    var contentImages: [UIImage] = []
    
    override func awakeFromNib() {
        super.awakeFromNib()
        configureCollectionView()
    }
    
    func configure(contentImages: [UIImage], labelText: String) {
        self.contentImages = contentImages
        
        collectionView.reloadData()
    }
    
    private func configureCollectionView() {
        collectionView.register(ContentImageCell.self, forCellWithReuseIdentifier: ContentImageCell.identifier)
        collectionView.dataSource = self
        collectionView.collectionViewLayout = createPagingContentLayout()
    }
    
    private func createPagingContentLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { _, _ -> NSCollectionLayoutSection? in
        
            ~~ 省略:横スクロール用のレイアウト設定 ~~
            
            return section
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return contentImages.count
    }
    
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ContentImageCell.identifier, for: indexPath)
        
        ~~ 省略:cellの中身の更新 ~~
        
        return cell
    }
}

セクション全体を装飾するViewの調整

ContentCollectionViewInCellを使用した場合、CompostionalLayoutで苦労したセクション全体を装飾するViewの高さの調整は、オートレイアウトで解決できます。

実際に「ComposionalLayoutを使用した場合」と「ContentCollectionViewInCellを使用した場合」で実装を比較してみます。

ComposionalLayoutを使用した場合
class ViewController: UIViewController {
    ~~ 省略 ~~
    
    func createLayout() -> UICollectionViewCompositionalLayout {
        ~~ 省略:横スクロール用のレイアウト設定 ~~

        let title = "冬がはじまるよ\nアウターはMAST\n見れたらいいねスターダスト"
        let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))!
        // 最前面に配置したViewの高さを計算
        let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width)

        // 最前面に配置したViewの高さの反映と位置を調整する
        let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
        let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                        elementKind: “tag-container-element-kind”,
                                                                        alignment: .bottom,
                                                                        absoluteOffset: .init(x: 0, y: -decorationViewHeight))
        section.boundarySupplementaryItems = [decorationView]

        return section
    }
    
    ~~ 省略 ~~
}
ContentCollectionViewInCellを使用した場合
class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource, UICollectionViewDelegate {
    ~~ 省略 ~~
    
    func updateDecorationViewLabel() {
        decorationView.contentLabel.text = "冬がはじまるよ\nアウターはMAST\n見れたらいいねスターダスト"
        collectionView.reloadData()
    }
}

ContentCollectionViewInCellを使用した場合、decorationViewが持つラベルの設定とcollectionViewの更新のみで、期待した高さが反映されます。

OS差分の問題

ComposionalLayoutを使用せずにContentCollectionViewInCellを使用した場合は、iOS14.5未満とそうでないOSの差分による影響も無くなります。

コンテンツの自動スクロール機能

コンテンツの自動スクロール機能もContentCollectionViewInCellを使用した場合は、実装が容易になります。

y座標は常に固定で扱うことが可能なことから、scrollToItem(at:at:animated:)の呼び出しのみで対応することが可能になります。

実際に「ComposionalLayoutを使用した場合」と「ContentCollectionViewInCellを使用した場合」で比較すると次のようになります。

ComposionalLayoutを使用した場合
class ViewController: UIViewController {
    ~~ 省略 ~~

    func scrollToItemForCarousel(at indexPath: IndexPath, needsRepeate: Bool) {
        let currentOffset = collectionView.contentOffse
        let duration: TimeInterval = needsRepeate ? 0.05 : 0.3

        UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut) { [weak self] in
            guard let collectionView = self?.collectionView else { return }

            // 次のセルに進めるアニメーション
            collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
            collectionView.contentOffset.y = currentOffset.y
        } completion: { [weak self] _ in
            let previousIndex = indexPath.item - 1
        
            if needsRepeate {
                // 先頭のセルに戻すアニメーション
                self?.scrollToItemForCarousel(at: .init(item: previousIndex, section: indexPath.section),
                                              needsRepeate: previousIndex >= 0)
            }
        }
    }
    
    ~~ 省略 ~~
}
ContentCollectionViewInCellを使用した場合
class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource {
    ~~ 省略 ~~

    func autoScroll(toIndex: Int) {
        let totalContents = 5

        if indexPath.item < totalContents {
            // 次のセルに進めるアニメーション
            collectionView.scrollToItem(at: .init(item: toIndex, section: 0), at: .centeredHorizontally, animated: true)
        } else {
            // 先頭のセルに戻すアニメーション
            collectionView.scrollToItem(at: .init(item: 0, section: 0), at: .centeredHorizontally, animated: true)
        }
    }
    
    ~~ 省略 ~~
}

このように今回紹介したケース単体で見ると、CollectionViewを中に入れたセルを使う対応の方がより簡潔に済みます。

CollectionViewInCellの問題点

CollectionViewInCellにも仕様によっては、かえってコード量の増加や実装の複雑度が上がってしまう問題が考えられます。

考えられる問題

  1. セル毎にCollectionViewの設定が必要になる
  2. 親のセルが表示するセルの状態の管理をするようになる
  3. セルの階層に比例してタップアクションのハンドリングが複雑になる

実現したいUIに対する実装が容易になる一方で、アクションに対する実装やその後の運用が困難になるといったトレードオフがあることを理解しておく必要があります。

おわりに

今回は複数のレイアウトが混在する画面の改修だったため、CompositionalLayoutを使う方針を選択しました。

これにより、横スクロールのような複雑なレイアウトに対しても簡潔に取り入れることができました。

その一方で、凝ったUIの仕様では実装の複雑度が上がってしまいました。

今回は最初に検討していた、CompositionalLayoutで実現する方法を選択しましたが、メリット・デメリットを把握して現状取れる最適な選択をすることが大事だと感じました。

WEARでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は次のリンクからぜひご応募ください。

hrmos.co

カテゴリー