Android のページングの基本

1. はじめに

学習内容

  • ページング ライブラリの主なコンポーネントについて
  • プロジェクトにページング ライブラリを追加する方法

作成するアプリの概要

この Codelab では、すでに記事のリストを表示するようになっているサンプルアプリを使用します。リストは静的で、500 件の記事があり、すべてスマートフォンのメモリに保存されています。

7d256d9c74e3b3f5.png

この Codelab を通じて、以下を行います。

  • ページネーションをコンセプトとして紹介します。
  • ページング ライブラリのコア コンポーネントを紹介します。
  • ページング ライブラリを使用してページネーションを実装する方法を説明します。

完了すると、アプリは次のようになります。

  • ページネーションが適切に実装される。
  • さらにデータが取得されたとき、ユーザーに効果的に通知する。

最終的に UI は次のようになります。

6277154193f7580.gif

必要なもの

推奨

2. 環境をセットアップする

このステップでは、Codelab 全体のコードをダウンロードし、その後、簡単なサンプルアプリを実行します。

できるだけすぐに開始できるように、たたき台として利用できるスターター プロジェクトを用意しています。

git がインストールされている場合は、以下のコマンドをそのまま実行できます。git がインストールされているかどうかを確認するには、ターミナルまたはコマンドラインで「git --version」と入力し、正しく実行されることを確認します。

 git clone https://github.com/googlecodelabs/android-paging

git がない場合は、次のボタンをクリックして、この Codelab のすべてのコードをダウンロードできます。

コードは basicadvanced という 2 つのフォルダに分けられています。この Codelab では、basic フォルダのみを使用します。

basic フォルダにはもう 2 つ、startend というフォルダがあります。まず start フォルダ内のコードを扱います。Codelab の最後には、start フォルダ内のコードが end フォルダ内のコードと同じになります。

  1. Android Studio で basic/start ディレクトリのプロジェクトを開きます。
  2. デバイスまたはエミュレータで app 実行構成を実行します。

89af884fa2d4e709.png

記事のリストが表示されます。最後までスクロールして、リストが静的であること(つまり、リストの最後に到達したとき、それ以上アイテムが取得されないこと)を確認します。一番上までスクロールして戻り、まだすべてのアイテムがあることを確認します。

3. ページネーションの概要

ユーザーに情報を表示する最も一般的な方法として、リストを使用することが挙げられます。しかしそうしたリストでは、ユーザーが利用できるすべてのコンテンツに対して、ごく小さな範囲しか表示されないことがあります。ユーザーが利用可能な情報をスクロールしていくと、多くの場合、すでに表示されている情報を補足するために、より多くのデータが取得されることが予期されます。データを取得するたびに、効率的かつシームレスに行い、増分読み込みでユーザー エクスペリエンスを損なわないようにする必要があります。また増分読み込みでは、アプリが大量のデータをメモリに一度に保持する必要がないため、パフォーマンス上のメリットも得られます。

情報を段階的に取得するこのプロセスを「ページネーション」といい、各「ページ」は、取得するデータのチャンクに対応します。ページをリクエストするために、ページングされるデータソースに、必要な情報を定義するクエリが必要となることがよくあります。この Codelab の残りの部分では、ページング ライブラリを紹介し、アプリにページネーションを迅速かつ効率的に実装するためにどのように役立つかについて説明します。

ページング ライブラリのコア コンポーネント

ページング ライブラリのコア コンポーネントは次のとおりです。

  • PagingSource - 特定のページクエリのデータチャンクを読み込むための基本クラス。これはデータレイヤの一部であり、通常は DataSource クラスから公開され、その後 Repository によって ViewModel で使用するために公開されます。
  • PagingConfig - ページング動作を決定するパラメータを定義するクラス。これには、ページサイズや、プレースホルダが有効かどうかなどが含まれます。
  • Pager - PagingData ストリームの生成を担うクラス。そのために PagingSource に依存します。ViewModel に作成する必要があります。
  • PagingData - ページングされたデータのコンテナ。データを更新するたびに、自身の PagingSource に支えられた、対応する個別の PagingData 出力を行います。
  • PagingDataAdapter - RecyclerViewPagingData を提示する RecyclerView.Adapter サブクラス。PagingDataAdapter は、Kotlin FlowLiveData、RxJava Flowable、RxJava Observable、に接続でき、さらに、ファクトリ メソッドを使用して静的リストに接続できます。PagingDataAdapter は、内部の PagingData 読み込みイベントをリッスンし、ページの読み込みに合わせて UI を効率的に更新します。

