Weird behaviour of FragmentStateAdapter in ViewPager2 - android

dears.
I'm using ViewPager2 & FragmentStateAdapter to add Fragments dynamically. And I stuck for 1 week already. Read all data here but no topics regarding my question.
I'll simplify real project just to illustrate my issue.
I have HostFragment which fills full screen in MainActivity.
HostFragment has ViewPager2 which fills full screen of HostFragment.
Tapping on the FAB new PageFragment should be created in ViewPager2 and immediately after it some View should be inflated on just created page.
How I'd realize it:
class HostFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_viewPager = binding.viewPager
----------------------------------
// For now viewModel.pages is empty - MutableList<PageFragment>()
----------------------------------
_viewPagerAdapter = ViewPagerHostFragmentAdapter(this, viewModel.pages)
viewPager.adapter = viewPagerAdapter
viewModel.pages.add(PageFragment())
viewPagerAdapter.notifyItemInserted(viewModel.pages.size - 1)
----------------------------------
// Retrieving the PageFragment on which some View will be inflated.
// For now there are 1 Fragment in viewModel.pages
----------------------------------
var page = viewModel.pages.last()
val inflater =
LayoutInflater.from(page.requireContext()).inflate(R.layout.some_view, null, false)
page.binding.addView(inflater)
}
Normally notifyItemInserted triggers fun createFragment in adapter. Adapter checks if fragment isn't already created and creates new instanse of fragment. So after notifyItemInserted immediately should be launched createFragment -> creation methods in PageFragment (onAttach, OnCreate so on...). And only after it the rest of code in HostFragment should be executed.
But this isn't happen. After viewPagerAdapter.notifyItemInserted(viewModel.pages.size - 1) the rest of code is executed:
var page = viewModel.pages.last()
val inflater =
LayoutInflater.from(page.requireContext()).inflate(R.layout.some_view, null, false)
page.binding.addView(inflater)
And when it comes to LayoutInflater.from(page.requireContext())... app crashes with
java.lang.IllegalStateException: Fragment PageFragment{c44e369} (a42d8521-35ae-4bb4-b445-c126e06fe026) not attached to a context.
It's clear. Because PageFragment isn't created yet, and we try to make some actions with it. In this case we try to inflate View on it.
I checked. If to remove
var page = viewModel.pages.last()
val inflater =
LayoutInflater.from(page.requireContext()).inflate(R.layout.some_view, null, false)
page.binding.addView(inflater)
then createFragment -> creation methods in PageFragment (onAttach, OnCreate so on...) is executed after notifyItemInserted
It's weird for me. It brakes foundomental rule of code order execution. Every line of code should be executed only after the previous line is done.
In this case app somehow skips notifyItemInserted and say: "Hey, friend. I'll execute it later". And goes to execute the rest of code. And only after it's done, app says: "Ok. Let's come back and execute all logic connected with notifyItemInserted". Means createFragment -> creation methods in PageFragment (onAttach, OnCreate so on...)
How can I force the app execute all methods sequentially? So no code is executed before notifyItemInserted -> createFragment -> onAttach, OnCreate so on is done?
How can I force the rest of code to wait till notifyItemInserted and all connected methods with it are done?
I need PageFragment to be created before app starts to inflate Views on it.
P.S.
I tried to debug notifyItemInserted and see what is does under the hood. But after few layers of repeats it comes to C++ code which compiler can't reach.
I am sure that notifyItemInserted triggers createFragment. Because if to remove rest of code with View inflation, the fragment is created perfectly. And if then to remove notifyItemInserted, fragment is added to viewModel.pages but is not inflated in the screen. Conclusion: notifyItemInserted triggers createFragment.
setCurrentItemView doesn't matter here. Even without it the notifyItemInserted sets created fragment on the screen. If the rest of code is absent of course.
Yes, I can execute inflations of Views inside onViewCreated of PageFragment. So we'll always be sure that Views are created only after the page in created. But I want to inflate View from HostFragment. Some another reasons for it exist, which are out of my question context.

Related

Error Fragment not attached to an activity when I leave a fragment and enter it again

