Android Jetpack Navigation with ViewPager and TabLayout

Experimented with different approaches to handle TabLayout with Jetpack Navigation. But hit issues like having a full history of switching between tabs multiple times etc.

Browsing known Google Android Issues before raising a request for a demo, I found this existing issue.

Its status is Closed marked as Intended Behavior with the following explanation:

Navigation focuses on elements that affect the back stack and tabs do not affect the back stack - you should continue to manage tabs with a ViewPager and TabLayout - Referring to Youtube training.


This worked for me. I added the viewPagerTabs fragment to nested graph like so:

<navigation
        android:id="@+id/nav_nested_graph"
        app:startDestination="@id/nav_viewpager_tab">
        <fragment
            android:id="@+id/nav_pager_tab"
            android:name="com.android.ui.tabs.TabsFragment"
            android:label="@string/tag_tabs"
            tools:layout="@layout/tabs_fragment">
            <action
                android:id="@+id/action_nav_tabs_to_nav_send"
                app:destination="@id/nav_send_graph">
        </fragment>
</navigation>

and then inside the child fragment of the viewpager:

val action = TabsFragmentDirections.actionNavTabsToNavSend()
findNavController().navigate(action)

How you implement appbar navigation changes your implementation. If you wish to use navigation from page to detail, it's using same fragmentManager the main NavHost fragment uses. It's like going to detail fragment/activity.

enter image description here

Home, Dashboard and Notification have their own graphs so they can open their child fragments while Login fragment belongs to main nav graph so it opens it's fragment as detail fragment.

This implementation requires main NavHostFragment in a fragment or MainActivity.

Layouts

activity_main.xml

<?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.coordinatorlayout.widget.CoordinatorLayout
            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">

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

        <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:layout_behavior="@string/appbar_scrolling_view_behavior">

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

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

        </androidx.constraintlayout.widget.ConstraintLayout>

    </androidx.coordinatorlayout.widget.CoordinatorLayout>

</layout>

As of now androidx.fragment.app.FragmentContainerView crashes with appbar navigation, so use fragment if you encounter navController not found error

fragment_main.xml

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

    <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:background="@color/colorPrimary"
            app:tabTextColor="#fff"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:tabMode="scrollable" />

    <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tabLayout" />

</androidx.constraintlayout.widget.ConstraintLayout>

Fragments for ViewPager2 that have NavHostFragment, only add one, others have the same layout as this one except app:navGraph="@navigation/nav_graph_home" with their own graphs.

fragment_nav_host_home.xml

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

    <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nested_nav_host_fragment_home"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"

            app:defaultNavHost="false"
            app:navGraph="@navigation/nav_graph_home" />

</androidx.constraintlayout.widget.ConstraintLayout>

Nothing special with other fragments, skipped them, i added link for full sample and other navigation component examples if you are interested.

Navivgation Graphs

Main nav graph, nav_graph.xml

<!-- MainFragment-->
<fragment
        android:id="@+id/main_dest"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.MainFragment"
        android:label="MainFragment"
        tools:layout="@layout/fragment_main">

    <!-- Login -->
    <action
            android:id="@+id/action_main_dest_to_loginFragment2"
            app:destination="@id/loginFragment2" />
</fragment>


<!-- Global Action Start -->
<action
        android:id="@+id/action_global_start"
        app:destination="@id/main_dest"
        app:popUpTo="@id/main_dest"
        app:popUpToInclusive="true" />

<!-- Login -->
<fragment
        android:id="@+id/loginFragment2"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.LoginFragment2"
        android:label="LoginFragment2" />

And one of the nav graph for pages of ViewPager2, others are same.

nav_graph_home.xml

<fragment
        android:id="@+id/home_dest"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.navhost.HomeNavHostFragment"
        android:label="HomeHost"
        tools:layout="@layout/fragment_navhost_home" />

<fragment
        android:id="@+id/homeFragment1"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.HomeFragment1"
        android:label="HomeFragment1"
        tools:layout="@layout/fragment_home1">
    <action
            android:id="@+id/action_homeFragment1_to_homeFragment2"
            app:destination="@id/homeFragment2" />
</fragment>

<fragment
        android:id="@+id/homeFragment2"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.HomeFragment2"
        android:label="HomeFragment2"
        tools:layout="@layout/fragment_home2">
    <action
            android:id="@+id/action_homeFragment2_to_homeFragment3"
            app:destination="@id/homeFragment3" />
</fragment>

<fragment
        android:id="@+id/homeFragment3"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.HomeFragment3"
        android:label="HomeFragment3"
        tools:layout="@layout/fragment_home3" />

Important thing with ViewPager nav graphs is to use fragment on screen instead of NavHost fragment, you need otherwise set navigation with

  if (navController!!.currentDestination == null || navController!!.currentDestination!!.id == navController!!.graph.startDestination) {
        navController?.navigate(R.id.homeFragment1)
    }

in NavHost fragments when fragment's navHost is attached.

MainActivity

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        listenBackStackChange()

    }

    private fun listenBackStackChange() {
        // Get NavHostFragment
        val navHostFragment =
            supportFragmentManager.findFragmentById(R.id.main_nav_host_fragment)

        // ChildFragmentManager of NavHostFragment
        val navHostChildFragmentManager = navHostFragment?.childFragmentManager

        navHostChildFragmentManager?.addOnBackStackChangedListener {

            val backStackEntryCount = navHostChildFragmentManager.backStackEntryCount
            val fragments = navHostChildFragmentManager.fragments


            Toast.makeText(
                this,
                "Main graph backStackEntryCount: $backStackEntryCount, fragments: $fragments",
                Toast.LENGTH_SHORT
            ).show()
        }
    }
}