566d0f6506f39480.jpeg

以降のセクションでは、上記の各コンポーネントの例を実装します。

4. プロジェクトの概要

現在のアプリでは、記事の静的なリストが表示されます。各記事には、タイトル、説明、作成日があります。静的なリストは、アイテムが少ないうちは適切に機能するものの、データセットが大きくなると適切にスケーリングできなくなります。この問題はページング ライブラリを使用してページネーションを実装することで解決しますが、まずはアプリの既存のコンポーネントを確認しましょう。

このアプリは、アプリ アーキテクチャ ガイドで推奨されているアーキテクチャに沿ったものです。各パッケージの内容を以下に示します。

データレイヤ:

  • ArticleRepository: 記事のリストを提供し、メモリに保持します。
  • Article: データレイヤからの情報の表現である「データモデル」を表すクラス。

UI レイヤ:

  • ActivityRecyclerView.AdapterRecyclerView.ViewHolder: UI にリストを表示するクラス。
  • ViewModel: UI が表示する必要のある状態を作成する状態ホルダー。

リポジトリは、articleStream フィールドを持つ Flow ですべての記事を公開します。UI レイヤの ArticleViewModel によって順番に読み取られ、state フィールドを持つ ArticleActivityStateFlow)の UI で使用するために準備されます。

記事をリポジトリからの Flow として公開することで、リポジトリは、時間の経過とともに変化したときに提示する記事を更新できます。たとえば記事のタイトルが変更された場合、その変更を articleStream のコレクタに簡単に伝えることができます。ViewModel で UI 状態に StateFlow を使用すると、UI 状態の収集を停止しても(設定変更の際に Activity が再作成された場合など)、収集を再開した時点で、中断したところから続けることができます。

前述のように、リポジトリの現在の articleStream は、当日のニュースのみを提示します。一部のユーザーにとってはこれで十分でも、当日の記事すべてをスクロールした後で過去の記事を確認したいユーザーもいる可能性があります。このような期待から、記事の表示はページネーションの対象として適しています。記事のページングを検討すべき理由としては、他に次のようなものがあります。

  • ViewModel は、メモリに読み込んだすべてのアイテムを items StateFlow に保持します。データセットが非常に大きくなった場合、パフォーマンスに影響を与える可能性があるため、この点は重要です。
  • 変更されたときにリスト内の 1 つまたは複数の記事を更新すると、記事のリストが大きくなるほどコストも高くなります。

ページング ライブラリを使用すると、こうした問題をすべて解決しつつ、アプリでデータを段階的に取得(ページネーション)するための一貫した API を提供できます。

5. データのソースを定義する

ページネーションを実装するときは、次の条件を満たしていることを確認する必要があります。

  • UI からのデータに対するリクエストを適切に処理し、同じクエリに対して複数のリクエストが同時にトリガーされないようにする。
  • 取得したデータを管理しやすい量でメモリに保持する。
  • すでに取得しているデータを補完するために、さらにデータを取得するリクエストをトリガーする。

これはすべて PagingSource で実現できます。PagingSource は、増分チャンクでデータを取得する方法を指定することで、データのソースを定義します。PagingData オブジェクトは、RecyclerView でのスクロールで生成される読み込みヒントに応答して、PagingSource からデータを取得します。

PagingSource は記事を読み込みます。data/Article.kt で、モデルは次のように定義されます。:

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)

PagingSource を構築するには、以下を定義する必要があります。

  • ページングキーのタイプ - より多くのデータをリクエストするために使用するページクエリのタイプの定義。今回は ID が順番に増加することが確実であるため、特定の記事 ID の前または後の記事を取得します。
  • 読み込むデータのタイプ - 各ページは記事の List を返すため、タイプは Article です。
  • データの取得元 - 通常は、データベース、ネットワーク リソース、またはその他のページングされたデータのソースです。ただし、この Codelab ではローカルで生成したデータを使用します。

data パッケージで、ArticlePagingSource.kt という新しいファイルに PagingSource の実装を作成します。

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource
import androidx.paging.PagingState

class ArticlePagingSource : PagingSource<Int, Article>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        TODO("Not yet implemented")
    }
}

