iOS の UIScrollView で、ページング付き無限スクロールを実現する

# 無限スクロール by Josh & Eliza, 2011 WWDC

`UIScrollView` を使った無限スクロールはかなり昔から知られていて、僕が一番はじめに聞いたのは [2011 年 WWDC の Josh & Eliza の Advanced ScrollView Techniques](https://developer.apple.com/videos/play/wwdc2011/104/)でした。

通常、スクロールビュー上で例えば左スワイプをすると、画面が左へ流れるイメージを持ちますが、実際はビューポートが右へ動くことによってそう見せています。次図で言うと、最初黄緑部分だけが画面に映っていたのが、左スワイプによってビューポートが右へ移動し、今度は黄色部分が画面に映ります。

![NormalScrolling.png](https://qiita-image-store.s3.amazonaws.com/0/108030/7f62a510-57c6-0b74-aa37-3aee5a53c6aa.png)

この通常のスクロールだと、当然 Content View の端っこで、これ以上スクロールできなくなります。

![StuckAtTheEnd.png](https://qiita-image-store.s3.amazonaws.com/0/108030/99c2f3d1-36dc-3a48-54b5-a2f15f612548.png)

Josh & Eliza が 2011 に紹介した無限スクロールテクニックの肝は、`UIScrollViewDelegate.viewDidScroll(...)`で

* `UIScrollView` の `contentOffset` を常にアジャストして、__ビューポートを常に Content View の中心に維持する__
* ビューポートを動かさない代わりに、__Content View を、ビューポートの通常の動きとは反対に動かす__
* ContentView の空いた部分に、__新しいコンテンツを追加__する

ということです。

![InfiniteScrollTechnique2.png](https://qiita-image-store.s3.amazonaws.com/0/108030/15adc996-f3b0-6120-827b-d726123a85e6.png)

ビューポートは常に ContentView の中心なのでスクロールを止めることはなく、プログラムが ContentView にちゃんと新しいコンテンツを供給している限り、延々とスクロールし続けられます。

`UIScrollViewDelegate.viewDidScroll(...)` はビューポートの移動ごとに呼ばれ、かつレイアウト前の段階で呼ばれるので、スクロールに伴ったこのような動きを記述するのに最適です。

# ページング付きスクロール

## 問題点
Josh & Eliza の方法はとても簡単に実装できて、`UIScrollView` のページング機能を使わない場合は、今でも非常に有効です。ただ、例えばスクロールビューをを画像ビューアーとして使いたい場合など、各ページでスナップしてくれるページング機能を使いたいです。

本稿で扱う例は、動物版「いないないばあ」アプリ（！？）で、画像を左右にスクロールすると、ランダムに新しい動物キャラクターが表示されると言うものです。

![final.gif](https://qiita-image-store.s3.amazonaws.com/0/108030/07a0ed81-4f99-1810-a5ad-f84720546c3e.gif)

`UIScrollView` には `isPagingEnabled` というフラグがあって、これを `true` にするだけで、スクロールした時に各動物の絵のところでピチッと止まってくれます。ところが、__Josh & Eliza の無限スクロールテクニックを使うと、ビューポートは常に ContentView の中心にあるので__、iOS からすると全くページ移動をしていないと勘違いしてしまい、__ページングが利かなくなります__。

## 解決方法
基本的な考え方は Josh & Eliza のものを使うとして、変更の肝となるアイデアは「そんなにしょっちゅうビューポートをアジャストする必要はない」ということです。Josh & Eliza は、`scrollViewDidScroll(...)` で毎回アジャストを行なっていました。実際にアジャストが必要となるのは次の二つのタイミングのみです。

* 右方向にスクロールして、次ページのコンテントがビューポートのマジョリティを占めるようになった時
* 左方向にスクロールして、前ページのコンテントがビューポートのマジョリティを占めるようになった時

右スクロールして n+1 ページ目がマジョリティになった場合:
![Right Scroll.png](https://qiita-image-store.s3.amazonaws.com/0/108030/01265010-5fb3-652d-4ec6-119cbf3a7a1a.png)

左スクロールして n-1 ページ目がマジョリティになった場合:
![Left Scroll.png](https://qiita-image-store.s3.amazonaws.com/0/108030/15396f56-0a01-5542-0416-e2eafc663d20.png)

__各々のタイミングで 1 ページ分、ビューポートをずらして次のページを補填__してやれば良いわけです。__ページ境界では、通常通りのスクロールが動いているのでページングも有効です。__

コード例は以下の通り。

```swift
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offsetX = scrollView.contentOffset.x
        
        if (offsetX > scrollView.frame.size.width * 1.5) {
            // 1. モデルをアップデート。n-1 ページ目を削除して, n+2 ページ目を追加
            let newImage = fetcher.fetchRandomImage()
            images.remove(at: 0)
            images.append(newImage)
            // 2. 後述。n ページ目から n+2 ページのフレーム設定
            layoutImages()
            // 3. ビューポートの調整
            scrollView.contentOffset.x -= scrollViewSize.width
        }

        if (offsetX < scrollView.frame.size.width * 0.5) {
            // 1. モデルをアップデート。n+1 ページ目を削除して, n-2 ページ目を追加
            let newImage = fetcher.fetchRandomImage()
            images.removeLast()
            images.insert(newImage, at: 0)
            // 2. 後述。n-2 ページ目から n ページのフレーム設定
            layoutImages()
            // 3. ビューポートの調整
            scrollView.contentOffset.x += scrollViewSize.width
        }
    }
```

各ページのフレーム設定をするコード例は以下。

```swift
    private func layoutImages() {
        imageViews.enumerated().forEach { (index: Int, imageView: UIImageView) in
            imageView.image = images[index]
            imageView.frame = CGRect(x: scrollViewSize.width * CGFloat(index),
                                     y: 0,
                                     width: scrollViewSize.width,
                                     height: scrollViewSize.height)
        }
    }
```

# まとめ
`UIScrollView` で無限スクロールを実現したい場合、ページングが必要ない場合は、`UIScrollViewDelegate.scrollViewDidScroll(...)` で毎回 `contentOffset` と ContentView のフレームを調整することで実現できます。

しかし、__ページングが必要な場合も調整するタイミングを絞ることで同じ方法を用いることができます__。

おそらく `UIPageViewController` なんかを使ってもできるでしょうが、僕は `UIView` の世界だけで実現できるこの方法が好きです。リソースとしても現在のページ + 左右のページを用意するだけですみ、最適にエコですw

あと、デバイスの回転をサポートするようになるとちょっとややこしくはなってくるんですが、その辺も含めた [コードがここ](https://github.com/yfujiki/iOS-PeekABoo) にあります。

今度アンドロイドで似たようなことをするステップを調査してみたいと思っています。