AndroidPaging3Tips
はじめに
この記事では、Paging3実装時のTipsを紹介します。
前提知識
この記事はPaging3の高度コースを終えた方を対象としています。
基本的なPagingSource、PagingDataAdapterの実装は理解している前提で進めます。
参考リンク
PagingConfigの注意点
initialLoadSizeのデフォルト値
PagingConfigのデフォルト設定では、initialLoadSizeがpageSizeの3倍になります。
これは初回ロード時に上下のプリフェッチを考慮した設計ですが、API設計によっては扱いにくい場合があります。
// デフォルト設定(initialLoadSize = pageSize * 3 = 60)
Pager(PagingConfig(pageSize = 20)) { ... }
// initialLoadSizeを明示的に指定(推奨)
Pager(
PagingConfig(
pageSize = 20,
initialLoadSize = 20 // pageSizeと同じ値にすると扱いやすい
)
) { ... }
この設定により、初回ロードとページングのロードで同じ件数を取得できるため、
APIのレスポンス処理やテストが簡潔になります。
Placeholderの実装
CodelabではPlaceholderについて詳しく触れていないため、実装方法をまとめます。
基本設定
まず、PagingConfigでenablePlaceholdersをtrue(デフォルト設定)にします。
次に、PagingSourceのLoadResult.PageでitemsBeforeとitemsAfterを指定します。
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の表示内容
PagingDataAdapterのonBindViewHolderで、アイテムが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を有効にすることを検討してください。
データのリフレッシュ
PagingDataはViewModelでキャッシュされるため、
画面を再表示したときに最新データを取得したい場合は、明示的に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の仕組み
内部実装を見ると、ConcatAdapterとaddLoadStateListenerを使用しているだけです。
また、PagingDataAdapterはloadStateFlowを公開しているため、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