How to show empty view while using Android Paging 3 library - android

I am using Paging 3 lib. and i am able to check if refresh state is "Loading" or "Error" but i am not sure how to check "empty" state.I am able to add following condition but i am not sure if its proper condition
adapter.loadStateFlow.collectLatest { loadStates ->
viewBinding.sflLoadingView.setVisibility(loadStates.refresh is LoadState.Loading)
viewBinding.llErrorView.setVisibility(loadStates.refresh is LoadState.Error)
viewBinding.button.setOnClickListener { pagingAdapter.refresh() }
if(loadStates.refresh is LoadState.NotLoading && (viewBinding.recyclerView.adapter as ConcatAdapter).itemCount == 0){
viewBinding.llEmptyView.setVisibility(true)
}else{
viewBinding.llEmptyView.setVisibility(false)
}
}
Also I am running into other problem
I have implemented search functionality and until more than 2 characters are entered i am using same paging source like following but the above loadstate callback is executed only once.So thats why i am not able to hide empty view if search query is cleared.I am doing so to save api call from front end.
private val originalList : LiveData<PagingData<ModelResponse>> = Transformations.switchMap(liveData){
repository.fetchSearchResults("").cachedIn(viewModelScope)
}
val list : LiveData<LiveData<PagingData<ModelResponse>>> = Transformations.switchMap{ query ->
if(query != null) {
if (query.length >= 2)
repository.fetchSearchResults(query)
else
originalList
}else
liveData { emptyList<ModelResponse>() }
}

Here is the proper way of handling the empty view in Android Paging 3:
adapter.addLoadStateListener { loadState ->
if (loadState.source.refresh is LoadState.NotLoading && loadState.append.endOfPaginationReached && adapter.itemCount < 1) {
recycleView?.isVisible = false
emptyView?.isVisible = true
} else {
recycleView?.isVisible = true
emptyView?.isVisible = false
}
}

adapter.loadStateFlow.collect {
if (it.append is LoadState.NotLoading && it.append.endOfPaginationReached) {
emptyState.isVisible = adapter.itemCount < 1
}
}
The logic is,If the append has finished (it.append is LoadState.NotLoading && it.append.endOfPaginationReached == true), and our adapter items count is zero (adapter.itemCount < 1), means there is nothing to show, so we show the empty state.
PS: for initial loading you can find out more at this answer: https://stackoverflow.com/a/67661269/2472350

I am using this way and it works.
EDITED:
dataRefreshFlow is deprecated in Version 3.0.0-alpha10, we should use loadStateFlow now.
viewLifecycleOwner.lifecycleScope.launch {
transactionAdapter.loadStateFlow
.collectLatest {
if(it.refresh is LoadState.NotLoading){
binding.textNoTransaction.isVisible = transactionAdapter.itemCount<1
}
}
}
For detailed explanation and usage of loadStateFlow, please check
https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data#load-state-listener

If you are using paging 3 with Jetpack compose, I had a similar situation where I wanted to show a "no results" screen with the Pager 3 library. I wasn't sure what the best approach was in Compose but I use now this extension function on LazyPagingItems. It checks if there are no items and makes sure there are no items coming by checking if endOfPaginationReached is true.
private val <T : Any> LazyPagingItems<T>.noItems
get() = loadState.append.endOfPaginationReached && itemCount == 0
Example usage is as follows:
#Composable
private fun ArticlesOverviewScreen(screenContent: Content) {
val lazyBoughtArticles = screenContent.articles.collectAsLazyPagingItems()
when {
lazyBoughtArticles.noItems -> NoArticlesScreen()
else -> ArticlesScreen(lazyBoughtArticles)
}
}
Clean and simple :).

Related

Synchronizing value change in Coroutines

I have a ViewModel in android and I am trying to validate whether the person has entered his name and age before moving on to the next page.
Here is my code:
fun onContinueClick() {
val navigateNextPage: (Int) -> Unit = lambda#{ validation ->
if (validation < 2)
return#lambda
nextPageUseCase().onEach {
_navigationNotify.value = it // Moving on to the next page
}
}
viewModelScope.launch {
var valid = 0
getName().collect { name ->
if (name != null) navigateNextPage(++valid)
}
getAge().collect { age ->
if (age != null) navigateNextPage(++valid)
}
}
}
Although this is working as expected, is it efficient to perform this operation? I think that if both the ++valid happen at the same time, I wouldn't be able to go to the next page.
I want to know how to synchronize the code to avoid that situation.

How to continue paging after swipe to refresh error?

