Android Jetpack Navigation: Support Multiple Back Stacks for BottomNavigationView

January 14, 2020

Read this issue and Android Jetpack Navigation Fragment Lost State After Navigation to understand the problem of Jetback Navigation 2.2.0.

The following solution is derrived from https://github.com/android/architecture-components-samples/tree/master/NavigationAdvancedSample with my observations and notes while implementing the solution.

Copy NavigationExtensions.kt

Copy NavigationExtensions.kt into your project.

Edit layout file

Remove NavHostFragment

NOTE: Assuming you already Setup BottomNavigationView With Jetpack Navigation UI

<fragment
    android:id="@+id/nav_host_fragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/mobile_navigation"
    />

Replace it with FragmentContainerView

<androidx.fragment.app.FragmentContainerView
    android:id="@+id/nav_host_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    />

Assuming you have 3 tabs in BottomNavigationView, you need to create 3 navigation files.

Sample: https://github.com/android/architecture-components-samples/tree/master/NavigationAdvancedSample/app/src/main/res/navigation

BottomNavigationView menu file

The id of each menu item of BottomNavigationView must match the id of navigation files, else BottomNavigationView (controlled by NavigationExtensions.kt) tab switching won’t work.

Sample: https://github.com/android/architecture-components-samples/blob/master/NavigationAdvancedSample/app/src/main/res/menu/bottom_nav.xml

Setup MainActivity

class MainActivity : AppCompatActivity(), NavController.OnDestinationChangedListener {
    private val viewModel by viewModels<MainViewModel>()
    private var currentNavController: LiveData<NavController>? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main)
        setSupportActionBar(toolbar)

        /*
        appBarConfiguration = AppBarConfiguration(
            // navController.graph,
            setOf(
                 R.id.navigate_home, R.id.navigate_collection, R.id.navigate_profile
            ),
            drawerLayout
        )

        setupActionBarWithNavController(navController, appBarConfiguration)
        navView.setupWithNavController(navController)
        bottomNavView.setupWithNavController(navController)

        // make sure appbar/toolbar is not hidden upon fragment switch
        navController.addOnDestinationChangedListener { controller, destination, arguments ->
            if (destination.id in bottomNavDestinationIds) {
                appBarLayout.setExpanded(true, true)
            }
        }
         */

        val navGraphIds = listOf(R.navigation.home, R.navigation.albumlist, R.navigation.test)
        val controller = bottomNavView.setupWithNavController(
            navGraphIds = navGraphIds,
            fragmentManager = supportFragmentManager,
            containerId = R.id.nav_host_container,
            intent = intent
        )
        // Whenever the selected controller changes, setup the action bar.
        controller.observe(this, Observer { navController ->

            setupActionBarWithNavController(navController)
            // optional NavigationView for Drawer implementation
            // navView.setupWithNavController(navController)

            addOnDestinationChangedListener(navController)
        })
        currentNavController = controller
    }

    private fun addOnDestinationChangedListener(navController: NavController) {
        // ensure only one listener is active
        navController.removeOnDestinationChangedListener(this)
        navController.addOnDestinationChangedListener(this)
    }

    override fun onDestinationChanged(
        controller: NavController,
        destination: NavDestination,
        arguments: Bundle?
    ) {
        if (destination.id in bottomNavDestinationIds) {
            appBarLayout.setExpanded(true, true)
        }
    }
}

Observations and Notes

  • Fragments are destroyed and created at each navigation (navigating from one fragment to another), so be aware of this to design your initialization logic properly.

  • Assuming you load a item fragment based on item id (you inspect the navArgs, query item from database and load value into EditText). You decide to change the EditText, and while editing you launch another fragment to pick/view something, and back to the current fragment. You realize the EditText is back to the original value and your edit is lost. As mentioned earlier, fragment is destoryed and created at each navigation, so the initialization code run again to overwrite EditText with value from database. To avoid this from happening, you need to store some state in ViewModel to indicate the fragment is loaded, and avoid re-initializing the UI. This require quite some testing to get it right.

  • After implementating the solution is this article, RecyclerView and EditText states are actually preserved as you switch tab using BottomNavigationView or navigating to another fragment. If that doesn’t happened, check 1) The view in layout file actually has an id 2) Check if your view initialization code overwrite the state value (did you load an empty adapter to RecylerView before loading the actual items, thus causing previous state lost, or overwriting EditText with database value again).

  • If you load EditText in RecyclerView, the state of EditText is not automatically saved. You need to utilize ViewModel to save and restore the last value.

  • Since you are using multiple navigation files, and there are probably duplicates declarations. You might want to explore nested navigation graphs or use <include>. Becareful not not bump into StackOverflowError when you use <include> in a cyclic manner (A include B and B include A). At such scenario, you probably want to use Nested Navigation Graph instead of <include>.

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.