Pocket Studio Academy
HomePart 44.4

Navigation between screens

Full course13 min read·4 questions

Add a second screen, give it an address, carry a value to it, and make the phone's Back gesture do the obvious thing — without writing a single line of Back handling yourself.

The version that almost works

Pocket Notes needs two screens: a list, and an editor. The obvious first attempt is a Boolean.

the tempting wrong answerkotlin
1var editing by remember { mutableStateOf(false) }
2
3if (editing) EditorScreen()
4else ListScreen(onOpen = { editing = true })

Run it and it looks perfect. Tap a note, the editor appears. Then a user presses the phone's Back gesture, expecting to go back to the list, and the app closes.

Android has no idea you changed screens. As far as it is concerned there is one showing one thing, and Back means "leave".

You could handle Back yourself. Then you need to handle it for the third screen, and the fourth, and remember which one came from where, and cope with the phone being rotated in the middle. That bookkeeping has a name — the — and there is a library that does it properly.

Think of it like this

Think about a stack of paper trays on a desk.

Opening a screen is putting a new sheet on top of the pile. You see the top sheet; everything else is still there, in order, underneath.

Pressing Back lifts the top sheet off and bins it. The sheet below is exactly as you left it — same notes in the margin, same coffee ring.

And when the pile is empty and you lift one more time, you have left the desk. That is why Back on the very first screen closes the app: there was nothing underneath.

Navigation is the library that manages the pile so you never have to.

Three pieces

Add one dependency — Pocket Notes and Focus Flow both have it already:

app/build.gradle.ktskts
implementation(libs.androidx.navigation.compose)

Then there are exactly three things to learn.

A is a piece of text that names a screen. "list". "settings". That is all a route is.

A is the composable that holds every screen and shows whichever one the current route names.

A is the object you ask to move. It keeps the back stack.

The smallest real example

PocketNotesApp.ktkotlin
1@Composable
2fun PocketNotesApp() {
3  val nav = rememberNavController()
4
5  NavHost(
6    navController = nav,
7    startDestination = "list"
8  ) {
9    composable("list") {
10      NoteListScreen(
11        onOpenNote = { nav.navigate("editor/$it") }
12      )
13    }
14    composable("editor/{noteId}") {
15      NoteEditorScreen(
16        onDone = { nav.popBackStack() }
17      )
18    }
19  }
20}

rememberNavController() survives recomposition and rotation, so the pile of screens is not lost when the phone turns.

navigate("editor/3") pushes a sheet on. popBackStack() lifts one off. The system Back gesture already calls popBackStack() for you — you write nothing.

9:41▲ ▮
Pocket Notes
Search notes
Shopping
Oats, tea, the good bread
2 minutes ago
Ideas for the timer app
Ring dial, stats, a gentle chime
Yesterday
Untitled
Last week
+

Tapping a card calls navigate("editor/3"). Back pops it off and returns here, scrolled exactly where you left it.

Carrying a value to the next screen

The editor needs to know which note. That value rides inside the route.

{noteId} in the route string is a slot. When you navigate to "editor/3", Navigation pulls 3 out of the address and hands it over.

Declare its type so a route expecting a number can never receive a word:

MainActivity.ktkotlin
1composable(
2  route = "editor/{noteId}",
3  arguments = listOf(
4    navArgument("noteId") {
5      type = NavType.LongType
6    }
7  )
8) { entry ->
9  val id = entry.arguments
10    ?.getLong("noteId") ?: 0L
11  NoteEditorScreen(
12    vm = vm,
13    noteId = id,
14    onDone = { nav.popBackStack() }
15  )
16}

That is the genuine code from Pocket Notes.

Careful

Pass ids, not objects. A route is text that Android may write down and restore later, so only small, simple values fit: numbers, short strings, booleans.

Sending a whole Note through a route means serialising it, and it means the editor is showing a stale copy from the moment it opens. Send the id; let the screen fetch the current version.

Tip

Pocket Notes uses "editor/0" for "a note that does not exist yet". id = 0 already means "new" everywhere else in the app — Room's autoGenerate treats zero as "please assign one" — so no separate "new" route is needed.

One idea, spelled the same way in three places. That is worth more than it sounds.

Sharing state between screens

Look at where the ViewModel is created in Pocket Notes:

MainActivity.ktkotlin
1@Composable
2fun PocketNotesApp() {
3  val vm: NotesViewModel = viewModel()
4  val nav = rememberNavController()
5
6  NavHost(navController = nav, ...) { ... }
7}

vm is created outside the NavHost, so both screens receive the same object — one ViewModel, one database connection, one source of truth. Save a note in the editor and the list behind it is already correct before you have finished the Back gesture, because Room pushed a new list to the they share.