I am implementing swipe to refresh with the PagingLibrary 3.0.
My repository returns a flow of PagingData<Item> which is then exposed as LiveData by a viewmodel.
Repository
override fun getItems(): Flow<PagingData<Item>> {
val pagingConfig = PagingConfig(pageSize = 20, enablePlaceholders = false)
val pager = Pager(pagingConfig) {
IndexedPagingSource(remoteDataSource)
}
return pager.flow
}
ViewModel
val itemsStream: LiveData<PagingData<Item>> = repository.getItems()
.asLiveData()
.cachedIn(viewModelScope)
Fragment
private fun FragmentItemListBinding.bindView() {
list.adapter = adapter
list.layoutManager = LinearLayoutManager(context)
swipeRefresh.setOnRefreshListener { onRefresh() }
adapter.loadStateFlow
.onEach { resolveLoadState(it) }
.launchIn(viewLifecycleOwner.lifecycleScope)
viewModel.itemsStream.observeWithViewLifecycleOwner {
adapter.submitData(viewLifecycleOwner.lifecycle, it)
}
}
private fun onRefresh() {
adapter.refresh()
}
private fun FragmentItemListBinding.resolveLoadState(loadState: CombinedLoadStates) {
val adapterEmpty = adapter.itemCount < 1
swipeRefresh.isRefreshing = loadState.refresh is LoadState.Loading && !adapterEmpty
// resolve all other states here...
}
The problem is that while the refresh is in progress, the paging stops working (and any ongoing requests are cancelled while the UI stays the same - talking about you LoadStateFooter). And it stops until the refresh succeeds, which includes failure.
If I don't have any data, I simply display an error screen. But in this case I want to see the previous items and continue paging even after error.
Is there a way to continue paging in the case of a refresh error?
The official architecture components sample for paging has the same behavior.
Currently not supported. The generation simply stops working when refresh fails.
https://issuetracker.google.com/u/3/issues/189967519

Paging 3 - How to scroll to top of RecyclerView after PagingDataAdapter has finished refreshing AND DiffUtil has finished diffing?

I'm using Paging 3 with RemoteMediator that shows cached data while fetching new data from the network.
When I refresh my PagingDataAdapter (by calling refresh() on it) I want my RecyclerView to scroll to the top after the refresh is done. In the codelabs they try to handle this via the loadStateFlow the following way:
lifecycleScope.launch {
adapter.loadStateFlow
// Only emit when REFRESH LoadState for RemoteMediator changes.
.distinctUntilChangedBy { it.refresh }
// Only react to cases where Remote REFRESH completes i.e., NotLoading.
.filter { it.refresh is LoadState.NotLoading }
.collect { binding.list.scrollToPosition(0) }
}
This indeed does scroll up, but before DiffUtil has finished. This means that if there is actually new data inserted at the top, the RecyclerView will not scroll all the way up.
I know that RecyclerView adapters have an AdapterDataObserver callback where we can get notified when DiffUtil has finished diffing. But this will cause all kinds of race conditions with PREPEND and APPEND loading states of the adapter which also cause DiffUtil to run (but here we don't want to scroll to the top).
One solution that would work would be to pass PagingData.empty() to the PagingDataAdapter and rerun the same query (just calling refresh won't work because the PagingData is now empty and there is nothing to refresh) but I would prefer to keep my old data visible until I know that refresh actually succeeded.
In cases, such as searching a static content, we can return false inside areItemsTheSame of DiffUtil.ItemCallback as a workaround. I use this also for changing sorting property.
#Florian I can confirm we don't need the postDelayed to scroll to top using version 3.1.0-alpha03 released on 21/07/2021.
Also, I managed to make further filter the loadStateFlow collection so it doesn't prevent StateRestorationPolicy.PREVENT_WHEN_EMPTY to work based on #Alexandr answer. My solution is:
By the time I am writing this, the latest version of Paging3 is 3.1.0-alpha03 so import:
androidx.paging:paging-runtime-ktx:3.1.0-alpha03
Then set the restoration policy of your adapter as following:
adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
If you have compilation error for the above mentioned change, make sure you are using at least version 1.2.0-alpha02 of RecyclerView. Any version above that is also good:
androidx.recyclerview:recyclerview:1.2.0-alpha02
Then use the filtered loadStateFlow to scroll the list to top only when you refresh the page and items are prepended in the list:
viewLifecycleOwner.lifecycleScope.launch {
challengesAdapter.loadStateFlow
.distinctUntilChanged { old, new ->
old.mediator?.prepend?.endOfPaginationReached.isTrue() ==
new.mediator?.prepend?.endOfPaginationReached.isTrue() }
.filter { it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached && !it.append.endOfPaginationReached}
.collect {
mBinding.fragmentChallengesByLocationList.scrollToPosition(0)
}
}
The GitHub discussion can be found here: https://github.com/googlecodelabs/android-paging/issues/149
adapter.refresh()
lifecycleScope.launch {
adapter.loadStateFlow
.collect {
binding.recycleView.smoothScrollToPosition(0)
}
}
Take a look on the code the condition if loadtype is refreshed.
repoDatabase.withTransaction {
// clear all tables in the database
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
val nextKey = if (endOfPaginationReached) null else page + 1
val keys = repos.map {
Log.e("RemoteKeys", "repoId: ${it.id} prevKey: $prevKey nextKey: $nextKey")
RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
}
repoDatabase.remoteKeysDao().insertAll(keys)
repoDatabase.reposDao().insertAll(repos)
}
You should delete the condition if LoadType is refresh clear all the tables.
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
I've managed the way to improve base code snippet from the topic question.
The key is to listen non-combined variants of properties inside CombinedLoadStates
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
adapter?.loadStateFlow
?.distinctUntilChanged { old, new ->
old.mediator?.prepend?.endOfPaginationReached.isTrue() ==
new.mediator?.prepend?.endOfPaginationReached.isTrue()
}
?.filter { it.refresh is LoadState.NotLoading }
..
// next flow pipeline operators
}
where isTrue is Boolean extension fun
fun Boolean?.isTrue() = this != null && this
So the idea here is to track mediator.prepend:endOfPagination flag states. When mediator has completed his part of paging load for the current page, his prepend state will not change (in case if you are loading pages after scroll down).
Solution works well both in offline & online modes.
If you need to track prepend paging or paging in both directions it is a good starting point to play around with the another CombinedLoadStates properties append,refresh,mediator and source
Follow https://developer.android.com/reference/kotlin/androidx/paging/PagingDataAdapter
val USER_COMPARATOR = object : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean =
// User ID serves as unique ID
oldItem.userId == newItem.userId
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean =
// Compare full contents (note: Java users should call .equals())
oldItem == newItem
}
class UserAdapter : PagingDataAdapter<User, UserViewHolder>(USER_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
return UserViewHolder.create(parent)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
val repoItem = getItem(position)
// Note that item may be null, ViewHolder must support binding null item as placeholder
holder.bind(repoItem)
}
}

