How to continue paging after swipe to refresh error? - android

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

Related

Why does Flow (kotlinx.coroutines.flow) not working with Retry even though I manually set as null in Android?

So basically, on the snackbar action button, I want to Retry API call if user click on Retry.
I have used core MVVM architecture with Flow. I even used Flow between Viewmodel and view as well. Please note that I was already using livedata between view and ViewModel, but now the requirement has been changed and I have to use Flow only. Also I'm not using and shared or state flow, that is not required.
Code:
Fragment:
private fun apiCall() {
viewModel.fetchUserReviewData()
}
private fun setObservers() {
lifecycleScope.launch {
viewModel.userReviewData?.collect {
LogUtils.d("Hello it: " + it.code)
setLoadingState(it.state)
when (it.status) {
Resource.Status.ERROR -> showErrorSnackBarLayout(-1, it.message, {
// Retry action button logic
viewModel.userReviewData = null
apiCall()
})
}
}
}
Viewmodel:
var userReviewData: Flow<Resource<ReviewResponse>>? = emptyFlow<Resource<ReviewResponse>>()
fun fetchUserReviewData() {
LogUtils.d("Hello fetchUserReviewData: " + userReviewData)
userReviewData = flow {
emit(Resource.loading(true))
repository.getUserReviewData().collect {
emit(it)
}
}
}
EDIT in ViewModel:
// var userReviewData = MutableStateFlow<Resource<ReviewResponse>>(Resource.loading(false))
var userReviewData = MutableSharedFlow<Resource<ReviewResponse>>()
fun fetchUserReviewData() {
viewModelScope.launch {
userReviewData.emit(Resource.loading(true))
repository.getUserReviewData().collect {
userReviewData.emit(it)
}
}
}
override fun onCreate() {}
}
EDIT in Activity:
private fun setObservers() {
lifecycleScope.launchWhenStarted {
viewModel.userReviewData.collect {
setLoadingState(it.state)
when (it.status) {
Resource.Status.SUCCESS ->
if (it.data != null) {
val reviewResponse: ReviewResponse = it.data
if (!AppUtils.isNull(reviewResponse)) {
setReviewData(reviewResponse.data)
}
}
Resource.Status.ERROR -> showErrorSnackBarLayout(it.code, it.message) {
viewModel.fetchUserReviewData()
}
}
}
}
}
Now, I have only single doubt, should I use state one or shared one? I saw Phillip Lackener video and understood the difference, but still thinking what to use!
The thing is we only support Portrait orientation, but what in future requirement comes? In that case I think I have to use state one so that it can survive configuration changes! Don't know what to do!
Because of the single responsibility principle, the ViewModel alone should be updating its flow to show the latest requested data, rather than having to cancel the ongoing request and resubscribe to a new one from the Fragment side.
Here is one way you could do it. Use a MutableSharedFlow for triggering fetch requests and flatMapLatest to restart the downstream flow on a new request.
A Channel could also be used as a trigger, but it's a little more concise with MutableSharedFlow.
//In ViewModel
private val fetchRequest = MutableSharedFlow<Unit>(replay = 1, BufferOverflow.DROP_OLDEST)
var userReviewData = fetchRequest.flatMapLatest {
flow {
emit(Resource.loading(true))
emitAll(repository.getUserReviewData())
}
}.shareIn(viewModelScope, SharingStarted.WhlieSubscribed(5000), 1)
fun fetchUserReviewData() {
LogUtils.d("Hello fetchUserReviewData: " + userReviewData)
fetchRequest.tryEmit(Unit)
}
Your existing Fragment code above should work with this, but you no longer need the ?. null-safe call since the flow is not nullable.
However, if the coroutine does anything to views, you should use viewLifecycle.lifecycleScope instead of just lifecycleScope.

Paging 3 library is not requesting my API after first usage