When I change the fragment and return to the one that was at the beginning, running a line I get the error Fragment not attached to an activity. It is strange because before I get to this line, I use requireAcitivity() and it works fine. The error appears after calling an ArrayAdapter custom
When I call the arrayadapter, the requiredActivity method still works:
val adapter = ListUserGameInfoAdapter(requireContext(), gameViewModel!!)
gameInfoListUsers.adapter = adapter
Each row of the list created by the arrayAdapter has a button.
icon.setOnClickListener {
gameViewModel.userToDeleteFromGamePositionLiveData.value = position
}
The button changes the value of a LiveData of the viewModel. By using an observer I pick up this new value of the LiveData. Inside the code of the observer, if I call requiredActivity(), the following error appears
val userToDeleteObserver = Observer<Int> {
if (it != null) {
//If I call requiredActivity here, I get the error Fragment not attached to an activity
showDialog(it)
}
}
gameViewModel!!.userToDeleteFromGamePositionLiveData.observe(
requireActivity(),
userToDeleteObserver
)
The strange thing is that the first time I enter this fragment the error does not appear, only when I go to another one and come back to this one.
You're adding an observer in your fragment, but tying it to the lifecycle of your activity. Therefore it will keep observing for changes even when the fragment has been destroyed, which is why you're getting a crash when calling requireActivity() in your observer.
If you do some debugging, you'll probably notice that the observer is actually triggering twice, once for the old fragment (no longer attached to an activity) and once for the new fragment.
You should be using Fragment.getViewLifecycleOwner() instead.

RecyclerView with StaggeredGridLayoutManager in ViewPager, arranges items automatically when going back to fragment

I am using Navigation component in my App, using google Advanced Sample(here).
my problem is when going back to a fragment, the scrolling position does not lost but it rearranges items and moves highest visible items so that top of those item align to top of recyclerview. please see this:
before going to next fragment:
and after back to fragment:
this problem is matter because some times clicked item goes down and not seen until scroll down.
how to prevent this behavior?
please consider:
this problem exist if using navigation component to change fragment. if start fragment using supportFragmentManager.beginTransaction() or start another activity and then go to this fragment it is OK. but if I navigate to another fragment using navigation component this problem is exist.(maybe because of recreating fragment)
also this problem exist if using fragment in ViewPager. i.e recyclerView is in a fragment that handle with ViewPagerAdapter and viewPager is in HomeFragment that opened with Navigation component. if recyclerView is in HomeFragment there is no problem.
no problem with LinearLayoutManager. only with StaggeredGridLayoutManager.
there is not difference if using ViewPager2 and also FragmentStatePagerAdapter
I try to prevent recreate of fragment(by this solution) but not solved.
UPDATE:
you can clone project with this problem from here
When using Navigation Component + ViewPager + StaggeredGridLayoutManager, wrong recyclerView.computeVerticalScrollOffset() has been returned during Fragment recreate.
In general, all layout managers bundled in the support library already know how to save and restore scroll position, but in this case, we had to take responsibility for this.
class TestFragment : Fragment(R.layout.fragment_test) {
private val testListAdapter: TestListAdapter by lazy {
TestListAdapter()
}
private var layoutManagerState: Parcelable? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
postListView.apply {
layoutManager = StaggeredGridLayoutManager(
2, StaggeredGridLayoutManager.VERTICAL
).apply {
gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
}
setHasFixedSize(true)
adapter = testListAdapter
}
testListAdapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT
}
override fun onPause() {
saveLayoutManagerState()
super.onPause()
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
restoreLayoutManagerState()
}
private fun restoreLayoutManagerState () {
layoutManagerState?.let { postListView.layoutManager?.onRestoreInstanceState(it) }
}
private fun saveLayoutManagerState () {
layoutManagerState = postListView.layoutManager?.onSaveInstanceState()
}
}
Source code: https://github.com/dautovicharis/MyStaggeredListSample/tree/q_65539771
The Navigation Component behavior is normal when you navigate from one fragment to another. I mean, onDestroyView() method from the previous fragment is executed, so it means that your view is destroyed, but not the fragment. Remembering that fragment has two lifecycles one for the fragment and another one for the view, There was a video about it.
Also, there were issues registered in issue tracker in order to avoid this behavior in some cases and the GitHub issues:
https://issuetracker.google.com/issues/127932815
https://github.com/android/architecture-components-samples/issues/530
The problem is that when you have fragment that is heavy to recreate, is easier to do not destroy it and just add one fragment. So, when you go back it is not recreated. But, for this behavior is not part of navigation component.
Solutions
The easiest solution is to not use navigation component and work with the tradicional way, as you can see this works perfectly in you use case.
You can use the traditional way just for this use case, and use the navigation component for other cases.
You can inflate this view in an activity. So you are adding un activity
But if the previous tree options is not possible. You can try the following:
If you are using viewModel, you can use SaveState. Basically, it can save the data from your fragment, it is like a map data structure, so you can save positions from your list or recycler view. When go back to this fragment, get the position from this saveState object and use scrollToPosition method in order to add the real position.
Recycler view have methods for restore positions. You can see the uses cases for that, because first you need the data and then add the real position, for more details you can visit this link. This configuration for recycler view is useful also when you lose memory and you need to recreate the recycler view with asynchronous data.
Finally, if you want to understand more about how fragment works with navigation component, you can see this link

