Viewpager2 with fragments and Jetpack navigation: Restore fragments instead of recreating them
Asked Answered
T

5

46

I have a Viewpager2 inside a Fragment (lets call it HomeFragment). That Viewpager itself also contains Fragments. When I navigate away from the HomeFragment its view will be destroyed and when I navigate back the view will be recreated. Now I set the adapter of the Viewpager2 in the HomeFragment during onViewCreated(). Therefore the adapter will be recreated when I navigate back to the HomeFragment, which also recreates all Fragments in the Viewpager2 and the current item is reset to 0. If i try to re-use the adapter that I instantiated on the first creation of the HomeFragmenti get an exception, because of this check inside of the FragmentStateAdapter:

public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
        checkArgument(mFragmentMaxLifecycleEnforcer == null);

Does anybody have an idea how I can prevent recreating everything when navigating back? Otherwise this is a pretty big performance overhead and hinders my UX.

Triennial answered 30/7, 2019 at 12:21 Comment(10)
Did you have any solution?, I have the same problemDracula
Not fully but I think the problem is something different. Recreating the adapter is fine, I just called notifyDataSetChanged() inside my databinding bindingadapter and that will be called everytime the view is created as the livedata of the viewmodel is re-attached. You need to check if the data has changed and only then call notifyDataSetChanged() as this will recreate all fragments. I will also have to try to use DiffUtil for thisTriennial
In my case, I just comment viewPager.offscreenPageLimit = 3 and current item save it statusDracula
Do you found a solution for this problem?Titanate
I updated the version to beta05 and looks like it's fine nowTitanate
did you find solution for it? I bumped with similar problem. googling...Sneeze
Any solution? I'm in the same boat. Also @extmkv, which dependency is it, when upgrading to beta05 fixed your issue?Reft
setOffscreenPageLimit(length of total pages); is this not working?Apropos
This appears to be a duplicate of: Android how to stop refreshing Fragments on tab change? The only "huge issue" here is that people often don't search before asking.Happy
I had found a solution for this by using a Custom Navigator and hiding the fragments instead of the default behaviour of replacing them. You can check out my question here.Enumerate
S
1

I've spent a bit of time with this, and I've diagnosed the problem for anyone who needs to hear it. I tried to keep my solution as conventional as possible. If we look at your statement:

Therefore the adapter will be recreated when I navigate back to the HomeFragment, which also recreates all Fragments in the Viewpager2 and the current item is reset to 0.

The problem is that the current item is reset to 0, because the list that your adapter is based off-of is recreated. To resolve the issue, we don't need to save the adapter, just the data inside of it. With that in mind, solving the problem is not difficult at all.

Let's layout some definitions:

  • HomeFragment is, as you've said, the host of your ViewPager2,
  • MainActivity is the running activity which hosts HomeFragment and all created fragments inside of it
  • We are paging through instances of MyFragment. You could even have more than one type of fragment that you page through, but that's beyond the scope of this example.
  • PagerAdapter is your FragmentStateAdapter, which is the adapter for HomeFragment's ViewPager2.

In this example, MyFragment has the constructor constructor(id : Int). Then, PagerAdapter is probably going to appear as follows:

class PagerAdapter(fm : Fragment) : FragmentStateAdapter(fm){
    
    var ids : List<Int> = listOf()

    ...
    
    override fun createFragment(position : Int) : Fragment{
        return MyFragment(ids[position])
    }
    

}

The problem that we are facing is every time you recreate PagerAdapter the constructor is called and that constructor, as we can see above, sets ids to an empty list.

My first thought was that maybe I could switch fm to be MainActivity. I don't navigate out of MainActivity so I'm not sure why, but this solution doesn't work.

Instead, what you need to do is abstract the data out of PagerAdapter. Create a "viewModel":

    /* We do NOT extend ViewModel. This naming just indicates that this is your data- 
    storage vehicle for PagerAdapter*/
    data class PagerAdapterViewModel(
    var ids : List<Int> 
    )

Then, in PagerAdapter, make the following adjustments:

class PagerAdapter(
    fm : Fragment,
    private val viewModel : PagerAdapterViewModel 
) : FragmentStateAdapter(fm){
    
    // by creating custom getters and setters, you are migrating your code to this 
    // implementation without needing to adjust any code outside of the adapter 
    var ids : List<Int>
        get() = viewModel.ids 
        set(value) {viewModel.ids = value} 
    
    override fun createFragment(position : Int) : Fragment{
        return MyFragment(ids[position])
    }
    

}

