CompositionalLayoutのカルーセルを無限スクロールさせてみる

こんにちは:christmas_tree:
<a href="https://qiita.com/advent-calendar/2022/zozo">ZOZO #6 Advent Calendar 2022</a> 21日目の記事を担当します:snowflake: (去年も21日)
今年は遅れてスノボーにシーズインした、@koiwai2020です:snowboarder:
よろしくお願いします:bow:

# 今回も


大好きなスノボーシーズンに使えそうな話題が、、、、作れませんでした。
なので調べてみたものの使うことはなかった、CompositionalLayoutで作ったカルーセルの無限スクロールを忘れないように記す場にします。


# 無限スクロールの実装について

無限スクロールは、動画のように1〜5の数字が割り振られたセルが、無限にスクロールされます。

<blockquote class="twitter-tweet"><p lang="zxx" dir="ltr"><a href="https://t.co/E03UzOY9vG">pic.twitter.com/E03UzOY9vG</a></p>&mdash; 小岩井 (@WfODXAd0jmop1Ev) <a href="https://twitter.com/WfODXAd0jmop1Ev/status/1603164100854714368?ref_src=twsrc%5Etfw">December 14, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

この挙動をCompositionalLayoutで実装する2つのポイントを紹介します。

## ①セルを表示したいコンテンツの2倍用意する

今回実際に表示させたいコンテンツは、1〜5の数字が割り当てられた、5つのセルです。

![実際に表示させたい.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368129/08d66189-9a72-b798-8991-38f0c84371d4.png)

ですが、無限スクロールを実装するにあたって、こちらを2倍用意します。

![2倍.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368129/94353608-1c13-6300-fc77-09faec67b21e.png)

## ②端まで進んだら戻るを実装する

実際に無限にセルを増やし続けることは難しいです。
その為、カルーセルの両端までスクロールされたタイミングで、真ん中の繋ぎ目のセルの位置まで戻します。

*左端までスクロールした時*
![戻る.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368129/dd4535f2-b707-ae25-e407-943bdb82fb28.png)

*右端までスクロールした時*
![戻る.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368129/f9d58656-6584-c3b8-7ca9-46b72fc58eba.png)

これを繰り返すことで、スクロールが無限に続いているように見せます。
こちらは、次の2つを組み合わせることで実現しました。


1. 端までスクロールされたかを検知する：visibleItemsInvalidationHandler [^1]
1. 繋ぎ目のセルまで戻す：scrollToItem(at: at: animated:) [^2]

まず、visibleItemsInvalidationHandlerですが、こちらはカルーセルのスクロールをハンドリングすることが可能で、次の3つの情報から状態を判別することができます。

- 表示中のセル：`[NSCollectionLayoutVisibleItem]`
- セルの座標：`CGPoint`
- 表示中のセルのレイアウト `NSCollectionLayoutEnvironment`

今回は、セルの座標を使って「端までスクロールされたか？」を確認しました。

あとは、検知されたタイミングで scrollToItem(at: at: animated:) を呼んでスクロール位置を移動させます。
この時、アニメーションの設定を無効にすることで、切り替わりを誤魔化します。

```Swift
func createLayout() -> UICollectionViewLayout {
    UICollectionViewCompositionalLayout { [weak self] _, layoutEnvironment -> NSCollectionLayoutSection? in
        guard let self = self else { return nil }

        let horizonalMargin = 20.0
        let layout = carouselLayout(with: layoutEnvironment)

        // カルーセル内のスクロールをハンドリング
        layout.visibleItemsInvalidationHandler = { [weak self] _, point, _ in
            guard let self = self else { return }

            // マージンを除いた横幅
            let width = layoutEnvironment.container.contentSize.width - horizonalMargin * 2

            if let index = Int(exactly: (point.x + horizonalMargin) / width) {
                if index == 0 {
                    // 「左端までスクロールした時」の状態
                    self.collectionView.scrollToItem(at: IndexPath(item: 5, section: 1), at: .none, animated: false)
                } else if index == 9 {
                    // 「右端までスクロールした時」の状態
                    self.collectionView.scrollToItem(at: IndexPath(item: 4, section: 1), at: .none, animated: false)
                }
            }
        }
        return layout
    }
}
```

ざっくりですが、以上で無限スクロールっぽい挙動になります。

 [^1]:https://developer.apple.com/documentation/uikit/nscollectionlayoutsection/3199096-visibleitemsinvalidationhandler

[^2]:https://developer.apple.com/documentation/uikit/uicollectionview/1618046-scrolltoitem