PagingSource では、load()getRefreshKey() という 2 つの関数を実装する必要があります。

load() 関数はページング ライブラリによって呼び出され、ユーザーがスクロールしたときに表示される追加のデータを非同期で取得します。LoadParams オブジェクトは、読み込み操作に関する次のような情報を保持します。

  • 読み込むページのキー - load() が初めて呼び出された場合、LoadParams.keynull になります。今回は、最初のページキーを定義する必要があります。このプロジェクトでは、記事 ID をキーとして使用します。また、最初のページキーの ArticlePagingSource ファイルの先頭に、STARTING_KEY 定数 0 を追加します。
  • 読み込みサイズ - 読み込むアイテムのリクエスト数。

load() 関数は LoadResult を返します。LoadResult のタイプは次のいずれかです。

  • LoadResult.Page: 結果が成功の場合。
  • LoadResult.Error: エラーの場合。
  • LoadResult.Invalid: 結果の整合性が保証されなくなったために PagingSource を無効にする必要がある場合。

LoadResult.Page の必須の引数は次の 3 つです。

  • data: 取得したアイテムの List
  • prevKey: 現在のページより前のアイテムを取得する必要がある場合に load() メソッドで使用するキー。
  • nextKey: 現在のページより後のアイテムを取得する必要がある場合に load() メソッドで使用するキー。

省略可能な引数は次の 2 つです。

  • itemsBefore: 読み込んだデータの前に表示するプレースホルダの数。
  • itemsAfter: 読み込んだデータの後に表示するプレースホルダの数。

読み込みのキーは Article.id フィールドです。Article ID は記事ごとに 1 つずつ増加するため、キーとして使用できます。つまり、記事 ID は連続した単調増加の整数です。

nextKey または prevKey は、対応する方向に読み込むデータがない場合、null になります。この例では、prevKey の場合、次のようになります。

  • startKeySTARTING_KEY と同じ場合は、このキーより前のアイテムをさらに読み込むことはできないため、null を返します。
  • それ以外の場合は、リストの最初のアイテムを取得し、それより前の LoadParams.loadSize を読み込み、STARTING_KEY 未満のキーを返さないようにします。そのためには、ensureValidKey() メソッドを定義します。

ページングキーが有効かどうかを確認する、次の関数を追加します。

class ArticlePagingSource : PagingSource<Int, Article>() {
   ... 
   /**
     * Makes sure the paging key is never less than [STARTING_KEY]
     */
    private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
}

nextKey の場合は次のようになります。

  • 無限のアイテムの読み込みをサポートしているため、range.last + 1 を渡します。

また、各記事には created フィールドがあるため、その値を生成する必要もあります。ファイルの先頭に次の行を追加します。

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   ...
}

こうしたコードをすべて配置したら、load() 関数を実装できます。

import kotlin.math.max
...

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        // Start paging with the STARTING_KEY if this is the first load
        val start = params.key ?: STARTING_KEY
        // Load as many items as hinted by params.loadSize
        val range = start.until(start + params.loadSize)

        return LoadResult.Page(
            data = range.map { number ->
                Article(
                    // Generate consecutive increasing numbers as the article id
                    id = number,
                    title = "Article $number",
                    description = "This describes article $number",
                    created = firstArticleCreatedTime.minusDays(number.toLong())
                )
            },

            // Make sure we don't try to load items behind the STARTING_KEY
            prevKey = when (start) {
                STARTING_KEY -> null
                else -> ensureValidKey(key = range.first - params.loadSize)
            },
            nextKey = range.last + 1
        )
    }

    ...
}

次に、getRefreshKey() を実装する必要があります。このメソッドは、バッキング PagingSource のデータが変更されたために、ページング ライブラリが UI のアイテムを再読み込みする必要がある場合に呼び出されます。PagingSource の基となるデータが変更され UI で更新する必要がある状況のことを、「無効化」といいます。無効化されると、ページング ライブラリはデータを再読み込みするために新しい PagingSource を作成し、新しい PagingData を出力することで UI に通知します。無効化については後のセクションで詳しく説明します。

新しい PagingSource から読み込む場合、getRefreshKey() を呼び出し、新しい PagingSource が読み込みを開始する必要のあるキーを提供して、更新した後にユーザーがリスト内での現在位置を見失わないようにします。

