Android Jetpack Navigation with ViewPager and TabLayout
N

5

37

For a new app i use Jetpack Navigation Library to implement proper back navigation. The first level of navigation is a navigation drawer which works fine with jetpack navigation as described in the documentation. But there is another level of navigation implemented with ViewPager and TabLayout. The fragments switched by the TabLayout contain additional linear navigation hierarchy. However, there seems to be no support for ViewPager/TabLayout in Jetpack Navigation. A FragmentPagerAdapter has to be implemented and the managed back stack ends when switching tabs. There is a disconnect between the top level navigation and the navigation inside each tab. Is there any way to make this work with Jetpack Navigation?

Nebulosity answered 27/9, 2018 at 15:21 Comment(1)
Does this answer your question? How to implement a ViewPager using new Navigation Architecture Component? – Molina
L
17

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.

Lipman answered 18/3, 2019 at 12:23 Comment(1)
Shall we have to follow the old way of managing fragments back stacks, transactions, animations then? – Sari
W
10

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

Waltz answered 23/6, 2020 at 8:12 Comment(1)
When I am in DashBoardFragment or NotificationFragment and (backStackEntryCount = 0) then I press BackPress again, I wanna it to go back to HomeFragment, So how to solve that problem? – Piton
B
4

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)
Blindfold answered 9/6, 2020 at 18:19 Comment(1)
if i had 1000 accounts, i will up-vote this answer from each account. I was stuck on this for almost a day. – Scorbutic
G
3

What worked for me so far:

In navigation_graph.xml

  • make your ViewPagerFragment the root of a nested graph
  • connect your in and out navigation to the nested graph

in nested graph:

  • add the ChildFragments of the ViewPager to the nested graph

I didnt need to change the ViewPager, and Directions were created for the Child Fragments so navigation is possible from there.

Giraldo answered 10/12, 2018 at 21:15 Comment(2)
Can you explain this better? I don't understand what you mean by "make your ViewPagerFragment..." What is a ViewPagerFragment? I know what a ViewPager is and I know it contains Fragments. Is there a working example somewhere? – Deliadelian
I think by ViewPagerFragment he meant that the fragment that contains viewpager in it – Gilbertgilberta
C
0

Yes, but you will have to implement your own custom destination, by implementing the class Navigator and overriding at least the methods popBackStack() and navigate().

In your navigate, you will have to call the ViewPager.setCurrentTab() and add it to your back stack. Something like:

lateinit var viewPager: ViewPager? = null // you have to provide this in the constructor

private val backstack: Deque<Pair<Int, View>> = ArrayDeque

override fun navigate(destination: Destination, args: Bundle?,
                      navOptions: NavOptions?, navigatorExtras: Extras?
): NavDestination? {

    viewPager.currentItem = destination.id
    backstack.remove(destination.id) // remove so the stack has never two of the same
    backstack.addLast(destination.id)

    return destination
}

In your popBackStack, you will have to set back the last item selected. Something like:

override fun popBackStack(): Boolean {
    if(backstack.size() <= 1) return false

    viewPager.currentItem = backstack.peekLast()
    backstack.removeLast()

    return true
}

You can find a brief explanation on Android docs and this example of custom navigator for FragmentDialog.

After implementing your ViewPagerNavigator, you will have to add it to your NavController and set the listeners of tab views selection to call NavController.navigate().

I hope someone will implement a library for all this common patterns (ViewPager, ViewGroup, FragmentDialog), if anyone find it, put it on the comments.

Cumbrous answered 5/2, 2019 at 15:7 Comment(2)
A library would be amazing. Did you ever get around to writing a demo app yourself? I've looked into different ideas, but now found a statement by a Google supporter suggesting to treat tabs as before and separate from Jetpack Navigation. – Lipman
Not for ViewPager, I am currently changing all my ViewPager's for recycler views with the SnapPagerHelper. I think navigation of most cases that involves a ViewPager-like view and TabLayout is better achieved by sending in the navigation extras the tab ID to be activated (the case Google probably have in mind). But in some cases, you may want navigation because the ViewPager acts like a stepper. For this, you need a proper back stack handling. I have implemented a ViewNavigator that replaces the content of a ViewGroup with a custom view and I use it for my signup flow. – Cumbrous

© 2022 - 2024 β€” McMap. All rights reserved.