RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY stopped working - android

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

Related

ViewPager with custom pages - kotlin.UninitializedPropertyAccessException

I'm using custom Pages in my ViewPager and entire App is based on those screens.
There are 2 main abstract functions in those Pages.
First one is getScreen() which suppose to copy function of onCreateView from Fragment. Its called inside ViewPagerAdapters function to initialize layout for that screen.
Example from adapter:
override fun instantiateItem(container: ViewGroup, position: Int): View {
val page = pageList[position]
val layout = page.getScreen(container)
App.log("flowSwitch: instantiateItem: getScreen: ${page::class.java}")
container.addView(layout)
page.screenLayoutWasInitialized = true
return layout
}
Another function is onScreenSwitched(). This one suppose to be called only if I switch to the screen manually by swiping/clicking on tab/calling it in code to get into next screen.
There is initializing some values for Views, sometimes based on payload provided from previous screens.
I call this onScreenSwitched() function inside switchScreen() function which is part of my Navigation class. I just pass there screen name and payload. Its always called after mainViewPager.setCurrentItem(index, useAnim).
Example:
fun switchScreen(
screen: Class<out FlowScreen>,
payload: List<ScreenPayload>? = null,
action: (() -> Unit)? = null,
useAnim: Boolean = true,
){
App.log("AppNavigationFlow: MainTabActivity: switchScreen: $screen")
try {
val index = mainFlowList.indexOfFirst { item -> item::class.java == screen }
App.log("AppNavigationFlow: MainTabActivity: switchScreen: index: $index")
if (index >= 0){
delayedScreenSelection {
mainTabLayout.getTabAt(index)?.select()
App.log("AppNavigationFlow: MainTabActivity: switchScreen: pageNameAtPos: ${mainViewPager.adapter?.getPageTitle(index)}")
mainViewPager.setCurrentItem(index, useAnim)
mainPagerAdapter.getPageAtPos(index)?.apply {
App.log("AppNavigationFlow: MainTabActivity: switchScreen: page: ${this::class.java}")
mainCurrentPage?.apply {
setScreenVisibleState(false)
resetToDefault()
removeBottomSheet(false)
this#MainTabActivity.removeBottomSheet(false)
}
mainCurrentPage = this
mainCurrentPage?.apply {
setScreenVisibleState(true)
clearPayload()
clearAction()
}
payload?.let { mainCurrentPage?.sendPayload(payload) }
action?.let { mainCurrentPage?.setAction(action) }
onScreenSwitched()
onPageChanged()
}?:kotlin.run {
setTabsEnabled(true)
}
}
} else {
setTabsEnabled(true)
}
}catch (e: IndexOutOfBoundsException){
App.log("AppNavigationFlow: MainTabActivity: switchScreen: $e")
setTabsEnabled(true)
}
}
98% of users are getting always called getScreen function before onScreenSwitched function, therefore my layout is completely initialized by that time onScreenSwitched is called.
But for 2% of users, they are getting kotlin.UninitializedPropertyAccessException because for example I'm trying to setup text for Button which was not initialized yet in getScreen function.
How to prevent this? I'm not sure if ViewPager should allow that to happen. How can setContentView ignore instantiateItem call if layout was not initialized yet for that screen I'm switching to? I ditched Fragments because of this bug happening in Fragments too and its happening again with fully customized logic. How can I build something functional when I cant even rely on basic native components to work as it suppose to at first place? There is possibly something I'm missing but 98% of time its working and I personally cant simulate those crashes but I want to fix it for those 2% of users.
Example usage in Page:
private lateinit var toolbarTitle: TextView
private lateinit var acceptButton: LoadingButton
override fun getScreen(collection: ViewGroup): View {
val layout = CustomResources.inflateLayout(inflater, l, collection, false) as ViewGroup
toolbarTitle = layout.findText(R.id.actionbarTitle)
acceptButton = layout.findViewById(R.id.acceptButton)
return layout
}
override fun onScreenSwitched() {
super.onScreenSwitched()
acceptButton.setText(if(payload.ok) "Yes" else "No")
}

Adding an item to a mutable list at index zero doesnt update the recyclerview

