I am using Paging Library 3 with a RemoteMediator which includes loading data from the network and the local Room database. Every time I scroll to a certain position in the RecyclerView, navigate away to another Fragment, and then navigate back to the Fragment with the list, the scroll state is not preserved and the RecyclerView displays the list from the very first item instead of the position I was at before I navigated away.
I have tried using StateRestorationPolicy with no luck and can't seem to figure out a way to obtain the scroll position of the PagingDataAdapter and restore it to that same exact position when navigating back to the Fragment.
In my ViewModel, I have a Flow that collects data from the RemoteMediator:
val flow = Pager(config = PagingConfig(5), remoteMediator = remoteMediator) {
dao?.getListAsPagingSource()!!
}.flow.cachedIn(viewModelScope)
and I am submitting that data to the adapter within my Fragment:
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow.collectLatest { pagingData ->
adapter?.submitData(pagingData)
}
}
At the top of the Fragment, my adapter is listed as:
class MyFragment : Fragment() {
...
private var adapter: FeedAdapter? = null
...
override onViewCreated(...) {
if (adapter == null) {
adapter = FeedAdapter(...)
}
recyclerView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow.collectLatest { pagingData ->
adapter?.submitData(pagingData)
}
}
}
}
How can we make sure that the adapter shows the list exactly where it was at before the user left the Fragment upon returning instead of starting the list over at the very first position?
Do this in your fragment's onViewCreated:
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow.collect { pagingData ->
adapter.submitData(viewLifecycleOwner.lifecycle, pagingData)
}
}
After checking many solutions, this is the method that worked for me cachedIn()
fun getAllData() {
viewModelScope.launch {
_response.value = repository.getPagingData().cachedIn(viewModelScope)
}
}
Related
Our app has a list of items, and an associated recycler view with it:
class Adapter(val clickListener: PreviewListener) :
ListAdapter<DataItem, RecyclerView.ViewHolder>(EntryDiffCallback()) {
The adapter is set up in the onViewCreatedMethod:
private var previewAdapter: PreviewAdapter? = null
if (previewAdapter == null) {
previewAdapter =
PreviewAdapter(
PreviewListener { info ->
previewViewModel.updateCurrentInfo(info)
findNavController()
.navigateSafely(
PreviewFragmentDirections
.actionToExerciseFragment())
})
previewAdapter?.stateRestorationPolicy =
RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
binding.previewListView.adapter = previewAdapter
postponeEnterTransition()
The list is populated in the following manner:
previewViewModel.listItems.observe(
viewLifecycleOwner,
Observer { list ->
previewAdapter?.submitList(list)
(view.parent as? ViewGroup)?.doOnPreDraw { startPostponedEnterTransition() }
})
When the user clicks on an item, it is taken to a new fragment, and when they press back, they end up in the main fragment again. However, the recycler view does not retain the position.
I have extensively read the internet:
https://medium.com/androiddevelopers/restore-recyclerview-scroll-position-a8fbdc9a9334
and stack over flow:
Maintain/Save/Restore scroll position when returning to a ListView
Refreshing data in RecyclerView and keeping its scroll position
RecyclerView store / restore state between activities
And tried some really funky solutions, yet none of these retain the recycler view in the old position when the user is returning back. (eg. PREVENT_WHEN_EMPTY, using SavedInstanceState on the layout manager, and remembering the scroll position).
I'm also using shared element to animate transitions
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition =
MaterialContainerTransform().apply {
duration = resources.getInteger(R.integer.reply_motion_duration_large).toLong()
scrimColor = Color.TRANSPARENT
interpolator = DecelerateInterpolator()
setAllContainerColors(requireContext().getColorFromAttr(R.attr.colorSurface))
}
}
I clean up the data on destroy view, and destroy, but removing these doesn't seem to do anything except cause CanaryLeaks:
override fun onDestroyView() {
super.onDestroyView()
binding.previewListView.adapter = null
_binding = null
}
override fun onDestroy() {
super.onDestroy()
previewAdapter = null
}
Any suggestions? Thank you
I'm using androidx.paging:paging-compose (v1.0.0-alpha-14), together with Jetpack Compose (v1.0.3), I have a custom PagingSource which is responsible for pulling items from backend.
I also use compose navigation component.
The problem is I don't know how to save a state of Pager flow between navigating to different screen via NavHostController and going back (scroll state and cached items).
I was trying to save state via rememberSaveable but it cannot be done as it is not something which can be putted to Bundle.
Is there a quick/easy step to do it?
My sample code:
#Composable
fun SampleScreen(
composeNavController: NavHostController? = null,
myPagingSource: PagingSource<Int, MyItem>,
) {
val pager = remember { // rememberSaveable doesn't seems to work here
Pager(
config = PagingConfig(
pageSize = 25,
),
initialKey = 0,
pagingSourceFactory = myPagingSource
)
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn() {
itemsIndexed(items = lazyPagingItems) { index, item ->
MyRowItem(item) {
composeNavController?.navigate(...)
}
}
}
}
I found a solution!
#Composable
fun Sample(data: Flow<PagingData<Something>>):
val listState: LazyListState = rememberLazyListState()
val items: LazyPagingItems<Something> = data.collectAsLazyPagingItems()
when {
items.itemCount == 0 -> LoadingScreen()
else -> {
LazyColumn(state = listState, ...) {
...
}
}
}
...
I just found out what the issue is when using Paging.
The reason the list scroll position is not remembered with Paging when navigating boils down to what happens below the hood.
It looks like this:
Composable with LazyColumn is created.
We asynchronously request our list data from the pager. Current pager list item count = 0.
The UI draws a lazyColumn with 0 items.
The pager responds with data, e.g. 10 items, and the UI is recomposed to show them.
User scrolls e.g. all the way down and clicks the bottom item, which navigates them elsewhere.
User navigates back using e.g. the back button.
Uh oh. Due to navigation, our composable with LazyColumn is recomposed. We start again with asynchronously requesting pager data. Note: pager item count = 0 again!
rememberLazyListState is evaluated, and it tells the UI that the user scrolled down all the way, so it now should go back to the same offset, e.g. to the fifth item.
This is the point where the UI screams in wild confusion, as the pager has 0 items, so the lazyColumn has 0 items.
The UI cannot handle the scroll offset to the fifth item. The scroll position is set to just show from item 0, as there are only 0 items.
What happens next:
The pager responds that there are e.g. 10 items again, causing another recomposition.
After recomposition, we see our list again, with scroll position starting on item 0.
To confirm this is the case with your code, add a simple log statement just above the LazyColumn call:
Log.w("TEST", "List state recompose. " +
"first_visible=${listState.firstVisibleItemIndex}, " +
"offset=${listState.firstVisibleItemScrollOffset}, " +
"amount items=${items.itemCount}")
You should see, upon navigating back, a log line stating the exact same first_visible and offset, but with amount items=0.
The line directly after that will show that first_visible and offset are reset to 0.
My solution works, because it skips using the listState until the pager has loaded the data.
Once loaded, the correct values still reside in the listState, and the scroll position is correctly restored.
Source: https://issuetracker.google.com/issues/177245496
Save the list state in your viewmodel and reload it when you navigate back to the screen containing the list. You can use LazyListState in your viewmodel to save the state and pass that into your composable as a parameter. Something like this:
class MyViewModel: ViewModel() {
var listState = LazyListState()
}
#Composable
fun MessageListHandler() {
MessageList(
messages: viewmodel.messages,
listState = viewmode.listState
)
}
#Composable
fun MessageList(
messages: List<Message>,
listState: LazyListState) {
LazyColumn(state = listState) {
}
}
If you don't like the limitations that Navigation Compose puts on you, you can try using Jetmagic. It allows you to pass any object between screens and even manages your viewmodels in a way that makes them easier to access from any composable:
https://github.com/JohannBlake/Jetmagic
The issue is that when you navigate forward and back your composable will recompose and collectAsLazyPagingItems() will be called again, triggering a new network request.
If you want to avoid this issue, you should call pager.flow.cacheIn(viewModelScope) on your ViewModel with activity scope (the ViewModel instance is kept across fragments) before calling collectAsLazyPagingItems().
LazyPagingItems is not intended as a persistent data store; it is just a simple wrapper for the UI layer. Pager data should be cached in the ViewModel.
please try using '.cachedIn(viewModelScope) '
simple example:
#Composable
fun Simple() {
val simpleViewModel:SimpleViewModel = viewModel()
val list = simpleViewModel.simpleList.collectAsLazyPagingItems()
when (list.loadState.refresh) {
is LoadState.Error -> {
//..
}
is LoadState.Loading -> {
BoxProgress()
}
is LoadState.NotLoading -> {
when (list.itemCount) {
0 -> {
//..
}
else -> {
LazyColumn(){
items(list) { b ->
//..
}
}
}
}
}
}
//..
}
class SimpleViewModel : ViewModel() {
val simpleList = Pager(
PagingConfig(PAGE_SIZE),
pagingSourceFactory = { SimpleSource() }).flow.cachedIn(viewModelScope)
}
I am presenting a PagingSource returned by Room ORM on a PagingDataAdapter.
The RecyclerView is present on a Fragment -- I have two such fragments. When they are switched, they stop loading the items on next page and only placehodlers are shown on scrolling.
Please view these screen captures if it isn't clear what I mean--
When I scroll without switching fragments, all the items are loaded
When I switch Fragments before scrolling all the way down, the adapter stops loading new items
Relevant pieces of code (please ask if you would like to see some other part/file) -
The Fragment:
private lateinit var recyclerView: RecyclerView
private val recyclerAdapter = CustomersAdapter(this)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView = view.findViewById(R.id.recycler_view)
recyclerView.adapter = recyclerAdapter
recyclerView.layoutManager = LinearLayoutManager(context)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.customersFlow.collectLatest { pagingData ->
recyclerAdapter.submitData(pagingData)
}
}
}
View model-
class CustomersListViewModel(application: Application, private val debtOnly: Boolean): ViewModel() {
private val db = AppDatabase.instance(application)
private val customersDao = db.customersDao()
val customersFlow = Pager(PagingConfig(20)) {
if (debtOnly)
customersDao.getAllDebt()
else
customersDao.getAll()
}.flow.cachedIn(viewModelScope)
}
After I went through your code, I found the problem FragmentTransaction.replace function and flow.cachedIn(viewModelScope)
When the activity calls the replace fragment function, the CustomerFragment will be destroyed and its ViewModel will also be destroyed (the viewModel.onCleared() is triggered) so this time cachedIn(viewModelScope) is also invalid.
I have 3 solutions for you
Solution 1: Remove .cachedIn(viewModelScope)
Note that this is only a temporary solution and is not recommended.
Because of this, instances of fragments still exist on the activity but the fragments had destroyed (memory is still leaking).
Solution 2: Instead of using the FragmentTransaction.replace function in the Main activity, use the FragmentTransaction.add function:
It does not leak memory and can still use the cachedIn function. Should be used when the activity has few fragments and the fragment's view is not too complicated.
private fun switchNavigationFragment(navId: Int) {
when (navId) {
R.id.nav_customers -> {
switchFragment(allCustomersFragment, "Customer")
}
R.id.nav_debt -> {
switchFragment(debtCustomersFragment, "DebtCustomer")
}
}
}
private fun switchFragment(fragment: Fragment, tag: String) {
val existingFragment = supportFragmentManager.findFragmentByTag(tag)
supportFragmentManager.commit {
supportFragmentManager.fragments.forEach {
if (it.isVisible && it != fragment) {
hide(it)
}
}
if (existingFragment != fragment) {
add(R.id.fragment_container, fragment, tag)
.disallowAddToBackStack()
} else {
show(fragment)
}
}
}
Solution 3: Using with Navigation Component Jetpack
This is the safest solution.
It can be created using Android Studio's template or some of the following articles.
Navigation UI
A safer way to collect flows
I tried solution 1 and 2 and here is the result:
I have a fragment showing a list of items, observing from view model (from a http service, they are not persisted in database). Now, I need to delete one of those items. I have a delete result live data so the view can observe when an item has been deleted.
Fragment
fun onViewCreated(view: View, savedInstanceState: Bundle?) {
//...
viewModel.deleteItemLiveData.observe(viewLifecycleOwner) {
when (it.status) {
Result.Status.ERROR -> showDeletingError()
Result.Status.SUCCESS -> {
itemsAdapter.remove(it.value)
commentsAdapter.notifyItemRemoved(it.value)
}
}
}
}
fun deleteItem(itemId: String, itemIndex: Int) = lifecycleScope.launch {
viewModel.deleteItem(itemId, itemIndex)
}
ViewModel
val deleteItemLiveData = MutableLiveData<Result<Int>>()
suspend fun deleteItem(itemId: String, itemIndex: Int) = withContext(Dispatchers.IO) {
val result = service.deleteItem(itemId)
withContext(Dispatchers.Main) {
if (result.success) {
deleteItemLiveData.value = Result.success(itemIndex)
} else {
deleteItemLiveData.value = Result.error()
}
}
}
It is working fine, but the problem comes when I navigate to another fragment and go back again. deleteItemLIveData is emitted again with the last Result, so fragment tries to remove again the item from the adapter, and it crashes.
How con I solve this?
Rather than deleting an individual item from the adapter, it would make sense to update the original source of LiveData<List> since the view observes that list.
The item repository should handle deletions, removing that item from the LiveData<List> which in turns propagates the update to the view and then the adapter.
Repo might look something like this...
fun deleteItem(item: Item): Result {
val updated = items.value
updated.remove(item)
items.postValue(updated)
. . .
// propagate result of success/failure back to the view
}
fun observeItems() = items
In your fragment you would get immediate updates from a single LiveData source
fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.observeItems().observe(viewLifecycleOwner) {
itemsAdapter.update(it) //use DiffUtil to update list or notifyDataSetChanged
}
}
}
Showing errors should be contextual, a toast message or some visual notification.
Update:
Handle error in deletion might look like this, off the top of my head...
suspend fun deleteItem(itemId: String, itemIndex: Int): Result = withContext(Dispatchers.IO) {
val result = service.deleteItem(itemId)
withContext(Dispatchers.Main) {
if (result.success) {
// push updated list to items
val updated = items.value
updated.remove(item)
items.postValue(updated)
Result.Success()
} else {
Result.error()
}
}
}
I found a solution. I changed my code so fragment observes from onCreate method instead of onViewCreated. And I changed the owner as well. Instead of viewLifecycleOwner now is this. This way, value is not re-emitted when fragment is resumed, but just when is created or viewModel.deleteItem is called specifically.
It is working properly now. If anybody considers this a bad solution, please, tell me.
It's a common problem when you use LiveData for events that should happen only one time. There are several solutions explained here and here. They either wrap the emitted data or the observers. In this wrapper they store a flag that tracks whether or not the event has been handled/emitted yet.
I have a chat in my application and I want my RV stay on position 0 when the new message comes and the first visible item position is 0, but new messages are added above the currently visible one, but the visible items stay in view so user has to scroll to see new message.
My adapter was extended from RecyclerView.Adapter<RecyclerView.ViewHolder> and I found a little hacky solution to get it work:
private suspend fun update(newItems: List<IChatItem>) {
var recyclerViewState: Parcelable? = null
if ((recyclerView?.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
recyclerViewState = recyclerView?.layoutManager?.onSaveInstanceState()
}
val diffResult = withContext(Dispatchers.Default) {
DiffUtil.calculateDiff(Diff(this.currentItems, newItems))
}
diffResult.dispatchUpdatesTo(this)
recyclerViewState?.let {
recyclerView?.layoutManager?.onRestoreInstanceState(recyclerViewState)
}
}
The thing is saving RV state and after DiffUtil completes it's work, restore it. It did work, but now I'm trying to implement new adapter and extend my adapter class from ListAdapter because it launches DiffUtil in another thread. It works good, but the problem is I can't implement my previous solution here, it just doesn't work.
I tried following:
override fun submitList(list: MutableList<IChatItem>?) {
var recyclerViewState: Parcelable? = null
if ((recyclerView?.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
recyclerViewState = recyclerView?.layoutManager?.onSaveInstanceState()
}
super.submitList(list)
recyclerViewState?.let {
recyclerView?.layoutManager?.onRestoreInstanceState(recyclerViewState)
}
}
But this doesn't work anymore. Does anybody have a solution?
PS: I found this question - Inserting RecyclerView items at zero position - always stay scrolled to top, but I don't think this is the best solution to add empty invisible items in adapter's list. Any other ideas?
UPD: as I thought - because this adapter launches DiffUtil in another thread, it restored RV state before DiffUtil completes it's job. This code works:
override fun submitList(list: List<IChatItem>?) {
var recyclerViewState: Parcelable? = null
if ((recyclerView?.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
recyclerViewState = recyclerView?.layoutManager?.onSaveInstanceState()
}
super.submitList(list)
recyclerViewState?.let {
Handler().postDelayed(
{
recyclerView?.layoutManager?.onRestoreInstanceState(recyclerViewState)
}, 1000
)
}
}
But ofc this is not what I want. Is there any way to detect when DiffUtil is done with everything?
You can use RecyclerView.AdapterDataObserver() and handle what you need.
Create your listener class that implement RecyclerView.AdapterDataObserver()
and in your adapter constructor call registerAdapterDataObserver(your listener)
and then handle your callbacks.
See RecyclerView.AdapterDataObserver