Finally, in HomeFragment, you'll have something like:

class HomeFragment : Fragment(){ 

    ... 

    /** Calling "by lazy" ensures that this object is only created once, and hence
    we retain the data stored in it, even when navigating away. */
    private val pagerAdapterViewModel : PagerAdapterViewModel by lazy{
        PagerAdapterViewModel(listOf())
    }

    private lateinit var pagerAdapter : PagerAdapter

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        pagerAdapter = PagerAdapter(this, pagerAdapterViewModel)
        pager.adapter = pagerAdapter 
        ...
    }
    
    ...

}
Sacking answered 30/10, 2020 at 15:4 Comment(1)
This doesn't work for my case, crashes the app when switching to the previous Fragment containing the ViewPager. Although, I've already implemented the fix for the crash (Fragment no longer exists for key f#0) but that doesn't work with this one. And yes, I'm using it for more than one type of Fragment.Enumerate
A
1

You can have initial pages of ViewPager as NavHostFragment which have their own back stacks which will result having the implementation in gif below

enter image description here

Create a NavHost fragment for each tab or can have generalized one will add it either

/**
 * Using [FragmentStateAdapter.registerFragmentTransactionCallback] with [FragmentStateAdapter] solves back navigation instead of using [OnBackPressedCallback.handleOnBackPressed] in every [NavHostFragment]
 * ### Should set app:defaultNavHost="true" for [NavHostFragment] for this to work
 */
class DashboardNavHostFragment : BaseDataBindingFragment<FragmentNavhostDashboardBinding>() {
    override fun getLayoutRes(): Int = R.layout.fragment_navhost_dashboard

    private var navController: NavController? = null

    private val nestedNavHostFragmentId = R.id.nested_nav_host_fragment_dashboard


    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val nestedNavHostFragment =
            childFragmentManager.findFragmentById(nestedNavHostFragmentId) as? NavHostFragment
        navController = nestedNavHostFragment?.navController

    }

}

Layout for this fragment

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:layout_constraintEnd_toEndOf="parent"
            android:background="#0D47A1"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:popupTheme="@style/ThemeOverlay.AppCompat.ActionBar" />

        </com.google.android.material.appbar.AppBarLayout>

        <fragment
            android:id="@+id/nested_nav_host_fragment_dashboard"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/appbar"

            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_graph_dashboard"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

And create a navigation graph for each page of the ViewPager2, for dashboard as you can see above we need nav_graph_dashboard.

Graph for this page is

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph_dashboard"
    app:startDestination="@id/dashboardFragment1">


    <fragment
        android:id="@+id/dashboardFragment1"
        android:name="com.smarttoolfactory.tutorial6_4_navigationui_viewpager_fragmenttoolbar_nested_navigation.blankfragment.DashboardFragment1"
        android:label="DashboardFragment1"
        tools:layout="@layout/fragment_dashboard1">
        <action
            android:id="@+id/action_dashboardFragment1_to_dashboardFragment2"
            app:destination="@id/dashboardFragment2" />
    </fragment>

    <fragment
        android:id="@+id/dashboardFragment2"
        android:name="com.smarttoolfactory.tutorial6_4_navigationui_viewpager_fragmenttoolbar_nested_navigation.blankfragment.DashboardFragment2"
        android:label="DashboardFragment2"
        tools:layout="@layout/fragment_dashboard2">
        <action
            android:id="@+id/action_dashboardFragment2_to_dashboardFragment3"
            app:destination="@id/dashboardFragment3" />
    </fragment>
    <fragment
        android:id="@+id/dashboardFragment3"
        android:name="com.smarttoolfactory.tutorial6_4_navigationui_viewpager_fragmenttoolbar_nested_navigation.blankfragment.DashboardFragment3"
        android:label="DashboardFragment3"
        tools:layout="@layout/fragment_dashboard3" >
        <action
            android:id="@+id/action_dashboardFragment3_to_dashboardFragment1"
            app:destination="@id/dashboardFragment1"
            app:popUpTo="@id/dashboardFragment1"
            app:popUpToInclusive="true" />
    </fragment>

</navigation>

And let's merge these NavHostFragments with FragmentStateAdapter and implement back press navigation which does not work by default.

