【Android】RecyclerView で無限スクロールを実装する

# やりたいこと

RecyclerView で作ったリストに無限スクロールを実装してみます。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/238891/1c13ba73-d808-faa0-d8ba-010b08468f8f.gif">

API でデータセットを取得してリスト表示するような機能を想定しています。

画面生成時にリストを 10 件しておき、リストの下端までスクロールしたタイミングで 10 件ずつ追加で表示するサンプルアプリを作っていきます。

最終的に作成したアプリは Github で公開していますので参考にしていただければと思います。

https://github.com/yudai0308/infinite_scroll_sample

# 実装手順

## 1. レイアウトファイル作成

RecyclerView のレイアウトと、リストに表示させる１行分のレイアウトを作成します。

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:visibility="invisible"/>

</androidx.constraintlayout.widget.ConstraintLayout>
```

```list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/list_item"
    android:layout_width="match_parent"
    android:layout_height="80dp"
    android:gravity="center_vertical"
    android:padding="16dp"
    android:textSize="20sp">
</TextView>
```

## 2. ビューホルダーとアダプターの作成

リストに表示させるデータを保持するビューホルダーと、ビューホルダーを管理してリストを画面表示するロジックを担うアダプターを作成します。

```MyAdapter.kt
class MyAdapter(private val listData: MutableList<String>) : RecyclerView.Adapter<MyAdapter.MyViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val textView = inflater.inflate(R.layout.list_item, parent, false) as TextView
        return MyViewHolder(textView)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.textView.text = listData[position]
    }

    override fun getItemCount(): Int {
        return listData.size
    }

    /**
     * リストデータを追加して画面に反映させるメソッド。
     */
    fun add(listData: List<String>) {
        this.listData += listData
        notifyDataSetChanged()
    }

    class MyViewHolder(val textView: TextView): RecyclerView.ViewHolder(textView)
}
```

MyAdapter クラス内に `add()` メソッドを作成しています。

リスト下端までスクロールしたタイミングでデータを追加で取得するので、取得したデータをこのメソッドに渡してリストを更新するようにします。

## 3. 無限スクロールの実装

MainActivity に無限スクロールの実装を行います。

```MainActivity.kt
class MainActivity : AppCompatActivity() {

    /**
     * API から取得するデータセットを想定したプロパティ。
     */
    private val dataSet = mutableListOf<String>()

    /**
     * API に問い合わせ中は true になる。
     */
    private var nowLoading = false

    private lateinit var myAdapter: MyAdapter

    private val handler = Handler()

    private val progressBar by lazy { findViewById<ProgressBar>(R.id.progress_bar) }

    init {
        // 仮想データセットを生成。
        for (i in 0..99) { dataSet.add("Number $i") }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val listData = runBlocking { fetch(0) }
        myAdapter = MyAdapter(listData as MutableList<String>)
        findViewById<RecyclerView>(R.id.recycler_view).also {
            it.layoutManager = LinearLayoutManager(this)
            it.adapter = myAdapter
            it.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
            it.addOnScrollListener(InfiniteScrollListener())
        }
    }

    /**
     * API でリストデータを取得することを想定したメソッド。
     */
    private suspend fun fetch(index: Int): List<String> {
        // API 問い合わせの待ち時間を仮想的に作る。
        handler.post { progressBar.visibility = View.VISIBLE }
        delay(3000)
        handler.post { progressBar.visibility = View.INVISIBLE }

        return when (index) {
            in 0..90 -> dataSet.slice(index..index + 9)
            in 91..99 -> dataSet.slice(index..99)
            else -> listOf()
        }
    }

    /**
     * API でリストデータを取得して画面に反映することを想定したメソッド。
     */
    private suspend fun fetchAndUpdate(index: Int) {
        val fetchedData = withContext(Dispatchers.Default) {
            fetch(index)
        }

        // 取得したデータを画面に反映。
        if (fetchedData.isNotEmpty()) {
            handler.post { myAdapter.add(fetchedData) }
        }
        // 問い合わせが完了したら false に戻す。
        nowLoading = false
    }

    /**
     * リストの下端までスクロールしたタイミングで発火するリスナー。
     */
    inner class InfiniteScrollListener : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)

            // アダプターが保持しているアイテムの合計
            val itemCount = myAdapter.itemCount
            // 画面に表示されているアイテム数
            val childCount = recyclerView.childCount
            val manager = recyclerView.layoutManager as LinearLayoutManager
            // 画面に表示されている一番上のアイテムの位置
            val firstPosition = manager.findFirstVisibleItemPosition()

            // 何度もリクエストしないようにロード中は何もしない。
            if (nowLoading) {
                return
            }

            // 以下の条件に当てはまれば一番下までスクロールされたと判断できる。
            if (itemCount == childCount + firstPosition) {
                // API 問い合わせ中は true となる。
                nowLoading = true
                GlobalScope.launch {
                    fetchAndUpdate(myAdapter.itemCount)
                }
            }
        }
    }
}
```

### `init {...}` について

今回は実際に API へ問い合わせは行いませんので、その代わりに文字列型のリストデータを作成しています。

### `onCreate()` について

`fetch()` メソッドで API からリストデータを取得し、そのデータを引数として MyAdapter オブジェクトを作成しています。

また、RecyclerView にレイアウトマネージャー、アダプター、デコレーション（区切り線）、スクロールリスナー（無限スクロール）を設定しています。

### `fetch()` について

API に問い合わせてリストデータを 10 件取得することを想定したメソッドです。インデックスを引数として取るので、仮に引数が 20 だった場合、20 ~ 29 のデータを返却します。

取得までにかかる時間を `delay()` で仮想的に実装しています。待ち時間は ProgressBar を表示させて、さらにそれっぽくしてます。

### `fetchAndUpdate()` について

API からのデータ取得と、取得したデータを画面に反映させる処理を一緒に行うメソッドです。`InfiniteScrollListener` から呼び出して使います。

### `InfiniteScrollListener` について

無限スクロールの実装部分です。

変数 itemCount、childCount、firstPosition の役割はコメントに書かれているとおりです。条件式にあるとおり `itemCount == childCount + firstPosition` のとき、下端までスクロールされたと判断できます。

![Untitled Diagram.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/238891/80afc7ee-5e05-d663-e0e1-df9f9e464349.jpeg)

下端までスクロールされてデータを取得する際、変数 nowLoading を ture にします。データを取得している途中でもユーザーが上下にスクロールすると `onScrolled()` が発火してしまい、同じデータを何度もリクエストしてしまいます。これを防止するためにリクエスト中は変数 nowLoading を true にして制御します。その後データ取得が完了したタイミングで false に戻します。

以上で冒頭の GIF と同じ無限スクロールが実装できたと思います。

# 参考

[RecyclerViewでスクロールが一番下まで行ったらロードする実装](https://qiita.com/konifar/items/e57c47bfb93d0d40ef59)