Jetpack Navigation: Bottom Nav and Multiple Backstacks

May 9, 2021

IMG_0756.jpg

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.

switch_tabs.gif

Each tab has its own backstack and remember scroll position:

separate_backstacks.gif

You can see full source code for this sample app at https://github.com/jshvarts/BottomNavigationDoNotRefreshTabDemo

 
Previous
Previous

Manage multiple Git configs

Next
Next

Consumable LiveData Wrapper