Fragment lose data from ViewModel after recreating view

Imagine that you have 2 fragments connected to one (or more) viewModel(s) and inside of activity you'll switch between them. Once you open fragment, viewModel works as expected, so I start listening for changes from onCreate method, code example:
viewModel = new ViewModelProvider(requireActivity(), new InventoryTasksFactory()).get(InventoryTasksViewModel.class);
viewModel.inventoryTasksResponse().observe(this, new Observer<Response<List<InventoryTask>>>() {
#Override
public void onChanged(Response<List<InventoryTask>> listResponse) {
handleResponse(listResponse);
}
});
But when you switching to another fragment and going back, fragment becomes blank. I understand that fragment listening changes inside of viewModel, and you should manually getting value from viewModel and I get value from viewModel inside of onCreateView method, code example:
Response<List<InventoryTask>> inventory = viewModel.inventoryTasksResponse().getValue();
if (inventory!=null){
handleResponse(inventory);
}
Problem is that Response has 3 states: Running, Success, Error, and depends on those states view is updating. So, in first fragment opening, view updating twice and it leads to skipping frames and display blinking.
I was thinking about keeping data inside of fragment, but I want to avoid data duplicating. Besides of that, in case of sharedViewModel, you'll get issues about updating data inside of fragment!
Please, help me!
Observing your data from onViewCreated(View view, Bundle savedInstanceState) might work out.

The Fragment refreshes on back- Android Navigation

I am making an application using the Android Navigation component. But I ran into a very fundamental problem which can cause problems in the whole development of my application.
The Scenario
I have this Fragment where in onViewCreated I am observing a field from my viewmodel.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProviders.of(this).get(EventDetailsViewModel::class.java)
viewModel.init(context!!,eventId)
viewModel.onEventDetailsUpdated().observe(this, Observer {
setEventDetails(it)
})
}
And in the setEventDetails method, I set recyclerviews with the data.
The PROBLEM
This fragment is a long fragment with a scroll. Suppose I scroll long way down to a section and click on a button which takes me to another fragment.
But when I come back to this fragment, it again takes me to the top and does everything that it did on first load.
That can be troubling. It is kind of recreating the whole fragment instead of keeping its old state.
What I tried
I searched a lot of questions. And went through This Github Query, This SO question, Another Git... But I could not solve my problem.
Please help, Thanks in advance.
Yes, Fragment's view will get destroyed whenever you navigate forward to another fragment.
RecyclerView's scroll position should be automatically restored, even when new instance of RecyclerView is created and new Adapter instance is set, as long as you setup everything with the same dataset as before. Also, you need to do it before the first layout pass.
This means that you need your old data and you need to have it ready immediately (no async loads!).
ViewModelProvider should return the same ViewModel instance. That ViewModel holds the data you should be able to synchronously get and display on the UI. Make sure to refactor your viewModel.init method - you don't want to make API call if data is already there in case when going back. A simple boolean isInitialized can work here, or you can even check if LiveData is empty or not.
Also, you have a subtle bug when calling observe on LiveData. onViewCreated can be called many times for the same fragment (each time you navigate forward and back!) - so observe will be called each time. Your Fragment will be subscribed many times to the same LiveData. This means you will get events multiple times (once for each subscription). This can cause issues with RecyclerView state restoration too. Your subscription is tied to Lifecycle owner you passed. You passed Fragment's Lifecycle owner which is tied to Fragment's lifecycle. What you want to do is pass Fragment view's lifecycle owner, so whenever the view is destroyed the subscription gets cleared, and you only have 1 subscription ever and only while the Fragment's view is alive. For this, you can use getViewLifecycleOwner instead of this.
You need to rely on ViewModel to restore the fragment state because ViewModel doesn't get destroyed on fragment change.
In your viewModel, create a variable listState
class HomeViewModel : ViewModel() {
var listState: Parcelable? = null
}
Then in your fragment use below code
class HomeFragment : Fragment() {
private val viewModel by navGraphViewModels<HomeViewModel>(R.id.mobile_navigation)
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (viewModel.listState != null) {
list.layoutManager?.onRestoreInstanceState(viewModel.listState)
viewModel.listState = null
}else{
//load data normally
}
override fun onDestroyView() {
super.onDestroyView()
viewModel.listState = list.layoutManager?.onSaveInstanceState()
}
}
You don't have to initialize the view model each time. Just check for null before initializing. Don't know kotlin, still it will be something like:
if(viewModel == null){
viewModel = ViewModelProviders.of(this).get(EventDetailsViewModel::class.java)
viewModel.init(context!!,eventId)
}
try putting this code where you first call your fragment.
ft = fm.beginTransaction();
ft.replace(R.id.main_fragment, yourSearchFragment, "searchFragment");
ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
ft.commit();
and this when going back to the fragment
ft = fm.beginTransaction();
ft.hide(getFragmentManager().findFragmentByTag("searchFragment"));
ft.add(R.id.main_fragment, yourDetailfragment);
ft.addToBackStack(null);
ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
ft.commit();

