Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
JetPack Bottom Bar Navigation with Sections State

Android JetPack Navigation + Material BottomNavigationView keeping sections navigation state

This mimicks what you can achieve in iOS by using a root UITabBarController with a UINavigationController for each section.

When you switch to a different section and then you come back, you will be exactly at the last position you were in that section.

Disclaimer

The solution described here is a temporary workaround with code taken from NavigationAdvancedSample, while waiting for JetPack Navigation official multiple backstack support. Check this tweet

IMHO it's better than a fully handmade custom solution from scratch without JetPack Navigation:

  • you can use nav graphs, view models, safe args, deep links
  • looking at mid/long range period in a project's maintenance, this is going in the same direction as JetPack

however take this as a study and experimental material, being sure you understand it fully before applying it to a real production project.

I followed the principle of:

Write code that's easy to delete, not easy to extend

That's an interesting article

since this will be useless once official support in JetPack Navigation is implemented and you want to keep your codebase as clean as possible.

USE THIS AT YOUR OWN RISK. FEEL FREE TO COMMENT IF YOU HAVE SUGGESTIONS FOR IMPROVEMENT OR TO REPORT SOME REAL SCENARIOS WHERE YOU USED IT AND THE OUTCOMES, WHICH CAN SERVE ALSO AS FEEDBACK TO PEOPLE WORKING ON JETPACK NAVIGATION.

License: Apache 2.0

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Recipe

Create a new project or open an existing one. You need the following dependencies:

def lifecycle_version = "2.2.0-rc03"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"

def nav_version = "2.2.0-rc04"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

// Support
implementation "androidx.appcompat:appcompat:1.1.0"
implementation "androidx.constraintlayout:constraintlayout:2.0.0-beta4"
implementation "com.google.android.material:material:1.1.0-rc01"

Copy:

  • NavigationBottomBarSectionsStateKeeperWorkaround.kt
  • NavigationWorkaroundExtensions.kt -> import your project's R to resolve animations imports

in your project (both files are in this gist after the README).

In your activity layout you need to have:

  • androidx.fragment.app.FragmentContainerView
  • com.google.android.material.bottomnavigation.BottomNavigationView

Example:

activity_main.xml:

<?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"
    android:orientation="vertical">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:animateLayoutChanges="true"
        android:theme="@style/AppTheme.AppBarOverlay"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/AppTheme.PopupOverlay" />

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
        app:layout_constraintTop_toBottomOf="@id/appbar" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottom_navigation"
        style="@style/Widget.MaterialComponents.BottomNavigationView.Colored"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:menu="@menu/main_bottombar" />

</androidx.constraintlayout.widget.ConstraintLayout>

Then, create the menu which contains the bottom bar items. Example:

main_bottombar.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/nav_section1"
        android:icon="@android:drawable/ic_menu_call"
        android:title="Section 1" />

    <item
        android:id="@+id/nav_section2"
        android:icon="@android:drawable/ic_menu_add"
        android:title="Section 2" />

    <item
        android:id="@+id/nav_section3"
        android:icon="@android:drawable/ic_menu_camera"
        android:title="Section 3" />
</menu>

Create one nav graph for each section in the tab bar. In this example, the 3 nav graphs must be called:

nav_section1
nav_section2
nav_section3

because for the link between the bottom bar and the sections to work, main_bottombar menu IDs must match navigation graph IDs.

Populate each section with your navigation items and don't forget to set every startDestination.

now in your MainActivity.kt add:

