AkkeyLab

Automatic paging method achieved with CompositionalLayout

この記事は「Qiita Advent Calendar 2021 / iOS」2日目の投稿です。

長方形の広告画像が一定間隔で自動ページングする 。このような機能を持つアプリに人生で一度は遭遇したことがあるのではないでしょうか。今回は、この挙動を CompositionalLayout を用いた環境で実現する方法をご紹介いたします。

はじめに

結論としては、かなり強引な方法で実現することになります。再現方法は他にもありますので、一つの参考例としてご覧いただけますと幸いです。
また、標準 API の他に RxSwift を用いていることを前提とした記述例でご紹介いたしますので、予めご了承ください。

レイアウト

UICollectionViewCompositionalLayout(
    sectionProvider: { [weak self] (sectionIndex: Int, _) -> NSCollectionLayoutSection? in
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        // Calculation of height and width is omitted
        let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(height))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 8
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 34, trailing: 16)
        section.orthogonalScrollingBehavior = .groupPaging // Point!
        return section
    }
)

今回は水平方向に自動ページングさせたいので Group は Horizontal で生成します。次に、通常のスクロールではなく ページング動作 をさせたいので NSCollectionLayoutSection.orthogonalScrollingBehaviorgroupPaging を指定します。
以上で、手動であれば理想のページング動作が可能なレイアウトが実現可能です。

必要なパラメータ

  • セルの個数
  • 選択されているセルとそのインデックス
  • ページング領域が手動で稼働したタイミングのハンドリング

セルの個数に関してはデータソースに問い合わせることで取得可能ですが、 残り2つが難問 です。

let visiableItems = collectionView.indexPathsForVisibleItems.filter { $0.section == .zero }.sorted()
let firstIndexPath = visiableItems.first!
let firstCell = collectionView.cellForItem(at: firstIndexPath)!
let horizontalScrollView = firstCell.superview as? UIScrollView

下準備として 水平方向にページングする領域の ScrollView を取得 する必要があります。なぜなら、その領域の中でのスクロール量から選択されているインデックスを計算しなければならないからです。
まず、indexPathsForVisibleItems を用いて UICollectionView の全セクションで 表示されている セルの IndexPath を取得します。その中でも今回必要なのは、水平方向にページングするセクションのみなので filter で絞り込みます。

次に、その中で先頭のセル、つまり見えているセルの中で最も左にあるセルの IndexPath のみを取得します。ここで注意しなければならないのが、左右に余白があり、 前後のセルが顔を覗かせるレイアウト の場合です。その場合、 必ずしも先頭のセルが画面中央にあるセルとは限らない のです。この IndexPath を元にセルの実態を取得し、その Superview にアクセスすることで水平方向にページングする領域の ScrollView にアクセスすることが可能になります。

※上記は記述簡略化のために強制アンラップを利用しておりますので予めご了承ください

let sideMargin = CGFloat(16 * (itemCount + 1))
let currentRow = Int((sideMargin + horizontalScrollView.contentOffset.x) / collectionView.bounds.width)

下準備が終わりましたので、さっそく選択されているセルのインデックスを取得していきましょう。
まず、「選択されている」という言葉が曖昧なので、ここではセルの横幅が端末の横幅とほぼ同じで、 画面中央に来ているセルを選択されているセル と定義することにします。そして、詳細なセルのサイズとしては左右の余白16pxを合わせた32pxを端末横幅から引いたサイズとします。

このとき、UICollectionView 全体におけるセル左右の余白の合計は 16*(セルの数+1) で求められます。これとページングを行う領域の ScrollView.contentOffset.x を足したものを端末の横幅で割って整数にすることで選択されているセルのインデックスが求められます。

let nextIndexPath: IndexPath? = {
    if let nextIndexPath = visiableItems.last, currentRow < nextIndexPath.row {
        return nextIndexPath
    } else if let section = visiableItems.last?.section {
        return IndexPath(row: .zero, section: section)
    }
    return nil
}()

選択されているセルのインデックスが求まったので、次のセル、つまり 右側で控えているセルのインデックス を求めてみましょう。今回のレイアウトの場合、右側がある場合は 必ずセルが顔を覗かせているはず なので、 配列の末端を次のセルと仮定 して判断していきます。
もしも、選択されているセルが一番右端であった場合はインデックスをゼロに戻しています。これによってページングの 無限ループ を実現できます。

horizontalScrollView.rx.willBeginDragging
    .subscribe()

ページング領域が手動で稼働したタイミングのハンドリングは、下準備の段階で取得した ScrollView のイベント として取得可能です。このトリガーを使うことによって、ユーザがセルに触れているタイミングで自動ページングしてしまう現象を防ぐことが可能になります。

一定間隔で自動ページング

private var autoScrollBag = DisposeBag()

Observable<Int>.interval(.seconds(5), scheduler: MainScheduler.instance)
    .subscribe(onNext: { [weak self] _ in
        // No need to move if there is only one cell
        guard itemCount > 1 else { return }

        if let next = nextIndexPath {
            collectionView.scrollToItem(at: next, at: .centeredHorizontally, animated: true)
        }
    })
    .disposed(by: autoScrollBag)

ページングさせる処理自体は非常に単純で、先程求めた 次に表示するセルの IndexPathUICollectionView.scrollToItem に指定するだけです。
RxSwift を利用している場合、 定期実行には interval がお勧め です。

Observable.merge(
    scrollView.rx.willBeginDragging,
    rx.viewWillDisappear
)
.subscribe(onNext: { [weak self] in
    self?.autoScrollBag = DisposeBag()
})
.dispose(with: self)

Observable.merge(
    scrollView.rx.didEndDragging.filter { $0 },
    rx.viewDidAppear
)
.subscribe(onNext: { [weak self] _ in
    // Re-Subscribe
})
.dispose(with: self)

定期実行を停止させるタイミングは2パターン、再開させるパターンも2パターンあります。
まず停止させるタイミングは、ユーザが手動でページングを開始する直前と、画面が非表示になる直前です。 インスタンスの破棄ではなく、viewWillDisappear をトリガーにしている ことに注意してください。次に再開させるタイミングは、ユーザがページングをやめた直後と、画面が表示された直後です。

さいごに

いかがだったでしょうか。
スマートな実装方法ではありませんが、こうした実装を行う中で UIKit の内部構造に詳しくなれる という利点があるだけで チャレンジしてみて良かった と感じます。ぜひ、あなたなりの実装方法を考えて、試してみてください!

アイデア、ご指摘等ございましたら、このホームページの連絡フォームより気軽に送っていただけると嬉しいです。
最後までお読みいただき、ありがとうございます。

Author

Next post

Let's write a script that contains API calls! / Getting Started with Ruby

この記事は「Qiita Advent Calendar 2021 / 完全に理解したTalk Advent Calendar 2021」1日目の投稿です。

Read More →