이번 영상에서는 페이징하는 데이터의 로딩상태를 추적하는 법에 대해 알아보도록 하겠습니다.
PagingDataAdapter에 리스너를 등록하면 데이터의 로딩상태를 LoadState 객체로 반환받을 수 있습니다. 우선 SearchFragment의 화면을 구성해보도록 하죠. 데이터를 로딩중일 때 표시할 ProgressBar, 로딩 에러가 발생했을 때 로딩을 다시 시작하는데 사용할 retry 버튼, 그리고 검색 결과가 없을 때 "No results"라고 표시할 TextView를 추가합니다.
<?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=".ui.fragment.SearchFragment">
...
+ <ProgressBar
+ android:id="@+id/progress_bar"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+ <Button
+ android:id="@+id/btn_retry"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Retry"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+ <TextView
+ android:id="@+id/tv_emptylist"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:gravity="center"
+ android:text="No result"
+ android:textSize="24sp"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
이제 SearchFragment에 리스너를 구성해 보겠습니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
setupLoadState()
}
private fun setupLoadState() {
// 검색 화면에서 로딩스테이트를 파악
bookSearchAdapter.addLoadStateListener { combinedLoadStates ->
val loadState = combinedLoadStates.source
val isListEmpty = bookSearchAdapter.itemCount < 1
&& loadState.refresh is LoadState.NotLoading
&& loadState.append.endOfPaginationReached
// 검색결과 표시
binding.tvEmptyList.isVisible = isListEmpty
binding.rvSearchResult.isVisible = !isListEmpty
// 로딩중
binding.progressBar.isVisible = loadState.refresh is LoadState.Loading
// 로딩 에러 발생
binding.btnRetry.isVisible = loadState.refresh is LoadState.Error
|| loadState.append is LoadState.Error
|| loadState.prepend is LoadState.Error
val errorState: LoadState.Error? = loadState.append as? LoadState.Error
?: loadState.prepend as? LoadState.Error
?: loadState.refresh as? LoadState.Error
errorState?.let {
Toast.makeText(requireContext(), it.error.message, Toast.LENGTH_SHORT).show()
}
}
binding.btnRetry.setOnClickListener {
bookSearchAdapter.retry()
}
}
BookSearchPagingAdapter에 addLoadStateListener를 달고 loadState 값을 받아옵니다. 여기서 반환받는 CombinedLoadStates는 PagingSource 및 RemoteMediator 두 가지 소스의 로딩상태를 가지고 있습니다. 여기서는 RemoteMediator는 다루지 않으므로 source의 값만을 대응하면 됩니다. LoadState에는 로딩 시작시 만들어지는 prepend, 로딩 종료시 만들어지는 append, 로딩 값 갱신시 만들어지는 refresh 세 개의 속성이 있습니다.
그럼 코드를 순서대로 설명하겠습니다. isListEmpty로 리스트가 비어있는지를 판정합니다. 아이템이 1개 미만이고, LoadState가 NotLoading이며, loadState.append.endOfPaginationReached가 true이면 리스트가 비어있다고 판정할 수 있습니다.
각 상태에 따라 뷰 컴포넌트의 표시를 활성화하거나 비활성화 하게 하면 됩니다. 로딩중일땐 프로그레스바를 표시하고, 검색결과 여부에 따라 리사이클러뷰 표시를 전환합니다. isListEmpty가 true면 리사이클러뷰를 화면에서 가리고 "No result"가 표시되도록 했습니다. 에러가 발생하면 Toast로 화면에 표시하도록 했고 Retry 버튼을 클릭하면 어댑터를 갱신하도록 클릭 리스너를 붙여주었습니다.
지금까지 로딩상태를 화면 전체에 표시하는 법에 대해 알아보았습니다. 그런데 PagingDataAdapter에 LoadStateAdapter을 연결하면 데이터의 로딩상태를 리사이클러뷰 내부의 헤더나 푸터로 표시할 수도 있습니다.
바로 구현해보도록 하죠. 우선은 layout아래에 푸터로 사용할 item_load_state 레이아웃을 작성합니다. Error를 표시하는 TextView와 로딩상태를 표시하는 ProgressBar, 그리고 로딩을 다시 시도할 때 사용할 버튼을 배치합니다.
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<TextView
android:id="@+id/tv_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Error message"
android:textColor="@color/design_default_color_error"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
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" />
<Button
android:id="@+id/btn_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Retry"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
다음은 ui/adapter 패키지에 이 xml을 사용하는 ViewHolder를 작성합니다. Retry 버튼의 기능을 지정하고 각 에러상황에 맞게 컴포넌트의 isVisible 속성을 정의합니다.
class BookSearchLoadStateViewHolder(
private val binding: ItemLoadStateBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.btnRetry.setOnClickListener {
retry.invoke()
}
}
fun bind(loadState: LoadState) {
if (loadState is LoadState.Error) {
binding.tvError.text = "Error occurred"
}
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.btnRetry.isVisible = loadState is LoadState.Error
binding.tvError.isVisible = loadState is LoadState.Error
}
}
그리고 이 ViewHolder를 사용하는 LoadStateAdapter를 작성합니다. onBindViewHolder와 onCreateViewHolder를 정의하면 됩니다.
class BookSearchLoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<BookSearchLoadStateViewHolder>() {
override fun onBindViewHolder(holder: BookSearchLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): BookSearchLoadStateViewHolder {
return BookSearchLoadStateViewHolder(
ItemLoadStateBinding.inflate(LayoutInflater.from(parent.context), parent, false),
retry
)
}
}
그리고 withLoadStateFooter로 두 어댑터를 연결시켜주면 됩니다. 헤더를 표시하는 withLoadStateHeader나 헤더와 푸터를 모두 표시하는 withLoadStateHeaderAndFooter를 사용할 수도 있습니다.
private fun setupRecyclerView() {
binding.rvSearchBooks.apply {
setHasFixedSize(true)
layoutManager =
LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
addItemDecoration(
DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)
)
- adapter = bookSearchAdapter
+ adapter = bookSearchAdapter.withLoadStateFooter(
+ footer = BookSearchLoadStateAdapter(bookSearchAdapter::retry)
+ )
}
}
그럼 이제 setupLoadState에서 로딩상태를 관리할 필요가 없으므로 주석처리해 줍니다.
private fun setupLoadState() {
// 검색 화면에서 로딩스테이트를 파악
bookSearchAdapter.addLoadStateListener { combinedLoadStates ->
val loadState = combinedLoadStates.source
val isListEmpty = bookSearchAdapter.itemCount < 1
&& loadState.refresh is LoadState.NotLoading
&& loadState.append.endOfPaginationReached
// 검색결과 표시
binding.emptyList.isVisible = isListEmpty
binding.rvSearchResult.isVisible = !isListEmpty
// 로딩중
binding.progressBar.isVisible = loadState.refresh is LoadState.Loading
- // 로딩 에러 발생
- binding.btnRetry.isVisible = loadState.refresh is LoadState.Error
- || loadState.append is LoadState.Error
- || loadState.prepend is LoadState.Error
- val errorState: LoadState.Error? = loadState.append as? LoadState.Error
- ?: loadState.prepend as? LoadState.Error
- ?: loadState.refresh as? LoadState.Error
- errorState?.let {
- Toast.makeText(requireContext(), it.error.message, Toast.LENGTH_SHORT).show()
- }
}
- binding.btnRetry.setOnClickListener {
- bookSearchAdapter.retry()
- }
}