Call viewModel() inside a composable { } block instead and you get a ViewModel scoped to that entry in the back stack — a fresh one per screen, cleared when the screen is popped. Focus Flow does exactly that, because its three tabs have nothing to share.

Both are correct. Choose by asking whether the screens need the same facts.

Tabs are different

Focus Flow has a bottom bar: Timer, Stats, Settings. If tapping a tab simply called navigate(), every tap would push another sheet on the pile, and Back would walk you through your entire tab history like a browser.

FocusFlowApp.ktkotlin
1private fun openTab(
2  nav: NavHostController,
3  dest: Destination,
4) {
5  nav.navigate(dest.route) {
6    popUpTo(nav.graph.startDestinationId) {
7      saveState = true
8    }
9    launchSingleTop = true
10    restoreState = true
11  }
12}

Highlighting the current tab

The bar has to know which tab you are on, so it watches the back stack:

FocusFlowApp.ktkotlin
1val entry by nav.currentBackStackEntryFlow
2  .collectAsState(initial = null)
3val route = entry?.destination?.route
4  ?: Destination.TIMER.route

A of "what is on top right now", collected into state. Everything you learned in Lesson 4.3 applies here unchanged — the navigation state is just more state.

Try it in Pocket Studio

You will add a third screen to Pocket Notes and reach it from the list.

  1. Open Pocket Studio, tap Projects, then Pocket Notes.
  2. Tap Editor and open MainActivity.kt.
  3. Inside the NavHost { } block, below the existing composable("editor/...") entry, add a new one: composable("about") { Text("Pocket Notes 1.0") }
  4. Open ui/NoteListScreen.kt. In the LargeTopAppBar, add an actions = { } parameter containing TextButton(onClick = onAbout) { Text("About") }.
  5. Add onAbout: () -> Unit as a parameter of NoteListScreen.
  6. Back in MainActivity.kt, pass onAbout = { nav.navigate("about") }.
  7. Tap Run. Tap About, then use the phone's Back gesture. You return to the list, scrolled exactly where you were — and you wrote no Back code at all.
  8. Press Back once more. The pile is now empty, so the app closes. That is correct behaviour.
Error Doctor5 common errors
java.lang.IllegalArgumentException: Navigation destination that matches request NavDeepLinkRequest{ uri=android-app://androidx.navigation/editor/3 } cannot be found in the navigation graph
MeansYou navigated to a route that no composable { } declares. Nearly always a spelling mismatch between the route you built and the route you registered.
FixCompare the two strings character by character. "editor/{noteId}" matches "editor/3"; "editor{noteId}" and "Editor/{noteId}" match nothing. Declaring route strings as constants stops this happening twice.
java.lang.IllegalArgumentException: Wrong argument type for 'noteId' in argument bundle. long expected.
MeansThe route declares NavType.LongType but something read it as a different type, or built the route with a value that is not a whole number.
FixRead it with getLong("noteId"), not getString. And check the value you interpolate into the route is a Long"editor/$note" puts the whole object in the address by accident.
java.lang.IllegalStateException: You must call setGraph() before calling getGraph()
MeansSomething asked the NavController about its screens before the NavHost had registered them — typically a navigate() fired from a composable that runs before the host is composed.
FixOnly navigate from an event: a click, or inside a LaunchedEffect. Never call navigate() directly in the body of a composable, because that body runs during drawing.
e: Unresolved reference: composable
MeansThe wrong import. There are several things called composable, and the one that builds a route lives in the navigation package.
FixImport androidx.navigation.compose.composable and androidx.navigation.compose.NavHost. If neither is offered, the navigation dependency is missing — add it and tap Sync.
Back exits the app instead of returning to the previous screen
MeansA popUpTo with inclusive = true removed the screen you wanted to go back to, or you used navigate where you meant popBackStack.
FixTo go back, call popBackStack(). Only use popUpTo when you genuinely want to erase history — after a login screen, for example, which nobody should be able to return to.
Recap
  • Handling Back with a Boolean breaks immediately. The is real bookkeeping, and the Navigation library owns it.
  • Three pieces: a is text, a shows the matching screen, a moves between them.
  • navigate("editor/3") pushes; popBackStack() pops; the system Back gesture pops for free.
  • Declare a with navArgument, and pass ids, not objects.
  • Create the ViewModel outside the NavHost to share it; inside a composable block to scope it to one screen.
  • Tabs need popUpTo, saveState, launchSingleTop and restoreState — all four, or the bottom bar feels wrong.
  • Next: the notes themselves. gives you a real database, so what the user writes is still there tomorrow.