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
andTabLayout
- 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.
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.
- 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
withnavController?.navigateUp()
- When you use
OnBackPressedCallback
when you are at root of the ViewPager fragment, for instanceHomeFragment1
, you can't go back since you are usingnavController?.navigateUp()
. To fix it you should checkif (navController?.currentDestination?.id == navController?.graph?.startDestination)
is the root. - When you call
requireActivity().onBackPressed()
it callshandleOnBackPressed
and it creates an infinite loop. So, disable callback before and reset it again. - 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.