I'm trying to paginate my REST API with the paging 3 library, specifically with the RemoteMediator class.
What I want to achieve is that my local database is being used for the initial presentation of my UI, which gets updated reactively with a rest call if there are any changes in the shown data.
The strange behaviour that I have right now is, that the pagination only works like expected for the first time: Pages are loaded incrementally (meaning a REST call is fired) when the user scrolls the RecyclerView.
After having data in my local database, there is only a single rest call in the beginning (with the initial load size) and at the end where like 4 requests are fired successively. So it does not request my API incrementally like the first time, it just fires all requests at the end.
Is this behaviour normal ? Or is this a bug ?
My RemoteMediator code:
#ExperimentalPagingApi
class PageableRemoteMediator<T : Any>(
private val pageKeyId: String,
private val apiRequestFun: suspend (PageKey?, Int) -> ApiResult<Page<T>>,
private val entityPersisterFun: suspend (Page<T>) -> Unit,
application: Application
) : RemoteMediator<Int, T>() {
private val db = DatabaseProvider.getDatabase(application)
private val pageKeyDao = db.pageKeyDao()
override suspend fun initialize(): InitializeAction {
return InitializeAction.LAUNCH_INITIAL_REFRESH
}
override suspend fun load(loadType: LoadType, state: PagingState<Int, T>): MediatorResult {
val pageKey = getPageKeyOrNull(loadType)
/**
* If the next pageKey is null and loadType is APPEND, then the pagination end is reached.
* Also a prepend is seen as the end, since the RecyclerView always start from the beginning after a refresh.
*/
if ((loadType == LoadType.APPEND && pageKey?.next == null) || loadType == LoadType.PREPEND) {
return MediatorResult.Success(endOfPaginationReached = true)
}
val limit = if (loadType == LoadType.REFRESH) {
state.config.initialLoadSize
} else {
state.config.pageSize
}
return when (val result = apiRequestFun(pageKey, limit)) {
is ApiResult.Success -> {
db.withTransaction {
val page = result.value
removeAllPageKeysIfRefreshRequested(loadType)
// This function just does an #Insert query for the specific type in a Room DAO class.
// I'm not calling something like a BaseDAO function, since sometimes I need to use insert queries with multiple models in the args.
// E.g. #Insert insertUniversityWithStudents(university : University, students : List<Student>) in UniversityDAO
entityPersisterFun(page)
saveNewPageKey(page)
val endOfPaginationReached = page.cursor.next == null
MediatorResult.Success(endOfPaginationReached)
}
}
is ApiResult.Failed -> {
MediatorResult.Error(result.getException())
}
}
}
private suspend fun getPageKeyOrNull(loadType: LoadType): PageKey? {
return if (loadType != LoadType.REFRESH) {
pageKeyDao.findByKey(pageKeyId)
} else {
null
}
}
private suspend fun removeAllPageKeysIfRefreshRequested(loadType: LoadType) {
if (loadType == LoadType.REFRESH) {
pageKeyDao.removeAllByKey(pageKeyId)
}
}
private suspend fun saveNewPageKey(entityPage: Page<*>) {
val key = PageKey(pageKeyId, entityPage.cursor.next, entityPage.cursor.prev)
pageKeyDao.asyncInsert(key)
}
}
EDIT:
I found this code snippet in the Android codelab
// clear all tables in the database
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
Wouldn't this delete all my local data after an app restart ? Im not getting why I should use the RemoteMediator at all, if it just removes all entities anyway and wait for the response of my api-request ?
Check the #4 (Paging Library Components) in the Android Codelab you shared.
The remote mediator's behavior depends on the configuration of your implementation of the PagingSource and your PagingConfig.
The PagingSource manages the local data access, e.g. Sqlite database, while the RemoteMediator is for requesting data from the network when the particular conditions are satisfied within the PagingSource.

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 Paging library does not work with Asynchronous requests

