📃

AndroidPaging3Tips

に公開

はじめに

この記事では、Paging3実装時のTipsを紹介します。

前提知識
この記事はPaging3の高度コースを終えた方を対象としています。
基本的なPagingSourcePagingDataAdapterの実装は理解している前提で進めます。

参考リンク

PagingConfigの注意点

initialLoadSizeのデフォルト値

PagingConfigのデフォルト設定では、initialLoadSizepageSizeの3倍になります。
これは初回ロード時に上下のプリフェッチを考慮した設計ですが、API設計によっては扱いにくい場合があります。

// デフォルト設定(initialLoadSize = pageSize * 3 = 60)
Pager(PagingConfig(pageSize = 20)) { ... }

// initialLoadSizeを明示的に指定(推奨)
Pager(
    PagingConfig(
        pageSize = 20,
        initialLoadSize = 20  // pageSizeと同じ値にすると扱いやすい
    )
) { ... }

この設定により、初回ロードとページングのロードで同じ件数を取得できるため、
APIのレスポンス処理やテストが簡潔になります。

Placeholderの実装

CodelabではPlaceholderについて詳しく触れていないため、実装方法をまとめます。

基本設定

まず、PagingConfigenablePlaceholderstrue(デフォルト設定)にします。
次に、PagingSourceLoadResult.PageitemsBeforeitemsAfterを指定します。

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
    val page = params.key ?: 0
    val response = api.getItems(page = page, limit = params.loadSize)
    
    return LoadResult.Page(
        data = response.items,
        prevKey = if (page == 0) null else page - 1,
        nextKey = if (response.items.isEmpty()) null else page + 1,
        // Placeholderの設定
        itemsBefore = page * params.loadSize,  // または offset
        itemsAfter = if (response.hasMore) params.loadSize else 0
    )
}

itemsAfterの注意点

最後のページでアイテム数がpageSizeより少ない場合、
Placeholderを取り除くアニメーションが表示されます。
APIレスポンスに総アイテム数(total)が含まれる場合は、より正確な値を設定できます。

itemsAfter = maxOf(0, response.total - (page + 1) * params.loadSize)

Placeholderの表示内容

PagingDataAdapteronBindViewHolderで、アイテムがnullの場合の表示を実装します。

// ViewHolder
fun bind(item: Item?) {
    if (item == null) {
        showPlaceholder() // Placeholderの表示(スケルトンスクリーンなど)
        return
    }
    // 既存の表示
}

Placeholderとスクロール位置

Placeholderを無効にしている場合、リフレッシュ時にスクロール位置が変わることがあります。

例: pageSize=10の場合

                 表示位置
Refresh前:       [8] [9] [10] [11] [12]
Placeholder有:   [8] [9] [10] [null] [null]  // 位置維持
Placeholder無:   [6] [7] [8] [9] [10]        // 位置ずれ

スクロール位置を維持したい場合は、Placeholderを有効にすることを検討してください。

データのリフレッシュ

PagingDataViewModelでキャッシュされるため、
画面を再表示したときに最新データを取得したい場合は、明示的にrefreshする必要があります。

リフレッシュのタイミング問題

詳しい仕様までは把握していませんが、
PagingDataAdapter.refresh()を呼び出すタイミングには注意が必要です。
うまくリフレッシュされない場合onDestroyViewなどでのrefreshも試してみてください。
adapterを保持する場合はメモリーリークに注意してください。

private var _adapter: MyPagingDataAdapter? = null

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    _adapter = MyPagingDataAdapter()
    // ...
}

override fun onDestroyView() {
    super.onDestroyView()
    _adapter?.refresh()  // 次回表示時に最新データをロード
    _adapter = null  // メモリリーク防止
}

LoadStateのカスタマイズ

高度コースではwithLoadStateHeaderAndFooter()を使ったプログレスバー表示を学びますが、
SwipeRefreshLayoutとの併用や細かい制御が必要な場合は、カスタム実装が必要です。

withLoadStateHeaderAndFooterの仕組み

内部実装を見ると、ConcatAdapteraddLoadStateListenerを使用しているだけです。
また、PagingDataAdapterloadStateFlowを公開しているため、Flowベースで制御できます。

カスタム実装例

簡単な一例を置いておきます。

val pagingAdapter = CardListAdapter() //カスタムPagingDataAdapter
val headerAdapter = LoadingProgressAdapter() // カスタムLoadStateAdapter
val footerAdapter = LoadingProgressAdapter() // カスタムLoadStateAdapter
recyclerView.adapter = ConcatAdapter(headerAdapter, pagingAdapter, footerAdapter)
pagingAdapter.loadStateFlow
    .onEach {
        when (it.refresh) {
            is LoadState.Loading -> onRefreshLoading(binding, pagingAdapter)
            is LoadState.NotLoading -> onRefreshSuccess(binding, pagingAdapter)
            is LoadState.Error -> onRefreshError(binding, pagingAdapter)
        }
        if(it.prepend is LoadState.Error || it.append is LoadState.Error) {
            headerAdapter.loadState = LoadState.NotLoading(false)
            footerAdapter.loadState = LoadState.NotLoading(false)
            return@onEach showErrorToast(binding)
        }
        headerAdapter.loadState = it.prepend
        footerAdapter.loadState = it.append
    }
    .launchIn(viewLifecycleOwner.lifecycleScope)
private fun onRefreshLoading(
    binding: FragmentListBinding,
    pagingAdapter: PagingDataAdapter<*, *>
) {
    if (isAdditionalLoading(pagingAdapter, binding.swipeRefreshLayout)) return
    binding.progressBar.isVisible = true
}

private fun onRefreshSuccess(
    binding: FragmentListBinding,
    pagingAdapter: PagingDataAdapter<*, *>
) {
    if (pagingAdapter.itemCount == 0) return showEmptyView(binding)
    showContents(binding)
}

private fun onRefreshError(
    binding: FragmentListBinding,
    pagingAdapter: PagingDataAdapter<*, *>
) {
    if (isAdditionalLoading(pagingAdapter, binding.swipeRefreshLayout)) return showErrorToast(binding)
    showErrorView(binding, "エラーしました。")
}

private fun isAdditionalLoading(
    pagingAdapter: PagingDataAdapter<*, *>,
    swipeRefreshLayout: SwipeRefreshLayout
) = pagingAdapter.itemCount != 0 || swipeRefreshLayout.isRefreshing
class LoadingProgressAdapter : LoadStateAdapter<RecyclerView.ViewHolder>() {
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, loadState: LoadState) {
        holder.itemView.isVisible = loadState is LoadState.Loading
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        loadState: LoadState
    ): RecyclerView.ViewHolder {
        val progressBar = ProgressBar(parent.context)
        progressBar.layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
        return object : RecyclerView.ViewHolder(progressBar) {}
    }
}
株式会社ソニックムーブ

Discussion