How do I display items in adapter when fragment is returned to the layout via the back stack?

I have a flow of fragments that my users can cycle through. To best understand its architecture, think of an install wizard. I have one parent activity that contains a FrameLayout in its ViewGroup and a user can navigate forward or backwards through the wizard. A user starts at fragment1 and ends at fragmentN such that navigating forward, the fragment manager would have a set like [Fragment1, Fragment2]. My issue is that Fragment1 is a fragment that hosts a nested fragment. This nested fragment is a RecyclerView implementation. When a user navigates back to Fragment1 from Fragment2 (popping the back stack) Fragment1 recreates its view as it should given its lifecycle, I do not set layout managers or adapters on the recycler view again because the view remembers the ones that it had however, despite my adapter being retained and containing data, none of it displays in the view.
To give a rough code impression / pseudo code:
Begin Wizard;
FragmentManger.getChildren() -> returns Fragment1
Fragment1 is a fragment that contains a nested fragment whose job is to act like a RecyclerView feed
The nested feeds code for onViewCreated() looks like this (java):
if (rvActions == null) {
// Grab a reference to the recycler widget
rvActions = view.findViewById(R.id.feed_recycler);
rvActions.setHasFixedSize(true);
// grab the appropriate layout manager from the injected factory then attach it to the recycler
if (layoutManager == null) {
layoutManager = feedFactory.getLayoutManager();
rvActions.setLayoutManager(layoutManager);
}
// Grab the appropriate adapter from the injected factory then attach it to the recycler
if(adapter == null) {
// mTiles is a class global ArrayList that contains the objects to display in the adapter, (NOTE: mTiles is not null and contains data when returning to this fragment on the back stack)
adapter = adapterFactory.getAdapter(getContext(), mTiles);
rvActions.setAdapter(adapter);
}
}
User navigates forward and adds Fragment2;
FragmentManager().getChildren() -> return [Fragment1, Fragment2]
User navigates backwards
Pop the Fragment Back Stack
FragmentManager().getChildren() -> return [Fragment1]
At this point, Fragment 1 calls onCreateView() and onViewCreated() again as shown in the fragment lifecycle https://developer.android.com/guide/components/fragments
At this point, Fragment 1 remembers its past components and there is no need to reinitialize anything. The same RecyclerView as before is returned which has the same LayoutManager and Adapter as when it was created the first time. However, none of the adapter data shows in the view. Calling NotifyDataSetChanged() or any of those other adapter methods does not update the view as well. I'm a bit confused here. Do I need to create a new adapter every time? Do I need to some how unset the underlying data list for the adapter and relink it? I can't quite grok why this would be happening if all the data is there, for some reason the data just doesn't display in the view
When a fragment is added the back stack OnDestroyView is called, destroying the view for your fragment, in your logic, rvActions is actually null when the fragment returns from the back stack, so is recreated in OnCreateView but layoutManager and adapter are not null. So when rvActions is recreated your logic does not allow for the layoutManager or adapter to be readded to the recyclerview. Simply changing to the following will fix your problem:
if (rvActions == null) {
// Grab a reference to the recycler widget
rvActions = view.findViewById(R.id.feed_recycler);
rvActions.setHasFixedSize(true);
// grab the appropriate layout manager from the injected factory then attach it to the recycler
if (layoutManager == null) {
layoutManager = feedFactory.getLayoutManager();
}
rvActions.setLayoutManager(layoutManager);
// Grab the appropriate adapter from the injected factory then attach it to the recycler
if(adapter == null) {
// mTiles is a class global ArrayList that contains the objects to display in the adapter, (NOTE: mTiles is not null and contains data when returning to this fragment on the back stack)
adapter = adapterFactory.getAdapter(getContext(), mTiles);
}
rvActions.setAdapter(adapter);
}

Categories

Resources