Android - Issues with Awaitility and Unit Testing

I have a pretty simple test. I'm waiting for a list of transactions to be received and assigned to a list view.
#Test
fun testTransactionsLoad(){
val listView: ListView? = fragment!!.findView(R.id.listView)
assertNotNull(listView)
await().atMost(20, TimeUnit.SECONDS).until(transactionsLoad(listView!!))
assert(listView.adapter.count > 0)
}
But for some reason, awaitability always send false for this callable:
private fun transactionsLoad(listView: ListView): Callable<Boolean> {
return if (listView.adapter == null){
Callable { false }
} else {
Callable { true }
}
}
It's not the first time Awaitility returns the wrong thing. On my device, the list view loads, proving it should also have an adapter. The adapter still appears as null in the callable though.
Any ideas?
As you are using kotlin, you could do:
await().atMost(20, TimeUnit.SECONDS).until{ listView.adapter != null }

Android Logic Based On Multiple Live Data Values

I am using the Android data binding library to make reactive views with LiveData
I make a repo request for a list of jobs
var jobsRequest: LiveData<Resource<List<Job>>>
= Transformations.switchMap(position) { repo.getJobsWithStatus(it) }
Then I have 3 more LiveData based on the above, like so
First, to check whether the request has completed
private val requestComplete: LiveData<Boolean>
= Transformations.map(jobsRequest) {
it.status == Status.SUCCESS || it.status == Status.ERROR
}
Next, to transform to a list of jobs without the resource wrapper
var jobs: LiveData<List<Job>>
= Transformations.map(jobsRequest) { it.data }
Lastly, to check if that job list is empty
val jobsEmpty: LiveData<Boolean>
= Transformations.map(jobs) { (it ?: emptyList()).isEmpty() }
In the layout I want to show a loading spinner if the request has not completed and the jobs list is empty and need a variable in my view model to dictate this
I have tried the code below and, as expected, it does not work
val spinnerVisible: LiveData<Boolean>
= Transformations.map(requestComplete) {
!(requestComplete.value ?: false) && (jobsEmpty.value ?: true)
}
What is the correct practice for having a LiveData variable based on the state of 2 others - I want to keep all logic in the view model, not in the activity or layout.
Is the jobsEmpty observer needed? Seems like you could reuse the jobs one for it.
Anway, to your question:
For this there is a MediatorLiveData. It does what you need: it can merge multiple (in your case: 2) LiveData objects and can determine another livedata value based on that.
Some pseudo-code:
MediatorLiveData showSpinner = new MediatorLiveData<Boolean>()
showSpinner.addSource(jobsEmpty, { isEmpty ->
if (isEmpty == true || requestComplete.value == true) {
// We should show!
showSpinner.value = true
}
// Remove observer again
showSpinner.removeSource(jobsEmpty);
})
showSpinner.addSource(requestComplete, { isCompleted ->
if (isCompleted == true && jobsEmpty == true) {
// We should show!
showSpinner.value = true
}
// Remove observer again
showSpinner.removeSource(requestComplete);
})
return showSpinner
Note that you need to return the mediatorlivedata as result, as this is the object you are interested in for your layout.
Additionally, you can check the documentation on the MediatorLiveData, it has some more examples: https://developer.android.com/reference/android/arch/lifecycle/MediatorLiveData

Categories

Resources