Open menu with table of contents Android Navigation
Logo of Stuttgart Media University for light theme Logo of Stuttgart Media University for dark theme
Android Development

Android Navigation

Stuttgart Media University

1 Agenda

  • Why navigation?
  • Navigation Compose: NavController, NavHost, routes
  • The MovieTracker navigation graph
  • Navigating: navigate, popBackStack, popUpTo, launchSingleTop
  • Passing arguments
  • State across destinations
  • Scaffold, TopAppBar and NavigationBar
  • Legacy: Fragments
  • Assignment 3

2 Why Navigation?

  • Real apps have several screens: a home screen, a search screen, a detail screen, settings, ...
  • The user moves between them and expects
    • the system back gesture/button to return to the previous screen
    • the previous screen to look exactly as it was left (scroll position, entered text)
    • deep links (a notification opens the detail screen directly)
  • Lecture 06: one Activity 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 duplicated
  • Compose apps use a single Activity and switch the composable that fills the screen. The Navigation component manages the back stack of screens for you: Navigation Compose

3 Setup

The 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)
}

4 Core Concepts

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
  • The NavController is created once at the top of the UI tree and passed to the NavHost
  • Each composable(route) { } block inside the NavHost is one destination

5 Routes as Constants

Routes 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"
}
  • The startDestination of the NavHost is Routes.HOME
  • Use the constants both when declaring the destination (composable(Routes.SEARCH)) and when navigating (navController.navigate(Routes.SEARCH))
  • Outlook: type-safe routes with @Serializable classes replace the strings (later slide)

6 The MovieTracker App

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

7 The Navigation Graph

@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()
                }
            )
        }
    }
}

8 Screens Do Not Know the NavController

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)
    }
}
  • A screen receives its data and callbacks as parameters, never the NavController
  • Only the NavHost decides what a callback does (navigate, popBackStack, change state)
  • Benefits: the screen can be previewed (@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 stack

10 The Back Stack

What 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
  • The 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 removed
  • NavHost handles the system back button automatically, you never override onBackPressed()

11 Passing Arguments

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() })
    }
}
  • Optional arguments use query syntax: "search?query={query}" with defaultValue/nullable = true

12 Outlook: Type-Safe Routes

Since 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)
    }
}
  • Arguments are typed and checked by the compiler, no string templates and no navArgument
  • Requires the org.jetbrains.kotlin.plugin.serialization Gradle plugin and kotlinx-serialization-json
  • Not used in the course assignments (the starters use the Routes object), but recommended for your project

13 State Across Destinations

  • Each destination is a separate composition. When you navigate away, the composable leaves the composition and all its remember state is gone (SearchScreen: the results list)
  • State that several screens need must live above the 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 survives
  • But it does not survive the recreation of the Activity (rotation) or a process death
  • Outlook lecture 11: savedMovies moves into a ViewModel (survives rotation) backed by a Room database (survives app restarts). The screens keep the same parameters

14 Scaffold with TopAppBar

A 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 navigation

Top-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.

16 Nested Graphs and Beyond

  • Large apps group destinations into nested graphs (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)
  • Deep links: composable(route, deepLinks = listOf(navDeepLink { uriPattern = "https://..." })) plus an intent filter in the manifest (lecture 06) open a destination from outside the app
  • Animations: enterTransition/exitTransition parameters of composable and NavHost
  • Navigation 3 (androidx.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 same

17 Legacy: Fragments (View system)

  • Before Compose, one Activity 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)
  • The Activity layout contains a 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)
}
  • The Navigation component also exists for Fragments (NavHostFragment + XML navigation graph)
  • Deprecated along the way: the <fragment> XML tag (use FragmentContainerView, lint FragmentTagUsage) and Fragment.onActivityCreated() (use onViewCreated())
  • Compose does not need Fragments: a composable is already a reusable UI part with its own state and lifecycle, and NavHost swaps composables instead of fragments. You will still meet Fragments in existing code bases and in some libraries

18 Assignment 3

Assignment 3: Introduction to Navigation

What you need from this lecture for it:

  • rememberNavController() + NavHost with the two routes Routes.HOME and Routes.SEARCH
  • navController.navigate(Routes.SEARCH) opens the search screen, navController.popBackStack() returns to the home screen after a result was clicked
  • The hoisted savedMovies = remember { mutableStateListOf<Movie>() } in MovieTrackerNavHost: add a movie from the search screen callback, remove it from the home screen callback
  • Screens get data and lambdas as parameters (HomeScreen(savedMovies, onSearchClick, onMovieClick)), they never see the NavController

19 Summary

  • Compose apps have a single Activity; the Navigation component switches between screen composables and manages the back stack including the system back button
  • rememberNavController() creates the NavController, NavHost declares the graph with one composable(route) { } per destination; routes are constants in a Routes object
  • navigate(route) pushes, popBackStack()/navigateUp() pop, popUpTo { inclusive } and launchSingleTop shape the stack
  • Arguments travel in the route ("detail/{imdbId}", navArgument), type-safe @Serializable routes are the modern alternative
  • Screens receive data and callbacks as parameters, never the NavController; shared state is hoisted above the destinations (and moves into a ViewModel in lecture 11)
  • Scaffold with TopAppBar/NavigationBar react to currentBackStackEntryAsState()
  • Fragments are the legacy way to split an Activity into parts

20 Recap Questions

  • Why do Compose apps use one Activity and a NavHost instead of one Activity per screen?
  • What is the difference between NavController and NavHost?
  • Why are the routes defined as constants in a Routes object?
  • What does popBackStack() do, and what happens when the user presses the system back button?
  • How do you navigate from a login screen to the home screen so that back does not return to login?
  • How do you pass the id of a movie to a detail destination, and how does the destination read it?
  • Why should a screen composable not receive the NavController as parameter?
  • Where does savedMovies live in assignment 3 and why? What does it not survive?
  • How does a TopAppBar know whether to show a back arrow?
  • What were Fragments used for, and why does Compose not need them?

Questions?