Skip to main content
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 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.
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. 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

1

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
}
2

Register composable

Add the screen to the NavHost in AppNavigation:
composable<AppScreens.Profile> { navBackStackEntry ->
    currentScreen = navBackStackEntry.toRoute<AppScreens.Profile>()
    ProfileScreen()
}
3

Navigate from screens

Use the NavController to navigate with type-safe arguments:
navController.navigate(AppScreens.Profile(userId = "123"))
4

Extract arguments

Access navigation arguments using toRoute() in the destination screen:
val args = navBackStackEntry.toRoute<AppScreens.Profile>()
val userId = args.userId
Push a new destination onto the back stack:
navController.navigate(AppScreens.ArticleDetail(id = articleId))
Clear previous screens when navigating to a new flow:
navController.navigate(
    route = AppScreens.Home,
    builder = {
        popUpTo(navBackStackEntry.destination.id) {
            inclusive = true
        }
    },
)
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
    }
}
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:
@Serializable
data object Home : AppScreens {
    override val hasTopBar: Boolean = true
    override val hasBottomBar: Boolean = true
}
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.

Build docs developers (and LLMs) love