The Android Paging Library does not work when making asynchronous network calls using Retrofit. I am using the Google's sample code for Architecture Components on Github, and modified it for my needs.
I had faced the same issue previously but got around it by making synchronous call since the use-case allowed it. But in the current scenario, there are multiple network calls required and the data repository returns the combined result. I am using RxJava for this purpose.
Initially it seemed like a multi-threading issue, but this answer suggests otherwise. Observing the RxJava call on the Main Thread also does not work.
I have added the relevant code below. I stepped into the callback.onResult while debugging and everything works as expected. But ultimately it does not notify the Recycler View Adapter.
View Model snippet:
open fun search(query : String, init : Boolean = false) : Boolean {
return if(query == searchQuery.value && !init) {
false
} else {
searchQuery.value = query
true
}
}
fun refresh() {
listing.value?.refresh?.invoke()
}
var listing : LiveData<ListingState<T>> = Transformations.map(searchQuery) {
getList() // Returns the Listing State from the Repo snippet added below.
}
Repository snippet:
val dataSourceFactory = EvaluationCandidateDataSourceFactory(queryParams,
Executors.newFixedThreadPool(5) )
val pagelistConfig = PagedList.Config.Builder()
.setEnablePlaceholders(true)
.setInitialLoadSizeHint(5)
.setPageSize(25)
.setPrefetchDistance(25).build()
val pagedList = LivePagedListBuilder<Int, PC>(
dataSourceFactory, pagelistConfig)
.setFetchExecutor(Executors.newFixedThreadPool(5)).build()
val refreshState = Transformations.switchMap(dataSourceFactory.dataSource) {
it.initialState
}
return ListingState(
pagedList = pagedList,
pagingState = Transformations.switchMap(dataSourceFactory.dataSource) {
it.pagingState
},
refreshState = refreshState,
refresh = {
dataSourceFactory.dataSource.value?.invalidate()
},
retry = {
dataSourceFactory.dataSource.value?.retryAllFailed()
}
)
Data Source snippet :
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, PC>) {
try {
queryMap = if (queryMap == null) {
hashMapOf("page" to FIRST_PAGE)
} else {
queryMap.apply { this!!["page"] = FIRST_PAGE }
}
initialState.postValue(DataSourceState.LOADING)
pagingState.postValue(DataSourceState.LOADING)
val disposable : Disposable = aCRepositoryI.getAssignedAC(queryMap)
.subscribeOn(AndroidSchedulers.mainThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
if(it.success) {
// remove possible retries on success
retry = null
val nextPage = it.responseHeader?.let { getNextPage(it, FIRST_PAGE) } ?: run { null }
val previousPage = getPreviousPage(FIRST_PAGE)
callback.onResult(it.response.pcList, previousPage, nextPage)
initialState.postValue(DataSourceState.SUCCESS)
pagingState.postValue(DataSourceState.SUCCESS)
} else {
// let the subscriber decide whether to retry or not
retry = {
loadInitial(params, callback)
}
initialState.postValue(DataSourceState.failure(it.networkError.message))
pagingState.postValue(DataSourceState.failure(it.networkError.message))
Timber.e(it.networkError.message)
}
}, {
retry = {
loadInitial(params, callback)
}
initialState.postValue(DataSourceState.failure(it.localizedMessage))
pagingState.postValue(DataSourceState.failure(it.localizedMessage))
})
} catch (ex : Exception) {
retry = {
loadInitial(params, callback)
}
initialState.postValue(DataSourceState.failure(ex.localizedMessage))
pagingState.postValue(DataSourceState.failure(ex.localizedMessage))
Timber.e(ex)
}
}
Can someone please tell what is the issue here. There is a similar issue I mentioned above, but it recommends using synchronous calls. How can we do it using asynchronous calls or with RxJava.
I don't understand why you want to go to the main thread. The loading methods in the DataSource are running in a background thread. This means you can do synchronous work on this thread without blocking the main thread, which means you could just think of a solution without RxJava. Something like:
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, PC>) {
try {
val result = repository.fetchData(..)
// post result values and call the callback
catch (e: Exception) {
// post error values and log and save the retry
}
}
Then in your repository you can do this because we are not on the main thread.
fun fetchData(...) {
val response = myRetrofitService.someBackendCall(..).execute()
response.result?.let {
return mapResponse(it)
} ?: throw HttpException(response.error)
}
I might have messed up the syntax but I hope you get the point. No callbacks, no subscribing/observing but simple straightforward code.
Also if you start threading inside the loadInitial(..) method your initial list will be empty, so doing things synchronously also avoids seeing empty lists.

RecyclerView, DiffUtil and weird animation

I have a chat app working with websockets.
Via websocket I receive message, which I save into db, messages table, and update last message id in conversation table. Now, both saves will notify cursor. So I call updateDate twice. They will run sequentially, in correct order and on correct threads. Yet, I guess, the animations are overlapping and making weird effect visible in youtube video attached (visible from message "i"). Can anybody pinpoint my problem or give me solution?
I am using rxjava for threading and dequeue for stacking updates, while always getting only last update from dequeue. Inspiration from vlc here
RecyclerView is stacking from end and if user is scrolled to bottom, then animation scroll to bottom is run when new message comes.
Fragment
adapter = ChatMessageAdapter()
recycler.apply {
adapter = this#ChatDetailFragment.adapter
layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false).apply { stackFromEnd = true }
}
ChatMessageAdapter
private val pendingUpdates: ArrayDeque<Single<Pair<MutableList<ChatItemHolder>, DiffUtil.DiffResult>>> = ArrayDeque()
#MainThread
fun updateData(msgs: MutableList<ChatMessage>, onDone: () -> Unit) {
pendingUpdates.add(
internalUpdate(msgs)
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess {
items.clear()
items.addAll(it.first)
it.second.dispatchUpdatesTo(this#ChatMessageAdapter)
onDone()
}
)
if (pendingUpdates.size == 1) pendingUpdates.peek().subscribe(Consumer {processQueue()})
}
private fun internalUpdate(msgs: MutableList<ChatMessage>) = Single.create<Pair<MutableList<ChatItemHolder>, DiffUtil.DiffResult>> {
val newItems = temp(msgs) // transforming function from ChatMessage[] to ChatItemHolder[]
val result = DiffUtil.calculateDiff(DiffUtilCallback(newItems, items), true)
it.onSuccess(Pair(newItems, result))
}
#MainThread
private fun processQueue() {
pendingUpdates.remove()
if (!pendingUpdates.isEmpty()) {
if (pendingUpdates.size > 1) {
val last = pendingUpdates.peekLast()
pendingUpdates.clear()
pendingUpdates.add(last)
}
pendingUpdates.peek().subscribe(Consumer { processQueue() })
}
}
Thank you!

Categories

Resources