/**
 * FragmentStateAdapter to contain ViewPager2 fragments inside another fragment.
 *
 * * 🔥 Create FragmentStateAdapter with viewLifeCycleOwner instead of Fragment to make sure
 * that it lives between [Fragment.onCreateView] and [Fragment.onDestroyView] while [View] is alive
 *
 * * https://mcmap.net/q/57492/-leak-canary-detects-memory-leaks-for-tablayout-with-viewpager2
 */
class ChildFragmentStateAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
    FragmentStateAdapter(fragmentManager, lifecycle) {

    init {
        // Add a FragmentTransactionCallback to handle changing
        // the primary navigation fragment
        registerFragmentTransactionCallback(object : FragmentTransactionCallback() {
            override fun onFragmentMaxLifecyclePreUpdated(
                fragment: Fragment,
                maxLifecycleState: Lifecycle.State
            ) = if (maxLifecycleState == Lifecycle.State.RESUMED) {

                // This fragment is becoming the active Fragment - set it to
                // the primary navigation fragment in the OnPostEventListener
                OnPostEventListener {
                    fragment.parentFragmentManager.commitNow {
                        setPrimaryNavigationFragment(fragment)
                    }
                }

            } else {
                super.onFragmentMaxLifecyclePreUpdated(fragment, maxLifecycleState)
            }
        })
    }


    override fun getItemCount(): Int = 3

    override fun createFragment(position: Int): Fragment {

        return when (position) {
            0 -> HomeNavHostFragment()
            1 -> DashboardNavHostFragment()
            else -> NotificationHostFragment()
        }
    }

}

You also need to be aware of memory leaks so use viewLifecycleOwner instead of lifeycleOwner if your ViewPager2 itself inside a Fragment.

You can check out other samples and more in this tutorial link.

Avalokitesvara answered 28/12, 2020 at 12:44 Comment(1)
i think it's too complex answer for my case, and i still didn't get it. I have viewpager2 in homeFragment (part of Navigation Component) and this viewpager2 is images carousel. i want whenever user click this carousel and it will zoom out (jump to zoomFragment). It's work actually, but a problem appear when get back. It's crash and Error said Expected the adapter to be 'fresh' while restoring state.Francophile
S
1

I tried setting

viewPager2.setOffscreenPageLimit(ViewPager2.OFFSCREEN PAGE LIMIT_DEFAULT);

And after that, the behavior of the fragments became normal.

More information about OffscreenPageLimit here

Springing answered 2/4, 2021 at 7:40 Comment(0)
D
0

I use FragmentActivity instead of Fragment as the arguments for the adapter's constructor and it works. When navigate back to the HomeFragment, the adapter is re-created but the child fragments are not.

Previous:

class HomeFragmentAdapter() : FragmentStateAdapter(fragment)

now:

class HomeFragmentAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity)

or

// needs FragmentActivity's lifecycle
FragmentStateAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle)
Dromond answered 26/3, 2020 at 8:58 Comment(3)
This is always the wrong solution and will cause the fragments to not properly restore their state after a configuration change or process death/recreation. You need to use the constructor that takes a Fragment to nest the fragments properly.Fermentation
Hi @Fermentation I have asked basically the same question here: #71446130 . This is a common question that remains unanswered and is very confusing. Are we correct in saying FragmentStateAdapters save and restore fragments for config changes only, and are incapable of doing so when we pop "back" into a viewpager2? Would really appreciate an answer on this or my question thanks.Alveolus
@DanielWilson - I don't know what you are talking about. As long as you are using the correct constructor, every Fragment is going to save and restore its state perfectly, whether that is across being on the back stack, config changes, or process death.Fermentation
K
-1

It's a bug in a ViewPager2 (or actually in a RecyclerView) https://issuetracker.google.com/issues/151212195

You have to reuse an old adapter when go back (to avoid fragments duplicates) and in HomeFragment's onDestroyView() call viewPager.adapter = null

[Updated 04.08.2022] It's very strange that my answer is downvoted o_O. Again, you don't have to recreate adapters in onViewCreated, it's a mistake (not only for a ViewPager, but also for a RecyclerView and others who use adapter approach).

Kosiur answered 21/3, 2022 at 7:54 Comment(2)
How exactly we're reusing the old adapter?Alejoa
@JimlyAsshiddiqy Just create it in onCreate, not in onCreateViewKosiur

© 2022 - 2024 — McMap. All rights reserved.