I have a recyclerview inside a fragment that displays a mutable list of tasks that each have a title and description, wrapped in mutable live data.
private val _tasks = MutableLiveData<MutableList<Task>>()
To add those items, i implemented a bottom sheet dialog fragment with text edits for both values.
When i add a task item without specifying the index the recyclerview updates correctly :
_tasks.value!!.add(Task(taskEditText,descriptionEditText))
However, when i specify i want the new task item at index 0 and i add multiple task items, the recyclerview displays the first task i added over and over.
Things i've tried:
Using notifyDataSetChanged inside the adapter works and updates the recyclerview correctly, however i tried adding it to my add task button inside my bottom sheet dialog and it does nothing.
I tried adding the items to a temporary list and then setting it to _tasks.value but the same thing happened, only updates when i dont specify the index.
Here are relevant files :
AddTaskFragment (Bottom Sheet Dialog) :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
// if the textfields are not empty, adds the task and leaves the dialog
binding.buttonAdd.setOnClickListener{
if (binding.addTaskEditText.text!!.isNotEmpty() && binding.addDescriptionEditText.text!!.isNotEmpty()) {
viewModel.addTask(binding.addTaskEditText.text.toString(), binding.addDescriptionEditText.text.toString())
dismiss()
}
}
}
addTask function inside viewmodel :
fun addTask(taskEditText : String, descriptionEditText : String) {
_tasks.value!!.add(0,Task(taskEditText,descriptionEditText))
}
Adapter:
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val task = viewModel.tasks.value!![position]
holder.itemTitle.text = task.text
holder.itemDescription.text = task.description
holder.textViewOptions.setOnClickListener {
onMenuClick(position, holder, task)
}
}
Thanks in advance and i hope you pros can help me
viewModel.tasks.observe(viewLifecycleOwner, Observer {
adapter.notifyDataSetChanged()
})
Try it.

Restoring Scroll Position in Paging Library 3

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)
}
}

PagingDataAdapter stops loading after fragment is removed and added back

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:

Restore PagedListAdapter position when resuming activity

I've been experimenting with PagedListAdapter and can't figure out how to restore adapters position correctly.
Last attempt was to save lastKey from current list.
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val lastKey = adapter.currentList?.lastKey as Int
outState.putInt("lastKey", lastKey)
}
but when restoring my adapter and passing lastKey to PagedListBuilder what I last saw and what is being displayed differs by quite a bit.
val dataSourceFactory = dao.reportsDataSourceFactory()
val builder = RxPagedListBuilder(
dataSourceFactory,
PagedList.Config.Builder()
.setEnablePlaceholders(false)
.setInitialLoadSizeHint(60)
.setPageSize(20)
.setPrefetchDistance(60)
.build()
)
.setInitialLoadKey(initialLoadKey)
.setBoundaryCallback(boundaryCallback)
If I'm in the middle of page #4 when resuming - adapter will be at position at the beginning of page #4. Ideally adapter should be restored in exactly the same position as last seen.
Various attempts to save LayoutManager state
outState.putParcelable("layout_manager_state", recycler_view.layoutManager.onSaveInstanceState())
and then restore it
recycler_view.layoutManager.onRestoreInstanceState(it.getParcelable("layout_manager_state"))
failed miserably. Any suggestions are welcome :)
Finally managed to get it working.
Precondition - your PagedListAdapter must support null placeholders! setEnablePlaceholders(true). Read more here
val dataSourceFactory = dao.reportsDataSourceFactory()
val builder = RxPagedListBuilder(
dataSourceFactory,
PagedList.Config.Builder()
.setEnablePlaceholders(true) //in my original implementation it was false
.setInitialLoadSizeHint(60)
.setPageSize(20)
.setPrefetchDistance(60)
.build()
)
Save state as usual:
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val lastKey = adapter.currentList?.lastKey as Int
outState.putInt("lastKey", lastKey)
outState.putParcelable("layout_manager_state", recycler_view.layoutManager.onSaveInstanceState())
}
but when restoring - first save state as variable and only restore saved state after submitting list to the PagedListAdapter
private fun showReports(pagedList: PagedList<Report>?) {
adapter.submitList(pagedList)
lastLayoutManagerState?.let {
report_list.layoutManager.onRestoreInstanceState(lastLayoutManagerState)
lastLayoutManagerState = null
}
}
where lastLayoutManagerState is:
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = withViewModel(viewModelFactory) {
observe(reports, ::showReports)
}
report_list.adapter = adapter
lastLayoutManagerState = savedInstanceState?.getParcelable("layout_manager_state")
val lastKey = savedInstanceState?.getInt("lastKey")
viewModel.getReports(lastKey)
}
Oh and when binding ViewHolder in onBindViewHolder I just bail out fast if item is null.
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = getItem(position) ?: return
...
}
Because it will be null as otherwise adapter item count won't match with saved state item count (guessing here) and that is why in some of my experiments layout was jumping around on pages 2+ while it worked on page 1.
If there are better ways how to approach this without manually storing and then using lastLayoutManagerState - let me know.

Categories

Resources