listenBackStackChange function is just to observe how main fragment stack and fragment change, it has only observational purpose, remove it if not needed.

Adapter for ViewPager2

class ChildFragmentStateAdapter(private val fragment: Fragment) :
    FragmentStateAdapter(fragment) {

    override fun getItemCount(): Int = 4

    override fun createFragment(position: Int): Fragment {


        return when (position) {
            0 -> HomeNavHostFragment()
            1 -> DashBoardNavHostFragment()
            2 -> NotificationHostFragment()
            else -> LoginFragment1()
        }
    }

}

Fragments with HostFragment have no appbar navigation since it's not implemented in this example.

MainFragment

class MainFragment : BaseDataBindingFragment() {

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

    // TabLayout
    val tabLayout = dataBinding.tabLayout
    // ViewPager2
    val viewPager = dataBinding.viewPager

    /*
        🔥 Set Adapter for ViewPager inside this fragment using this Fragment,
        more specifically childFragmentManager as param
     */
    viewPager.adapter = ChildFragmentStateAdapter(this)

    // Bind tabs and viewpager
    TabLayoutMediator(tabLayout, viewPager) { tab, position ->
       when(position) {
           0->  tab.text = "Home"
           1->  tab.text = "Notification"
           2->  tab.text = "Dashboard"
           3->  tab.text = "Login"
       }
    }.attach()

}

override fun getLayoutRes(): Int = R.layout.fragment_main

}

MainFragment sets tabs, BaseDataBindingFragment only uses databinding via getLayoutRes()

Finally Pager's nested fragments

class HomeNavHostFragment : BaseDataBindingFragment<FragmentNavhostHomeBinding>() {
   
    override fun getLayoutRes(): Int = R.layout.fragment_navhost_home

    var navController: NavController? = null

    private val nestedNavHostFragmentId = R.id.nested_nav_host_fragment_home
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        /*
            🔥 This is navController we get from findNavController not the one required
            for navigating nested fragments
         */
        val mainNavController =
            Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)

        val nestedNavHostFragment =
            childFragmentManager.findFragmentById(nestedNavHostFragmentId) as? NavHostFragment
        navController = nestedNavHostFragment?.navController
        
        /*
            🔥 Alternative 1
            Navigate to HomeFragment1 if there is no current destination and current destination
            is start destination. Set start destination as this fragment so it needs to
            navigate next destination.

            If start destination is NavHostFragment it's required to navigate to first
         */
//        if (navController!!.currentDestination == null || navController!!.currentDestination!!.id == navController!!.graph.startDestination) {
//            navController?.navigate(R.id.homeFragment1)
//        }

        /*
            🔥 Alternative 2 Reset graph to default status every time this fragment's view is created
            ❌ This does not work if initial destination if this fragment because it repeats
            creating this fragment in an infinite loop since graph is created every time
         */
//        val navInflater = navController!!.navInflater
//        nestedNavHostFragment!!.navController.graph = graph
//        val graph = navController!!.navInflater.inflate(navGraphId)
//        nestedNavHostFragment!!.navController.graph = graph



        // Listen on back press
        listenOnBackPressed()

    }



    private fun listenOnBackPressed() {
        requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
    }

    override fun onResume() {
        super.onResume()
        callback.isEnabled = true
    }

    override fun onPause() {
        super.onPause()
        callback.isEnabled = false
    }
    
    // This should be false, true causes problems on rotation
    val callback = object : OnBackPressedCallback(false) {

        override fun handleOnBackPressed() {

            // Get NavHostFragment
            val navHostFragment =
                childFragmentManager.findFragmentById(nestedNavHostFragmentId)
            // ChildFragmentManager of the current NavHostFragment
            val navHostChildFragmentManager = navHostFragment?.childFragmentManager

            val currentDestination = navController?.currentDestination
            val backStackEntryCount = navHostChildFragmentManager!!.backStackEntryCount

            val isAtStartDestination =
                (navController?.currentDestination?.id == navController?.graph?.startDestination)

      

            // Check if it's the root of nested fragments in this navhost
            if (navController?.currentDestination?.id == navController?.graph?.startDestination) {

                /*
                 Disable this callback because calls OnBackPressedDispatcher
                  gets invoked  calls this callback  gets stuck in a loop
                */
                isEnabled = false
                requireActivity().onBackPressed()
                isEnabled = true
            } else {
                navController?.navigateUp()
            }
        }
    }

}

Important thing here is to use onBackPressedDispatcher correctly. There are some issues with nested fragment back navigation in ViewPager2.

  1. Since fragments are not added to main back stack when you press back button, Activity skips ViewPager back stack completely. To overcome this issue you should use OnBackPressedCallback with navController?.navigateUp()
  2. When you use OnBackPressedCallback when you are at root of the ViewPager fragment, for instance HomeFragment1, you can't go back since you are using navController?.navigateUp(). To fix it you should check if (navController?.currentDestination?.id == navController?.graph?.startDestination) is the root.
  3. When you call requireActivity().onBackPressed() it calls handleOnBackPressed and it creates an infinite loop. So, disable callback before and reset it again.
  4. Also disable callback in onPause() when your fragment is not visible to prevent it getting called when other fragments' handleOnBackPressed is called

I created other examples including the one with nested navigation for child fragments of ViewPager2, this is the link for current project. For the one with image below. It's more tricky requires use of LiveData and has issue with rotation. Also add another example with ViewModel that solves this issue either.

enter image description here