Jetpack Navigation: Bottom Nav and Multiple Backstacks
May 9, 2021
While official support for multiple backstacks for Bottom Navigation when using a Jetpack Navigation is pending from Google, there is a workaround you can use in the meantime.
But first, why would you want to take advantage of multiple backstacks when using bottom navigation? Multiple backstacks lets you save and restore state for multiple screens in your app. When using bottom nav, each Tab gets to retain its state. This lets user easily access the last screen they navigated to within a tab before switching to another tab thus avoiding the element of surprise (and frustration) as you navigate between various parts of your app.
Let’s see how this can be done with Jetpack navigation library. We will build a simple single Activity app (as recommended by Google) where each tab has its own navigation destination graph.
Gradle Dependencies
Make sure you have Jetpack navigation Gradle dependencies your app module build.gradle:
implementation 'androidx.navigation:navigation-fragment-ktx:<version>' implementation 'androidx.navigation:navigation-ui-ktx:<version>'
Note that we use FragmentContainerView
and do not define app:navGraph
NavigationExtensions
Copy NavigationExtensions.kt into your project which does the following:
Manages the various graphs needed for a
BottomNavigationView
.This is a workaround until the Navigation Component supports multiple back stacks.
Navigation Graphs
We will have an app with 3 tabs so we need 3 navigation graphs:
navigation/nav_tab_0.xml:
<?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/tab0" app:startDestination="@+id/fragment0"> <fragment android:id="@+id/fragment0" android:name="io.valueof.donotrefreshtab.ui.Fragment0" android:label="@string/fragment_title_0" tools:layout="@layout/fragment_0"> <action android:id="@+id/action_fragment_0_to_item_detail" app:destination="@+id/itemDetail" /> </fragment> <fragment android:id="@+id/itemDetail" android:name="io.valueof.donotrefreshtab.ui.ItemDetailFragment" android:label="@string/fragment_item_detail_title" tools:layout="@layout/fragment_item_detail"> <argument android:name="itemId" app:argType="integer" /> </fragment> </navigation>
navigation/nav_tab_1.xml:
<?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/tab1" app:startDestination="@+id/fragment1"> <fragment android:id="@+id/fragment1" android:name="io.valueof.donotrefreshtab.ui.Fragment1" android:label="@string/fragment_title_1" tools:layout="@layout/fragment_1" /> </navigation>
navigation/nav_tab_2.xml:
<?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/tab2" app:startDestination="@+id/fragment2"> <fragment android:id="@+id/fragment2" android:name="io.valueof.donotrefreshtab.ui.Fragment2" android:label="@string/fragment_title_2" tools:layout="@layout/fragment_2" /> </navigation>
Fragments
For simplicity sake, the Fragments are very similar and contain helpful lifecycle debug info. Here is one of the Fragments:
import android.content.Context import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.zhuinden.fragmentviewbindingdelegatekt.viewBinding import io.valueof.donotrefreshtab.R import io.valueof.donotrefreshtab.databinding.Fragment0Binding import io.valueof.donotrefreshtab.model.Item import io.valueof.donotrefreshtab.presentation.Tab0ViewModel import kotlinx.coroutines.flow.collect import timber.log.Timber class Fragment0 : Fragment(R.layout.fragment_0) { private val binding by viewBinding(Fragment0Binding::bind) private val viewModel: Tab0ViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Timber.d("fragment onCreate") } override fun onAttach(context: Context) { super.onAttach(context) Timber.d("fragment onAttach") } override fun onDetach() { super.onDetach() Timber.d("fragment onDetach") } override fun onDestroyView() { super.onDestroyView() Timber.d("fragment onDestroyView") } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val itemAdapter = ItemAdapter(this::onItemClicked) binding.recyclerView.apply { setHasFixedSize(true) adapter = itemAdapter } lifecycleScope.launchWhenResumed { viewModel.itemList.collect { itemList -> Timber.d("fragment load data ${itemList.size}") itemAdapter.submitList(itemList) } } } private fun onItemClicked(item: Item) { val action = Fragment0Directions.actionFragment0ToItemDetail(item.id) findNavController().navigate(action) } }
Activity Layout
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.fragment.app.FragmentContainerView android:id="@+id/navHostContainer" android:layout_width="0dp" android:layout_height="0dp" app:defaultNavHost="true" app:layout_constraintBottom_toTopOf="@id/bottomNavigationView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottomNavigationView" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/navHostContainer" app:menu="@menu/bottom_nav_menu" /> </androidx.constraintlayout.widget.ConstraintLayout>
Note that our nav host container is defined as FragmentContainerView
(not fragment) and it does not contain app:navGraph
element. We will manager nav graph in the Activity code below instead.
Activity Code
Note that you must have the NavigationExtensions from above added to project for the Activity code below to compile:
import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.lifecycle.LiveData import androidx.navigation.NavController import androidx.navigation.ui.setupActionBarWithNavController import io.valueof.donotrefreshtab.databinding.ActivityMainBinding import io.valueof.donotrefreshtab.extensions.setupWithNavController import timber.log.Timber class MainActivity : AppCompatActivity(R.layout.activity_main) { private lateinit var binding: ActivityMainBinding private var currentNavController: LiveData<NavController>? = null private val onDestinationChangedListener = NavController.OnDestinationChangedListener { controller, destination, arguments -> Timber.d("controller: $controller, destination: $destination, arguments: $arguments") Timber.d("controller graph: ${controller.graph}") // if you need to show/hide bottom nav or toolbar based on destination // binding.bottomNavigationView.isVisible = destination.id != R.id.itemDetail } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) if (savedInstanceState == null) { setUpBottomNavigationBar() } } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) setUpBottomNavigationBar() } private fun setUpBottomNavigationBar() { val navGraphIds = listOf( R.navigation.nav_tab_0, R.navigation.nav_tab_1, R.navigation.nav_tab_2 ) val controller = binding.bottomNavigationView.setupWithNavController( navGraphIds = navGraphIds, fragmentManager = supportFragmentManager, containerId = R.id.navHostContainer, intent = intent ) controller.observe(this) { navController -> setupActionBarWithNavController(navController) // unregister old onDestinationChangedListener, if it exists currentNavController?.value?.removeOnDestinationChangedListener( onDestinationChangedListener ) // add onDestinationChangedListener to the new NavController navController.addOnDestinationChangedListener(onDestinationChangedListener) } currentNavController = controller } override fun onSupportNavigateUp(): Boolean { return currentNavController?.value?.navigateUp() ?: false } }
Note that we also added OnDestinationChangedListener
which is useful in order to control many aspects of your UI based on current destination as well as being useful for debugging as your app transitions through navigation destinations.
The Result
Now you get an app with bottom navigation where each start destination Fragment per tab gets created once and does not get recreated as you switch between tabs. The view of that Fragment will get destroyed as you leave a tab, however. As far as I am aware, this is an existing limitation of the Jetpack navigation library which I hope will addressed sometime soon. In the meantime, it’s recommended to address this issue by using caching in whatever layer is the most appropriate to reduce rendering time when a tab is re-opened again.
Each tab has its own backstack and remember scroll position:
You can see full source code for this sample app at https://github.com/jshvarts/BottomNavigationDoNotRefreshTabDemo