class MainActivity : AppCompatActivity() {
    private val navSectionsStateKeeper by lazy {
        NavigationBottomBarSectionsStateKeeperWorkaround(
            activity = this,
            navHostContainerID = R.id.nav_host_fragment,
            navGraphIds = listOf(
                R.navigation.nav_section1,
                R.navigation.nav_section2,
                R.navigation.nav_section3
            ),
            bottomNavigationViewID = R.id.bottom_navigation
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        setSupportActionBar(toolbar)
        supportActionBar?.apply {
            setDisplayHomeAsUpEnabled(true)
            setDisplayShowHomeEnabled(true)
        }

        navSectionsStateKeeper.onCreate(savedInstanceState)
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
        navSectionsStateKeeper.onRestoreInstanceState(savedInstanceState)
    }

    override fun onSupportNavigateUp() =
        navSectionsStateKeeper.onSupportNavigateUp()
    
    override fun onBackPressed() {
        if (!navSectionsStateKeeper.onSupportNavigateUp())
            super.onBackPressed()
    }
}

Run and enjoy 🎉

package jetpack.navigation.workaround
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.navigation.NavController
import androidx.navigation.ui.setupActionBarWithNavController
import com.google.android.material.bottomnavigation.BottomNavigationView
import java.lang.ref.WeakReference
class NavigationBottomBarSectionsStateKeeperWorkaround(
activity: AppCompatActivity,
private val navHostContainerID: Int,
private val navGraphIds: List<Int>,
private val bottomNavigationViewID: Int
) {
private var currentNavController: LiveData<NavController>? = null
private val activityRef = WeakReference(activity)
fun onCreate(savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
setupBottomNavigationBar()
} // Else, need to wait for onRestoreInstanceState
}
fun onRestoreInstanceState(savedInstanceState: Bundle?) {
// Now that BottomNavigationBar has restored its instance state
// and its selectedItemId, we can proceed with setting up the
// BottomNavigationBar with Navigation
setupBottomNavigationBar()
}
/**
* Called on first creation and when restoring state.
*/
private fun setupBottomNavigationBar() {
val activity = activityRef.get() ?: return
val bottomNavigationView =
activity.findViewById<BottomNavigationView>(bottomNavigationViewID)
val controller = bottomNavigationView.setupWithNavController(
navGraphIds = navGraphIds,
fragmentManager = activity.supportFragmentManager,
containerId = navHostContainerID,
intent = activity.intent
)
// Whenever the selected controller changes, setup the action bar.
controller.observe(activity, Observer { navController ->
activity.setupActionBarWithNavController(navController)
})
currentNavController = controller
}
fun onSupportNavigateUp() = currentNavController?.value?.navigateUp() ?: false
}
package jetpack.navigation.workaround
// Copied from https://github.com/android/architecture-components-samples/blob/master/NavigationAdvancedSample/app/src/main/java/com/example/android/navigationadvancedsample/NavigationExtensions.kt
// google navigation workaround stuff until multiple back stacks are available
/*
* Copyright 2019, The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.content.Intent
import android.util.SparseArray
import androidx.core.util.forEach
import androidx.core.util.set
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import com.google.android.material.bottomnavigation.BottomNavigationView
/**
* Manages the various graphs needed for a [BottomNavigationView].
*
* This sample is a workaround until the Navigation Component supports multiple back stacks.
*/
fun BottomNavigationView.setupWithNavController(
navGraphIds: List<Int>,
fragmentManager: FragmentManager,
containerId: Int,
intent: Intent
): LiveData<NavController> {
// Map of tags
val graphIdToTagMap = SparseArray<String>()
// Result. Mutable live data with the selected controlled
val selectedNavController = MutableLiveData<NavController>()
var firstFragmentGraphId = 0
// First create a NavHostFragment for each NavGraph ID
navGraphIds.forEachIndexed { index, navGraphId ->
val fragmentTag =
getFragmentTag(
index
)
// Find or create the Navigation host fragment
val navHostFragment =
obtainNavHostFragment(
fragmentManager,
fragmentTag,
navGraphId,
containerId
)
// Obtain its id
val graphId = navHostFragment.navController.graph.id
if (index == 0) {
firstFragmentGraphId = graphId
}
// Save to the map
graphIdToTagMap[graphId] = fragmentTag
// Attach or detach nav host fragment depending on whether it's the selected item.
if (this.selectedItemId == graphId) {
// Update livedata with the selected graph
selectedNavController.value = navHostFragment.navController
attachNavHostFragment(
fragmentManager,
navHostFragment,
index == 0
)
} else {
detachNavHostFragment(
fragmentManager,
navHostFragment
)
}
}
// Now connect selecting an item with swapping Fragments
var selectedItemTag = graphIdToTagMap[this.selectedItemId]
val firstFragmentTag = graphIdToTagMap[firstFragmentGraphId]
var isOnFirstFragment = selectedItemTag == firstFragmentTag
// When a navigation item is selected
setOnNavigationItemSelectedListener { item ->
// Don't do anything if the state is state has already been saved.
if (fragmentManager.isStateSaved) {
false
} else {
val newlySelectedItemTag = graphIdToTagMap[item.itemId]
if (selectedItemTag != newlySelectedItemTag) {
// Pop everything above the first fragment (the "fixed start destination")
fragmentManager.popBackStack(
firstFragmentTag,
FragmentManager.POP_BACK_STACK_INCLUSIVE
)
val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)
as NavHostFragment
// Exclude the first fragment tag because it's always in the back stack.
if (firstFragmentTag != newlySelectedItemTag) {
// Commit a transaction that cleans the back stack and adds the first fragment
// to it, creating the fixed started destination.
fragmentManager.beginTransaction()
.setCustomAnimations(
R.anim.nav_default_enter_anim,
R.anim.nav_default_exit_anim,
R.anim.nav_default_pop_enter_anim,
R.anim.nav_default_pop_exit_anim
)
.attach(selectedFragment)
.setPrimaryNavigationFragment(selectedFragment)
.apply {
// Detach all other Fragments
graphIdToTagMap.forEach { _, fragmentTagIter ->
if (fragmentTagIter != newlySelectedItemTag) {
detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!)
}
}
}
.addToBackStack(firstFragmentTag)
.setReorderingAllowed(true)
.commit()
}
selectedItemTag = newlySelectedItemTag
isOnFirstFragment = selectedItemTag == firstFragmentTag
selectedNavController.value = selectedFragment.navController
true
} else {
false
}
}
}
// Optional: on item reselected, pop back stack to the destination of the graph
setupItemReselected(graphIdToTagMap, fragmentManager)
// Handle deep link
setupDeepLinks(navGraphIds, fragmentManager, containerId, intent)
// Finally, ensure that we update our BottomNavigationView when the back stack changes
fragmentManager.addOnBackStackChangedListener {
if (!isOnFirstFragment && !fragmentManager.isOnBackStack(firstFragmentTag)) {
this.selectedItemId = firstFragmentGraphId
}
// Reset the graph if the currentDestination is not valid (happens when the back
// stack is popped after using the back button).
selectedNavController.value?.let { controller ->
if (controller.currentDestination == null) {
controller.navigate(controller.graph.id)
}
}
}
return selectedNavController
}
private fun BottomNavigationView.setupDeepLinks(
navGraphIds: List<Int>,
fragmentManager: FragmentManager,
containerId: Int,
intent: Intent
) {
navGraphIds.forEachIndexed { index, navGraphId ->
val fragmentTag =
getFragmentTag(
index
)
// Find or create the Navigation host fragment
val navHostFragment =
obtainNavHostFragment(
fragmentManager,
fragmentTag,
navGraphId,
containerId
)
// Handle Intent
if (navHostFragment.navController.handleDeepLink(intent)
&& selectedItemId != navHostFragment.navController.graph.id
) {
this.selectedItemId = navHostFragment.navController.graph.id
}
}
}
private fun BottomNavigationView.setupItemReselected(
graphIdToTagMap: SparseArray<String>,
fragmentManager: FragmentManager
) {
setOnNavigationItemReselectedListener { item ->
val newlySelectedItemTag = graphIdToTagMap[item.itemId]
val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)
as NavHostFragment
val navController = selectedFragment.navController
// Pop the back stack to the start destination of the current navController graph
navController.popBackStack(
navController.graph.startDestination, false
)
}
}
private fun detachNavHostFragment(
fragmentManager: FragmentManager,
navHostFragment: NavHostFragment
) {
fragmentManager.beginTransaction()
.detach(navHostFragment)
.commitNow()
}
private fun attachNavHostFragment(
fragmentManager: FragmentManager,
navHostFragment: NavHostFragment,
isPrimaryNavFragment: Boolean
) {
fragmentManager.beginTransaction()
.attach(navHostFragment)
.apply {
if (isPrimaryNavFragment) {
setPrimaryNavigationFragment(navHostFragment)
}
}
.commitNow()
}
private fun obtainNavHostFragment(
fragmentManager: FragmentManager,
fragmentTag: String,
navGraphId: Int,
containerId: Int
): NavHostFragment {
// If the Nav Host fragment exists, return it
val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?
existingFragment?.let { return it }
// Otherwise, create it and return it.
val navHostFragment = NavHostFragment.create(navGraphId)
fragmentManager.beginTransaction()
.add(containerId, navHostFragment, fragmentTag)
.commitNow()
return navHostFragment
}
private fun FragmentManager.isOnBackStack(backStackName: String): Boolean {
val backStackCount = backStackEntryCount
for (index in 0 until backStackCount) {
if (getBackStackEntryAt(index).name == backStackName) {
return true
}
}
return false
}
private fun getFragmentTag(index: Int) = "bottomNavigation#$index"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment