NavController, NavHost, routesnavigate, popBackStack, popUpTo, launchSingleTopScaffold, TopAppBar and NavigationBarActivity per screen, switched with Intents. Works, but every screen is an isolated
island: state has to be serialised into extras, the theme and Scaffold are duplicatedThe Navigation component is a Jetpack library (already part of the assignment 3 starter):
gradle/libs.versions.toml:
[versions]
navigationCompose = "2.9.8"
[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
Module app/build.gradle.kts:
dependencies {
implementation(libs.androidx.navigation.compose)
}
| Concept | What it is |
|---|---|
| Destination | One screen, a composable that fills the content area |
| Route | A String that identifies a destination, e.g. "home" or "detail/{imdbId}" |
| Navigation graph | All destinations and their routes, declared inside NavHost |
NavController |
Keeps the back stack and performs navigate()/popBackStack(); created with rememberNavController() |
NavHost |
The composable that shows the current destination of the graph |
| Back stack | The stack of visited destinations; system back pops the top entry |
NavController is created once at the top of the UI tree and passed to the NavHostcomposable(route) { } block inside the NavHost is one destinationRoutes are strings. Define them once, a typo in navigate("serach") would otherwise crash at
runtime with an IllegalArgumentException (MainActivity.kt of assignment 3):
/** Routes of the navigation graph. */
object Routes {
const val HOME = "home"
const val SEARCH = "search"
}
startDestination of the NavHost is Routes.HOMEcomposable(Routes.SEARCH)) and when
navigating (navController.navigate(Routes.SEARCH))@Serializable classes replace the strings (later slide)MainActivity.kt of assignment 3: the Activity only sets the content, the app composable creates the
NavController:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MovieTrackerTheme {
MovieTrackerApp()
}
}
}
}
@Composable
fun MovieTrackerApp() {
// The NavController holds the back stack and is used to navigate between screens
val navController: NavHostController = rememberNavController()
Scaffold { innerPadding ->
MovieTrackerNavHost(
navController = navController,
modifier = Modifier.padding(innerPadding)
)
}
}
@Composable
fun MovieTrackerNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
// The saved movies live here, above both screens, so they survive navigating back and forth
// (state hoisting). In assignment 4 this list moves into a database.
val savedMovies = remember { mutableStateListOf<Movie>() }
// The NavHost defines the navigation graph: one composable per route
NavHost(navController = navController, startDestination = Routes.HOME, modifier = modifier) {
composable(Routes.HOME) {
HomeScreen(
savedMovies = savedMovies,
onSearchClick = { navController.navigate(Routes.SEARCH) },
onMovieClick = { movie -> savedMovies.remove(movie) }
)
}
composable(Routes.SEARCH) {
SearchScreen(
onMovieClick = { movie ->
savedMovies.add(movie)
navController.popBackStack()
}
)
}
}
}
HomeScreen.kt of assignment 3:
@Composable
fun HomeScreen(
savedMovies: List<Movie>,
onSearchClick: () -> Unit,
onMovieClick: (Movie) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Button(onClick = onSearchClick, modifier = Modifier.testTag("openSearchButton")) {
Text(text = stringResource(R.string.open_search_button))
}
MovieList(movieList = savedMovies, onMovieClick = onMovieClick)
}
}
NavControllerNavHost decides what a callback does (navigate, popBackStack, change state)@Preview) and UI-tested without navigation, and it can be
reused at another place in the graph// Push the search screen on the back stack: [home] -> [home, search]
navController.navigate(Routes.SEARCH)
// Pop the top destination, same as the system back button: [home, search] -> [home]
navController.popBackStack()
// Go back to the parent destination; falls back to finishing the Activity if the stack is empty
navController.navigateUp()
// Navigate and remove everything up to and including "login": [login] -> [home]
navController.navigate(Routes.HOME) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
// Do not push a second copy if "home" is already on top (e.g. double tap on a tab)
navController.navigate(Routes.HOME) { launchSingleTop = true }
popUpTo is how you implement "login -> home, and back must not return to login"inclusive = false (default) keeps the popUpTo destination itself on the stackWhat happens in assignment 3 when a movie is saved:
| Action | Back stack (bottom -> top) | Visible |
|---|---|---|
| App start | home |
HomeScreen |
navigate(Routes.SEARCH) |
home, search |
SearchScreen |
Click result -> popBackStack() |
home |
HomeScreen with the saved movie |
System back on home |
empty | Activity finishes |
NavHost saves the state of a popped-off destination (rememberSaveable) when it stays on the
stack and restores it when you return, e.g. the text in the search field survives rotation but is
gone after popBackStack() because the destination was removedNavHost handles the system back button automatically, you never override onBackPressed()Routes can contain placeholders. Pass ids, not objects; the screen loads the object from the data layer (lecture 11):
object Routes {
const val HOME = "home"
const val DETAIL = "detail/{imdbId}"
fun detail(imdbId: String) = "detail/$imdbId"
}
NavHost(navController = navController, startDestination = Routes.HOME) {
composable(Routes.HOME) {
HomeScreen(onMovieClick = { movie -> navController.navigate(Routes.detail(movie.imdbId)) })
}
composable(
route = Routes.DETAIL,
arguments = listOf(navArgument("imdbId") { type = NavType.StringType })
) { backStackEntry ->
val imdbId = requireNotNull(backStackEntry.arguments?.getString("imdbId"))
DetailScreen(imdbId = imdbId, onBack = { navController.navigateUp() })
}
}
"search?query={query}" with defaultValue/nullable = trueSince Navigation 2.8 routes can be Kotlin objects and data classes instead of strings (type safety):
@Serializable object Home
@Serializable data class Detail(val imdbId: String)
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(onMovieClick = { movie -> navController.navigate(Detail(movie.imdbId)) })
}
composable<Detail> { backStackEntry ->
val detail: Detail = backStackEntry.toRoute()
DetailScreen(imdbId = detail.imdbId)
}
}
navArgumentorg.jetbrains.kotlin.plugin.serialization Gradle plugin and kotlinx-serialization-jsonRoutes object), but recommended for
your projectremember state is gone (SearchScreen: the results list)NavHost destinations, this is state
hoisting (lecture 05) applied to navigation:@Composable
fun MovieTrackerNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
val savedMovies = remember { mutableStateListOf<Movie>() } // survives navigating
NavHost(...) {
composable(Routes.HOME) { HomeScreen(savedMovies = savedMovies, ...) }
composable(Routes.SEARCH) { SearchScreen(onMovieClick = { savedMovies.add(it); ... }) }
}
}
MovieTrackerNavHost stays in the composition the whole time, so its remember survivessavedMovies moves into a ViewModel (survives rotation) backed by a Room
database (survives app restarts). The screens keep the same parametersA TopAppBar with a back arrow for destinations that are not the start destination:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieTrackerApp() {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val canNavigateBack = navController.previousBackStackEntry != null
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = backStackEntry?.destination?.route ?: "MovieTracker") },
navigationIcon = {
if (canNavigateBack) {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
}
)
}
) { innerPadding ->
MovieTrackerNavHost(navController = navController, modifier = Modifier.padding(innerPadding))
}
}
currentBackStackEntryAsState() turns the current destination into Compose state, so the app bar
recomposes on every navigationTop-level destinations as tabs; the selected tab follows the back stack:
@Composable
fun MovieTrackerBottomBar(navController: NavHostController) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
NavigationBar {
listOf(Routes.HOME to Icons.Filled.Home, Routes.SEARCH to Icons.Filled.Search).forEach { (route, icon) ->
NavigationBarItem(
selected = currentRoute == route,
onClick = {
navController.navigate(route) {
// Pop to the start destination so tabs do not pile up on the back stack
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true // no duplicate when re-selecting the tab
restoreState = true // restore the tab's scroll position etc.
}
},
icon = { Icon(icon, contentDescription = route) },
label = { Text(text = route) }
)
}
}
}
Pass it as bottomBar = { MovieTrackerBottomBar(navController) } to the Scaffold.
navigation(startDestination, route) { ... }
inside NavHost), e.g. an auth graph with login and registration. Navigating to the graph's route
shows its start destination, popUpTo(graph) clears the whole flow
(nested graphs)composable(route, deepLinks = listOf(navDeepLink { uriPattern = "https://..." }))
plus an intent filter in the manifest (lecture 06) open a destination from outside the appenterTransition/exitTransition parameters of composable and NavHostandroidx.navigation3, stable since 1.0) is a newer, Compose-first library where
the back stack is a plain list that you own in state. The course stays with Navigation Compose, the
concepts (destinations, back stack, hoisted state) are the sameActivity hosted several Fragments: reusable UI parts with an own XML layout
and an own lifecycle, tightly coupled to the Activity lifecycle (onAttach, onCreateView,
onViewCreated, onDestroyView, onDetach)FragmentContainerView; fragments are swapped with a
FragmentManager transaction, which can be put on a back stack:supportFragmentManager.commit {
setReorderingAllowed(true)
replace<DetailFragment>(R.id.fragment_container_view)
addToBackStack(null)
}
NavHostFragment + XML navigation graph)<fragment> XML tag (use FragmentContainerView, lint
FragmentTagUsage) and Fragment.onActivityCreated() (use onViewCreated())NavHost swaps composables instead of fragments. You will still meet Fragments in
existing code bases and in some librariesAssignment 3: Introduction to Navigation
What you need from this lecture for it:
rememberNavController() + NavHost with the two routes Routes.HOME and Routes.SEARCHnavController.navigate(Routes.SEARCH) opens the search screen,
navController.popBackStack() returns to the home screen after a result was clickedsavedMovies = remember { mutableStateListOf<Movie>() } in MovieTrackerNavHost:
add a movie from the search screen callback, remove it from the home screen callbackHomeScreen(savedMovies, onSearchClick, onMovieClick)),
they never see the NavControllerActivity; the Navigation component switches between screen composables
and manages the back stack including the system back buttonrememberNavController() creates the NavController, NavHost declares the graph with one
composable(route) { } per destination; routes are constants in a Routes objectnavigate(route) pushes, popBackStack()/navigateUp() pop, popUpTo { inclusive } and
launchSingleTop shape the stack"detail/{imdbId}", navArgument), type-safe @Serializable
routes are the modern alternativeNavController; shared state is hoisted
above the destinations (and moves into a ViewModel in lecture 11)Scaffold with TopAppBar/NavigationBar react to currentBackStackEntryAsState()Activity and a NavHost instead of one Activity per screen?NavController and NavHost?Routes object?popBackStack() do, and what happens when the user presses the system back button?NavController as parameter?savedMovies live in assignment 3 and why? What does it not survive?TopAppBar know whether to show a back arrow?