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.

sunilson
  • 1,449
  • 15
  • 31
  • 3
    Did you have any solution?, I have the same problem – Mahmood Ali Aug 17 '19 at 08:39
  • 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 this – sunilson Aug 17 '19 at 11:02
  • 1
    In my case, I just comment `viewPager.offscreenPageLimit = 3` and current item save it status – Mahmood Ali Aug 25 '19 at 15:35
  • Do you found a solution for this problem? – extmkv Oct 15 '19 at 16:04
  • I updated the version to `beta05` and looks like it's fine now – extmkv Oct 15 '19 at 21:11
  • 1
    did you find solution for it? I bumped with similar problem. googling... – Georgiy Chebotarev Jun 03 '20 at 20:08
  • 2
    Any solution? I'm in the same boat. Also @extmkv, which dependency is it, when upgrading to beta05 fixed your issue? – Karthik Aug 14 '20 at 03:57
  • `setOffscreenPageLimit(length of total pages);` is this not working? – Priyanka Dec 29 '20 at 12:45
  • This appears to be a duplicate of: [Android how to stop refreshing Fragments on tab change?](https://stackoverflow.com/questions/28494637/android-how-to-stop-refreshing-fragments-on-tab-change/32872290) The only "huge issue" here is that people often don't search before asking. – Martin Zeitler Jan 01 '21 at 08:41
  • 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](https://stackoverflow.com/questions/67450396/fragment-containing-viewpager-reloads-when-switched-back-from-another-fragment-u). – Lalit Fauzdar Oct 01 '21 at 21:43

5 Answers5

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

}
Kraigolas
  • 5,121
  • 3
  • 12
  • 37
  • 1
    This doesn't work for [my case](https://stackoverflow.com/questions/67450396/fragment-containing-viewpager-reloads-when-switched-back-from-another-fragment-u), 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. – Lalit Fauzdar May 10 '21 at 14:34
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://stackoverflow.com/questions/61779776/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.

Thracian
  • 43,021
  • 16
  • 133
  • 222
  • 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.` – Nanda Z Dec 28 '20 at 16:32
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

ANDREIRTT
  • 42
  • 4
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)
Dio_V
  • 69
  • 1
  • 10
  • 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. – ianhanniballake Dec 31 '20 at 17:43
  • Hi @ianhanniballake I have asked basically the same question here: https://stackoverflow.com/questions/71445630/viewpager2-how-to-restore-fragments-when-navigating-back . 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. – Daniel Wilson Mar 12 '22 at 00:06
  • @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](https://developer.android.com/guide/fragments/saving-state) perfectly, whether that is across being on the back stack, config changes, or process death. – ianhanniballake Mar 12 '22 at 00:24
-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).

blinker
  • 599
  • 5
  • 18