The navigation system uses Jetpack Compose Navigation with type-safe routing powered by Kotlin serialization. It manages app-wide navigation state, screen transitions, and UI component visibility.
Navigation architecture
Navigation is defined through a sealed interface that represents all app screens with type safety:
navigation/src/main/java/es/mobiledev/navigation/AppScreens.kt
@Serializable
sealed interface AppScreens {
val module: NavigationModule
val hasTopBar: Boolean
val hasBottomBar: Boolean
@Serializable
data object Launcher : AppScreens {
override val module: NavigationModule = NavigationModule.LAUNCHER
override val hasTopBar: Boolean = false
override val hasBottomBar: Boolean = false
}
@Serializable
data object Home : AppScreens {
override val module: NavigationModule = NavigationModule.HOME
override val hasTopBar: Boolean = true
override val hasBottomBar: Boolean = true
}
@Serializable
data class ArticleDetail ( val id: Long ) : AppScreens {
override val module: NavigationModule = NavigationModule.ARTICLE_DETAIL
override val hasTopBar: Boolean = true
override val hasBottomBar: Boolean = true
}
}
The @Serializable annotation enables type-safe navigation arguments without manual string-based route definitions.
Key features
Type-safe routes Compile-time validation of navigation arguments prevents runtime errors.
UI configuration Each screen declares whether it shows top bar and bottom bar.
Module grouping Screens are organized into modules for better navigation state management.
Parameter passing Pass complex data types safely between screens.
Navigation modules
Modules group related screens and define bottom navigation tabs:
navigation/src/main/java/es/mobiledev/navigation/NavigationModule.kt
enum class NavigationModule (
val hasOwnTab: Boolean = false
) {
LAUNCHER,
HOME (hasOwnTab = true ),
TEST (hasOwnTab = true ),
ARTICLE_DETAIL
}
Modules with hasOwnTab = true appear in the bottom navigation bar.
Navigation graph
The AppNavigation composable defines the navigation graph and manages UI components:
app/src/main/java/es/mobiledev/cpt/AppNavigation.kt
@Composable
fun AppNavigation (navController: NavHostController = rememberNavController ()) {
var currentScreen: AppScreens by remember { mutableStateOf (AppScreens.Launcher) }
val currentSelectedModule by currentScreen. getCurrentSelectedModule ()
. collectAsStateWithLifecycle ()
val showTopAppBar by remember { derivedStateOf { currentScreen.hasTopBar } }
val showBottomBar by remember { derivedStateOf { currentScreen.hasBottomBar } }
ScreenWrapper (
topBar = { CptTopBar () },
bottomBar = {
CptNavigationBar (
selectedModule = currentSelectedModule,
modifier = Modifier,
onClickModule = { screen ->
navController. navigate (screen) {
popUpTo (screen) {
inclusive = true
}
}
},
)
},
showTopAppBar = showTopAppBar,
showBottomBar = showBottomBar,
) { paddingValues ->
NavHost (
navController = navController,
startDestination = AppScreens.Launcher,
modifier = Modifier
. consumeWindowInsets (paddingValues)
. padding (paddingValues)
) {
composable < AppScreens . Launcher > { navBackStackEntry ->
currentScreen = navBackStackEntry. toRoute < AppScreens . Launcher >()
LauncherScreen (
onLauncherFinished = {
navController. navigate (
route = AppScreens.Home,
builder = {
popUpTo (navBackStackEntry.destination.id) {
inclusive = true
}
},
)
},
)
}
composable < AppScreens . Home > { navBackStackEntry ->
currentScreen = navBackStackEntry. toRoute < AppScreens . Home >()
HomeScreen (
navigateToArticleDetail = { id ->
navController. navigate (AppScreens. ArticleDetail (id))
},
)
}
composable < AppScreens . ArticleDetail > { navBackStackEntry ->
currentScreen = navBackStackEntry. toRoute < AppScreens . ArticleDetail >()
ArticleDetailScreen ()
}
}
}
}
Implementing navigation
Define screen routes
Add new screen definitions to the AppScreens sealed interface: @Serializable
data class Profile ( val userId: String ) : AppScreens {
override val module: NavigationModule = NavigationModule.PROFILE
override val hasTopBar: Boolean = true
override val hasBottomBar: Boolean = false
}
Register composable
Add the screen to the NavHost in AppNavigation: composable < AppScreens . Profile > { navBackStackEntry ->
currentScreen = navBackStackEntry. toRoute < AppScreens . Profile >()
ProfileScreen ()
}
Navigate from screens
Use the NavController to navigate with type-safe arguments: navController. navigate (AppScreens. Profile (userId = "123" ))
Extract arguments
Access navigation arguments using toRoute() in the destination screen: val args = navBackStackEntry. toRoute < AppScreens . Profile >()
val userId = args.userId
Navigation patterns
Navigate forward
Push a new destination onto the back stack:
navController. navigate (AppScreens. ArticleDetail (id = articleId))
Navigate with back stack clearing
Clear previous screens when navigating to a new flow:
navController. navigate (
route = AppScreens.Home,
builder = {
popUpTo (navBackStackEntry.destination.id) {
inclusive = true
}
},
)
When to clear the back stack
Use this pattern when transitioning between major app sections, like moving from a launcher screen to the main home screen. This prevents users from navigating back to screens that are no longer relevant.
Single top navigation
Reuse existing screen instances for bottom navigation tabs:
navController. navigate (screen) {
popUpTo (screen) {
inclusive = true
}
}
Navigate back
Return to the previous screen:
navController. popBackStack ()
Managing UI components
The navigation system controls the visibility of top and bottom bars based on the current screen:
Show both bars
Hide both bars
Top bar only
@Serializable
data object Home : AppScreens {
override val hasTopBar: Boolean = true
override val hasBottomBar: Boolean = true
}
@Serializable
data object Launcher : AppScreens {
override val hasTopBar: Boolean = false
override val hasBottomBar: Boolean = false
}
@Serializable
data class Settings : AppScreens {
override val hasTopBar: Boolean = true
override val hasBottomBar: Boolean = false
}
The ScreenWrapper component automatically shows/hides the top and bottom bars based on derived state from the current screen.
Passing complex arguments
Navigation supports complex data types through serialization:
@Serializable
data class SearchResults (
val query: String ,
val filters: List < String >,
val sortBy: String
) : AppScreens {
override val module: NavigationModule = NavigationModule.SEARCH
override val hasTopBar: Boolean = true
override val hasBottomBar: Boolean = true
}
// Navigate with multiple parameters
navController. navigate (
AppScreens. SearchResults (
query = "Android" ,
filters = listOf ( "tutorial" , "news" ),
sortBy = "recent"
)
)
Keep navigation arguments simple and serializable. For large objects or sensitive data, pass only identifiers and fetch the full data in the destination screen.
Testing navigation
Test navigation flows by creating a test NavController:
@Test
fun testNavigationToArticleDetail () {
val navController = TestNavController (ApplicationProvider. getApplicationContext ())
navController. setGraph (R.navigation.app_navigation)
composeTestRule. setContent {
AppNavigation (navController = navController)
}
// Trigger navigation
composeTestRule. onNodeWithText ( "Article Title" ). performClick ()
// Verify destination
assertEquals (
AppScreens.ArticleDetail:: class .qualifiedName,
navController.currentBackStackEntry?.destination?.route
)
}
Best practices
Type-safe routes Always use the sealed interface instead of string-based routes.
Minimal arguments Pass only essential data like IDs, not entire objects.
Clear back stacks Remove unnecessary screens from the back stack to prevent memory leaks.
Consistent patterns Use the same navigation patterns across similar flows.