Sitemap
Vidit Jha

Vidit Jha

Android Developer @Adobe | Kotlin | Java

Implementing Type-Safe Multi-Module Nested Navigation in Bottom Navigation using Jetpack Compose

4 min readOct 2, 2024
Press enter or click to view image in full size

Navigation in Jetpack Compose has been a frustration for many of us developers. Traditionally, we had to define routes and pass arguments as strings, which was highly error-prone and lacked compile-time safety.

Finally, Android has recently introduced its type-safe navigation library, which has made navigation simpler and provides compile-time safety. Now, instead of relying on fragile string-based routes, we can use Serializable objects to pass data between screens. While Android still internally uses strings to match routes, the new approach offers significant relief by reducing errors and making navigation more robust and developer-friendly.

In this blog, we will explore how to implement bottom navigation using type-safe navigation in Jetpack Compose. We’ll be using a multi-module architecture, where each tab in the bottom navigation will have its own module and navigation graph. This approach makes our solution more manageable, scalable, and easier to maintain as the app grows in complexity.

The multi-module architecture we will use includes three primary modules: app, core, and feature modules for each tab. In the example project, we will implement three tabs: Home, Search, and Profile.

So let the coding begins🚀

First add the required dependencies. Don’t forget to implement these dependencies in app level and project level build.gradle.

[versions]
composeNavigation = "2.8.1"
serialization = "1.6.3"
kotlinGradlePlugin = "1.9.0"

[libraries]
# Jetpack Compose Navigation
androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation" }

# Kotlin Serialization
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinGradlePlugin" }

Now, in each feature module, we will add the Navigation Graph, define the Navigation Objects, and implement the starting Composable screen. This ensures that each tab — whether it’s Home, Search, or Profile — manages its own navigation independently.

Get Vidit Jha’s stories in your inbox

Join Medium for free to get updates from this writer.

Each NavGraph is an independent navigation graph where we can add destinations and routes. For each screen, we define objects, and if we need to pass data through arguments, we define a corresponding data class. These objects or data classes can be used instead of strings, providing type safety. The composable screens themselves are simple layouts with just the screen name at center.

fun NavGraphBuilder.homeNavGraph(navController: NavController) {
navigation<HomeGraph>(startDestination = HomeScreen) {
composable<HomeScreen> { HomeScreen() }
}
}
@Serializable
object HomeGraph

@Serializable
object HomeScreen

@Composable
fun HomeScreen() {
Scaffold(
topBar = {},
content = { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Home Screen",
fontSize = 40.sp
)
}
}
)
}

We will repeat this setup for all of our tabs, ensuring each feature module contains its Navigation Graph, Navigation Objects, and a starting Composable screen.

Once each feature module is set up, we move on to the App Module, where we create a sealed class that holds all the details for our bottom navigation tabs. In the route property, instead of using string-based routes like we did earlier, we now leverage Serializable objects that are defined in each feature module.

@Serializable
sealed class BottomScreens<T>(
val name: String, // the name of the tab
val selectedIcon: Int, // filled icon when selected
val unselectedIcon: Int, // unfilled icon when not selected
val route: T //graphs defined in feature module for each tab

) {

@Serializable
data object Home : BottomScreens<HomeGraph>(
name = "Home",
unselectedIcon = R.drawable.home_unfilled,
selectedIcon = R.drawable.home_filled,
route = HomeGraph
)

@Serializable
data object Search : BottomScreens<SearchGraph>(
name = "Search",
unselectedIcon = R.drawable.search_unfilled,
selectedIcon = R.drawable.search_filled,
route = SearchGraph
)

@Serializable
data object Profile : BottomScreens<ProfileGraph>(
name = "Profile",
unselectedIcon = R.drawable.profile_unfilled,
selectedIcon = R.drawable.profile_filled,
route = ProfileGraph
)

}

We are now creating our BottomNavigation composable using Material 3's bottom navigation items. We'll determine the current destination and, on tab selection, check if any navigation graph hierarchy matches before navigating to the corresponding nav graph.

We’ll use popUpTo to remove all destinations from the back stack. The saveState and restoreState options ensure that when switching between tabs, their state is preserved. The launchSingleTop option prevents adding the destination to the back stack again if it's already at the top.

@Composable
fun AppBottomNavigation(navController: NavController) {
val bottomScreens = remember {
listOf(
BottomScreens.Home,
BottomScreens.Search,
BottomScreens.Profile,
)
}

NavigationBar {
val currentDestination = navController.currentBackStackEntryAsState().value?.destination

bottomScreens.forEach { screen ->
val isSelected =
currentDestination?.hierarchy?.any { it.hasRoute(screen.route::class) } == true
NavigationBarItem(
icon = {
if (isSelected) Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = screen.selectedIcon),
contentDescription = screen.name
)
else Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = screen.unselectedIcon),
contentDescription = screen.name
)
},
label = { Text(screen.name) },
selected = isSelected,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}

Now, we’ll create a central navigation component, which will serve as our main nav graph containing all the individual navigation graphs used in the bottom navigation. We’ll integrate this into the composable screen where we want to add the bottom navigation, along with a composable bottom sheet we created.

@Composable
fun CentralNavigation(navController: NavHostController) {
NavHost(navController = navController, startDestination = BottomScreens.Home.route){
homeNavGraph(navController)
searchNavGraph(navController)
profileNavGraph(navController)
}

}
@Composable
fun MainScreen() {
Surface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
CentralNavigation(navController = navController)
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding(),
contentAlignment = Alignment.BottomCenter
) {
AppBottomNavigation(navController = navController)
}
}
}

And that’s it — your Bottom Navigation is ready! You can also check out the source code on my GitHub repository. If you have any questions, feel free to leave a comment below or connect with me on LinkedIn or Twitter.

--

--

Vidit Jha
Vidit Jha

Written by Vidit Jha

Android Developer @Adobe | Kotlin | Java