ページング ライブラリの無効化が発生する理由は、次の 2 つのいずれかです。

  • PagingAdapter に対して refresh() を呼び出した
  • PagingSource に対して invalidate() を呼び出した

返されたキー(この例では Int)は、LoadParams 引数を介して、新しい PagingSource における load() メソッドの次回の呼び出しに渡されます。無効化の後にアイテムが飛ばないよう、返されたキーが、画面を埋めるために十分な数のアイテムを読み込むようにする必要があります。こうすることで、無効化されたデータ内に存在していたアイテムが、新しいアイテムのセットに含まれる可能性が高まり、現在のスクロール位置を維持できるようになります。アプリの実装を確認しましょう。

   // The refresh key is used for the initial load of the next PagingSource, after invalidation
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        // In our case we grab the item closest to the anchor position
        // then return its id - (state.config.pageSize / 2) as a buffer
        val anchorPosition = state.anchorPosition ?: return null
        val article = state.closestItemToPosition(anchorPosition) ?: return null
        return ensureValidKey(key = article.id - (state.config.pageSize / 2))
    }

上のスニペットでは PagingState.anchorPosition を使用しています。これが、ページング ライブラリでどのようにして追加のアイテムを取得するのかについて知る手がかりとなります。UI は、PagingData からアイテムを読み取ろうとするとき、特定のインデックスで読み取ろうとします。データが読み取られた場合、そのデータが UI に表示されます。しかしデータがない場合、ページング ライブラリは、失敗した読み取りリクエストを満たすためにデータを取得する必要があることを認識します。読み取り時にデータを正常に取得した最後のインデックスは anchorPosition です。

更新するときは、anchorPosition に最も近い Article のキーを取得して、読み込みキーとして使用します。こうして、新しい PagingSource からの読み込みを再開すると、取得したアイテムのセットには読み込み済みのアイテムが含まれるため、スムーズかつ一貫したユーザー エクスペリエンスが実現します。

これで PagingSource の定義が完了しました。次のステップは UI への接続です。

6. UI 用の PagingData を生成する

現在の実装では、ArticleRepositoryFlow<List<Article>> を使用して、読み込んだデータを ViewModel に公開しています。ViewModel は、UI に公開するために stateIn 演算子を使用して、データの常時利用可能な状態を維持します。

ページング ライブラリでは、代わりに ViewModel から Flow<PagingData<Article>> を公開します。PagingData は読み込んだデータをラップし、ページング ライブラリが追加のデータを取得するタイミングを決定します。また、同じページを 2 回リクエストしないようにします。

PagingData を作成するために、PagingData をアプリの他のレイヤに渡すために使用する API に応じて、Pager クラスのさまざまなビルダー メソッドのうちのいずれかを使用します。

  • Kotlin Flow - Pager.flow を使用
  • LiveData - Pager.liveData を使用
  • RxJava Flowable - Pager.flowable を使用
  • RxJava Observable - Pager.observable を使用

すでにアプリで Flow を使用しているので、このアプローチで続けますが、Flow<List<Article>> を使用する代わりに Flow<PagingData<Article>> を使用します。

どの PagingData ビルダーを使用する場合でも、次のパラメータを渡す必要があります。

  • PagingConfig。このクラスは、先読みの量や初期読み込みのサイズ リクエストなど、PagingSource からコンテンツを読み込む方法のオプションを設定します。定義する必要のある必須のパラメータは、ページサイズ(各ページに読み込む必要のあるアイテムの数)のみです。デフォルトで、ページングは読み込んだページをすべてメモリに保持します。スクロールしたときにメモリが浪費されないようにするには、PagingConfigmaxSize パラメータを設定します。デフォルトでは、ページングが読み込まれていないアイテムを数えることができ、かつ enablePlaceholders 設定フラグが true である場合、ページングはまだ読み込まれていないコンテンツのプレースホルダとして null アイテムを返します。このようにして、アダプタにプレースホルダ ビューを表示できます。この Codelab では作業を簡単にするために、enablePlaceholders = false を渡して、プレースホルダを無効にしましょう。
  • PagingSource の作成方法を定義する関数。ここでは ArticlePagingSource を作成するため、その方法をページング ライブラリに伝える関数が必要です。

では、ArticleRepository を変更してみましょう。

ArticleRepository更新する

  • articlesStream フィールドを削除します。
  • 作成した ArticlePagingSource を返す articlePagingSource() というメソッドを追加します。
class ArticleRepository {

    fun articlePagingSource() = ArticlePagingSource()
}

ArticleRepository をクリーンアップする

ページング ライブラリはさまざまな処理を行います。

  • メモリ内キャッシュの処理
  • リストの最後に近づいたときのデータのリクエスト

つまり、ArticleRepositoryarticlePagingSource() 以外はすべて削除できます。ArticleRepository ファイルは次のようになります。

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource

class ArticleRepository {
    fun articlePagingSource() = ArticlePagingSource()
}

ArticleViewModel でコンパイル エラーが表示されるはずです。ここでどのような変更が必要か見てみましょう。

7. ViewModel で PagingData をリクエストしてキャッシュする

コンパイル エラーに対処する前に、ViewModel を確認してみましょう。

class ArticleViewModel(...) : ViewModel() {

    val items: StateFlow<List<Article>> = ...
}

ページング ライブラリを ViewModel に統合するために、items の戻り値の型を StateFlow<List<Article>> から Flow<PagingData<Article>> に変更します。そのためには、まずファイルの先頭に ITEMS_PER_PAGE というプライベート定数を追加します。

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel {
    ...
}

次に、Pager インスタンスの出力結果となるように items を更新します。これを行うには、Pager に 2 つのパラメータを渡します。

  • pageSizeITEMS_PER_PAGE であってプレースホルダが無効になっている PagingConfig
  • 作成した ArticlePagingSource のインスタンスを提供する PagingSourceFactory
class ArticleViewModel(...) : ViewModel() {

   val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        ...
}

次に、設定またはナビゲーションの変更があってもページング状態を維持するために、cachedIn() メソッドを使用して androidx.lifecycle.viewModelScope を渡します。

上記の変更が完了すると、ViewModel は次のようになります。

package com.example.android.codelabs.paging.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.example.android.codelabs.paging.data.Article
import com.example.android.codelabs.paging.data.ArticleRepository
import com.example.android.codelabs.paging.data.ITEMS_PER_PAGE
import kotlinx.coroutines.flow.Flow

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel(
    private val repository: ArticleRepository,
) : ViewModel() {

    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        .cachedIn(viewModelScope)
}

PagingData についてもう 1 つ注意すべき点は、自己完結型であるということです。RecyclerView に表示されるデータを更新するための変更可能なストリームが含まれています。PagingData の出力はそれぞれ完全に独立しており、基となるデータセットが変更されたためにバッキング PagingSource が無効化されると、1 つのクエリに対して複数の PagingData インスタンスが出力されることがあります。そのため、PagingDataFlows は他の Flows とは独立して公開する必要があります。

これで、ViewModel にページング機能を追加できました。

8. Adapter を PagingData と連携させる

PagingDataRecyclerView にバインドするには、PagingDataAdapter を使用します。PagingDataAdapter は、PagingData コンテンツが読み込まれるたびに通知を受け、更新するよう RecyclerView に伝えます。

PagingData ストリームと連携するように ArticleAdapter を更新する

  • 現在、ArticleAdapterListAdapter を実装していますが、これを PagingDataAdapter を実装するようにします。クラス本体の残りは変更しません。
import androidx.paging.PagingDataAdapter
...

class ArticleAdapter : PagingDataAdapter<Article, RepoViewHolder>(ARTICLE_DIFF_CALLBACK) {
// body is unchanged
}

ここまでに多くの変更を加えてきましたが、もう一歩でアプリを実行できるところまで来ました。あとは UI を接続するだけです。

9. UI で PagingData を使用する

現在の実装には、特定の条件が満たされた場合に ViewModel を呼び出して追加のデータを読み込む、binding.setupScrollListener() というメソッドがあります。ページング ライブラリはこれをすべて自動的に行うため、このメソッドとその使用方法は削除できます。

次に、ArticleAdapterListAdapter ではなく PagingDataAdapter になったため、小さな変更を 2 つ加えます。

  • Flow の終了演算子を、ViewModel から collect ではなく collectLatest に切り替えます。
  • submitList() ではなく submitData() を使用して、ArticleAdapter に変更を通知します。

pagingData FlowcollectLatest を使用して、新しい pagingData インスタンスが出力されたときに、pagingData の以前の出力に関する収集がキャンセルされるようにします。

