Android の ViewPager で無限スクロールを実現する

以前、[iOS の UIScrollView で、ページつき無限スクロールを実現する](https://qiita.com/yfujiki/items/d974b69b35779897d249) という記事を書いて、Android ではどうするのかな、と思いました。少し調べてみたところ、[Looping/Infinite ViewPager](https://toyknight.net/looping-view-pager/) という記事がドンピシャでした。英語記事ということと、データの更新については触れられていない(少しユースケースが異なる)、ということでここにまとめてみます。

# アイデア

ViewPager で、5ページ分のスペースを用意しておきます。

[0][1][2][3][4]

0. 最初は1ページ目から始めます。
1. 0ページ目にたどり着いたら、3ページ目に飛びます。
2. 4ページ目にたどり着いたら、1ページ目に飛びます。

こうすることで、5ページ分のスペースの端っこに落ち着くことはないので、常に左右にスクロールできる状態になります。

この時、データの更新についても考えなくてはいけません。上記のステップ 2. で 0ページ目から 3ページ目に飛んだ時、現在のデータから後ろ三つを削除し、前三つを補填します。

![Rewind.png](https://qiita-image-store.s3.amazonaws.com/0/108030/5db82980-9f0b-8da5-6c8f-8b7c4e5963cd.png)

同様にステップ 3. で 4ページ目から 1ページ目に飛んだ時、現在のデータから前三つを削除し、後ろ三つを補填します。

![Forward.png](https://qiita-image-store.s3.amazonaws.com/0/108030/d47bb924-32ef-74c0-b2c9-e05b3071a2f9.png)

# コード

## PagerAdapter サブクラスの実装

コールバックの実装はいたって普通です。ページ総数は 5 なので、`getCount()` では 5 を返しています。

```kotlin
    private var dataList = ... // データリスト

    ...

    override fun isViewFromObject(view: View, `object`: Any): Boolean {
        return view == `object`
    }

    override fun getCount(): Int {
        return 5
    }

    override fun instantiateItem(container: ViewGroup, position: Int): View {
        val view = ... // Layout から inflate

        val dataItem = dataList.get(position)
        
        // set dataItem to the view
        view.setData(data.get(position))

        container.addView(view)

        return view
    }

    override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {
        container.removeView(`object` as View)
    }
```

次にデータの更新のために次のカスタムメソッドを用意しておきます。

```kotlin
    fun initializeData() {
        dataList.clear()

        for (i in 0 until 5) {
            dataList.add(...) // 初期データの設定
        }
    }

    fun forwardData() {
        for (i in 0 until 3) {
            imageList.removeAt(0)
        }

        for (i in 0 until 3) {
            imageList.add(...) // 後データの補填
        }
    }

    fun rewindData() {
        for (i in 0 until 3) {
            imageList.removeAt(5 - i - 1)
        }

        for (i in 0 until 3) {
            imageList.add(0, ...) // 前データの補填
        }
    }
```

## Activity (もしくは Fragment) 側の実装

```kotlin
    // adapter は PagerAdapter サブクラスのインスタンス
    viewPager.adapter = adapter
    // listener は ViewPager.OnPageChangeListener サブクラスのインスタンス。詳細は後述。
    viewPager.addOnPageChangeListener(listener)
    // 最初は 1ページ目から始める。
    viewPager.setCurrentItem(1, false)
```

listener は `ViewPager.OnPageChangeListener` サブクラスのインスタンスで、この実装の肝です。ページが変わったというイベントをキャッチして、必要に応じて別のページに飛ぶという実装をここで行います。

```kotlin
    val listener: ViewPager.OnPageChangeListener = object: ViewPager.OnPageChangeListener {

        private var jumpPosition = -1;


        override fun onPageScrolled(
            position: Int,
            positionOffset: Float,
            positionOffsetPixels: Int
        ) {
            // 何もしない
        }

        override fun onPageSelected(position: Int) {
            if (position == 0) {
                jumpPosition = 3

                adapter.rewindData() // 先ほど用意した、前データの補填 method
            } else if (position == 4) {
                jumpPosition = 1;

                adapter.forwardData() // 先ほど用意した、後データの補填 method
            }
        }

        override fun onPageScrollStateChanged(state: Int) {
            //アニメーションが終わるのを待ってから飛ぶ。
            // onPageSelected(int position) でやると、スクロールアニメーションがキャンセルされてしまう。
            if (state == ViewPager.SCROLL_STATE_IDLE && jumpPosition >= 0) {
                view!!.viewPager.setCurrentItem(jumpPosition, false);
                jumpPosition = -1;
            }
        }
    }
```

# 結果

このように、永遠にスクロールし続ける画面を作れます。

![infinitescroll.gif](https://qiita-image-store.s3.amazonaws.com/0/108030/95e246da-b9ca-fe3b-f42f-f62c91c5c28a.gif)

# まとめと考察

iOS で `UIScrollView` を使った時は、すべてのスクロールイベントを捉えてページをアジャストすることができたので、用意するページは 3ページで済みました。が、今回 `ViewPager` では、捉えられるのはページにスナップした時のイベントのみで、スクロール途中のイベントを受け取れないため、5ページが必要。iOS でも `UIPageViewController` なんかを使うと、まったく同じような発想で 5ページが必要になるんだと思います。

記事の内容を含んだソースコードは[こちら](https://github.com/yfujiki/Android-PeekABoo)です。