こうした変更を行うと、Activity は次のようになります。

import kotlinx.coroutines.flow.collectLatest

class ArticleActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityArticlesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        val viewModel by viewModels<ArticleViewModel>(
            factoryProducer = { Injection.provideViewModelFactory(owner = this) }
        )

        val items = viewModel.items
        val articleAdapter = ArticleAdapter()

        binding.bindAdapter(articleAdapter = articleAdapter)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                items.collectLatest {
                    articleAdapter.submitData(it)
                }
            }
        }
    }
}

private fun ActivityArticlesBinding.bindAdapter(
    articleAdapter: ArticleAdapter
) {
    list.adapter = articleAdapter
    list.layoutManager = LinearLayoutManager(list.context)
    val decoration = DividerItemDecoration(list.context, DividerItemDecoration.VERTICAL)
    list.addItemDecoration(decoration)
}

アプリがコンパイルされて実行されます。これで、アプリをページング ライブラリに移行できました。

f97136863cfa19a0.gif

10. 読み込み状態を UI に表示する

ページング ライブラリが追加のアイテムを取得して UI に表示するときは、ユーザーに対し、追加のデータがある旨を通知することをおすすめします。幸い、ページング ライブラリには、CombinedLoadStates 型で読み込み状態にアクセスする便利な方法が用意されています。

CombinedLoadStates インスタンスは、データを読み込むページング ライブラリ内の、すべてのコンポーネントの読み込み状態を記述します。今回は ArticlePagingSourceLoadState だけに着目するため、主に CombinedLoadStates.source フィールドの LoadStates 型を使用します。CombinedLoadStates には、PagingDataAdapter.loadStateFlow を介して PagingDataAdapter からアクセスできます。

CombinedLoadStates.sourceLoadStates 型であり、3 種類の LoadState のフィールドがあります。

  • LoadStates.append: ユーザーの現在位置より後に取得されるアイテムの LoadState 用。
  • LoadStates.prepend: ユーザーの現在位置より前に取得されるアイテムの LoadState 用。
  • LoadStates.refresh: 初期読み込みの LoadState 用。

LoadState 自体は次のいずれかです。

  • LoadState.Loading: アイテムを読み込んでいます。
  • LoadState.NotLoading: アイテムを読み込んでいません。
  • LoadState.Error: 読み込みエラーが発生しました。

今回は、ArticlePagingSource にエラーケースが含まれていないため、LoadStateLoadState.Loading であるかどうかだけを考慮します。

まず、UI の上部と下部に進行状況バーを追加し、いずれかの方向の取得について、読み込み状態を示します。

activity_articles.xml で、次のように 2 つの LinearProgressIndicator バーを追加します。

<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=".ui.ArticleActivity">

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

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/prepend_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/append_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

次に、PagingDataAdapter から LoadStatesFlow を収集して CombinedLoadState に対応します。ArticleActivity.kt で状態を収集します。

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                articleAdapter.loadStateFlow.collect {
                    binding.prependProgress.isVisible = it.source.prepend is Loading
                    binding.appendProgress.isVisible = it.source.append is Loading
                }
            }
        }
        lifecycleScope.launch {
        ...
    }

最後に、ArticlePagingSource に遅延を少し加えて読み込みをシミュレートします。

private const val LOAD_DELAY_MILLIS = 3_000L

class ArticlePagingSource : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = startKey.until(startKey + params.loadSize)

        if (start != STARTING_KEY) delay(LOAD_DELAY_MILLIS)
        return ...

}

アプリをもう一度実行し、リストの一番下までスクロールします。ページング ライブラリが追加のアイテムを取得している間は下部に進行状況バーが表示され、取得が完了すると消えます。

6277154193f7580.gif

11. まとめ

今回の内容を振り返りましょう。

  • ページネーションの概要と、それが必要な理由を確認しました。
  • Pager を作成し、PagingSource を定義して、PagingData を出力することで、アプリにページネーションを追加しました。
  • cachedIn 演算子を使用して、ViewModelPagingData をキャッシュに保存しました。
  • PagingDataAdapter を使用して UI で PagingData を使用しました。
  • PagingDataAdapter.loadStateFlow を使用して CombinedLoadStates に対応しました。

以上です。高度なページングのコンセプトについて詳しくは、高度なページングに関する Codelab をご覧ください。