In lesson 26 you built a four-screen flow with NavHost and navigate. Real apps have far more: a bottom bar whose tabs each remember their own history, screens that take arguments, deep links from a notification or a web page, login flows that must not be reachable by pressing Back, and graphs split across modules. This lesson teaches the navigation features that separate a demo from a shippable app.
It covers Navigation best practices and the official Navigation in Jetpack Compose codelab from Pathway 3 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-3), and the codelab at https://developer.android.com/codelabs/jetpack-compose-navigation. We use our own example app, Techbase Library.
By the end you will be able to:
- Define type-safe routes with arguments, defaults and optional values, and read them with
toRoute(). - Build a bottom navigation bar that preserves each tab's back stack and state.
- Create nested navigation graphs (for example an auth flow).
- Handle deep links: declare them in code and the manifest, and test them with
adb. - Control the back stack:
popUpTo,launchSingleTop,restoreState,inclusive. - Share a ViewModel across screens of a graph.
- Structure navigation for multi-module apps.
- Test navigation, and know where Navigation 3 fits.
Big idea: navigation state is app state. Treat the back stack as data you manage deliberately, not as a side effect of button clicks.
- Navigation Compose basics:
NavHost,NavController,navigate,popBackStack(lesson 26). @Serializableand the Kotlin serialization plugin (lesson 26).- ViewModel and state hoisting (lessons 24 and 54).
- Material 3
NavigationBarandScaffold(lessons 21 and 38).
A navigation graph is a map of the screens (destinations) and how you can travel between them. The back stack is the list of destinations the user has visited, with the current one on top. Back pops the top.
NavHost (start = Home)
Back stack after: Home -> Catalogue -> Book(7)
[Book(7)] <- top: what the user sees
[Catalogue]
[Home]
The principles from the official navigation principles guidance:
- Fixed start destination. Every app has one start destination, the first screen of the app, and Back eventually ends there.
- Navigation state is a stack. Back pops, Up and Back behave consistently.
- Up and Back are the same inside your app (Up is not "browser back" to a previous app).
- Deep links should recreate a sensible stack: tapping a notification for Book 7 should show Book 7 with Home underneath it, so Back goes somewhere meaningful.
- Use destinations, not strings, and pass IDs, not objects.
A sealed structure for routes
Keep all routes in one file, as @Serializable types, grouped by graph:
package com.techbase.library.nav
import kotlinx.serialization.Serializable
// Top-level destinations (bottom bar tabs)
@Serializable data object Home
@Serializable data object Catalogue
@Serializable data object Profile
// Detail with an argument
@Serializable data class BookDetail(val bookId: Int, val highlight: String? = null)
// A nested graph and its screens
@Serializable data object AuthGraph
@Serializable data object Login
@Serializable data object Register
Why data object rather than object? A data object has a proper toString, equals and hashCode, which makes logs and tests readable. Both work as routes.
package com.techbase.library.nav
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
@Composable
fun AppNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Home,
modifier = modifier
) {
composable<Home> {
HomeScreen(onOpenBook = { id -> navController.navigate(BookDetail(id)) })
}
composable<Catalogue> {
CatalogueScreen(
onOpenBook = { id -> navController.navigate(BookDetail(bookId = id, highlight = "new")) }
)
}
composable<BookDetail> { backStackEntry ->
val args: BookDetail = backStackEntry.toRoute()
BookDetailScreen(
bookId = args.bookId,
highlight = args.highlight,
onBack = { navController.popBackStack() }
)
}
composable<Profile> { ProfileScreen() }
}
}
How arguments work
navigate(BookDetail(7))serialises the route object into the destination's URL-like route string (internally something like.../7).backStackEntry.toRoute<BookDetail>()rebuilds the object. If the screen has a ViewModel, read the same object from itsSavedStateHandle:
package com.techbase.library.nav
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute
class BookDetailViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
private val route: BookDetail = savedStateHandle.toRoute()
val bookId: Int = route.bookId
val highlight: String? = route.highlight
// load the book by bookId from a repository...
}
That is the recommended way: the ViewModel gets the ID from the saved state handle, loads the data itself, and the composable only receives UI state (lesson 61). Do not read backStackEntry deep in composables.
Supported argument types
Primitives, String, enums, @Serializable classes in some versions, lists/arrays of primitives and Parcelable or custom types with a custom NavType. Rules:
- Default values make an argument optional (
highlight: String? = null). - Nullable types are allowed with
String?etc. - Do not send large or complex objects. Send the ID, and let the destination load the object. Reasons: the route string has size limits, objects can go stale, and process death restores arguments only from the saved state.
If you really must pass a custom type, register a typeMap for it (this requires a NavType implementation, documented in the official type-safety guide). Prefer avoiding it.
Users expect each tab to remember where they were. If they scroll down in Catalogue, open a book, switch to Profile and return, they should still be on the book. This needs the save and restore state options.
package com.techbase.library.nav
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlin.reflect.KClass
private data class TopLevel<T : Any>(val name: String, val route: T, val icon: ImageVector)
private val topLevelItems = listOf(
TopLevel("Home", Home, Icons.Filled.Home),
TopLevel("Catalogue", Catalogue, Icons.Filled.Menu),
TopLevel("Profile", Profile, Icons.Filled.Person)
)
@Composable
fun MainScaffold() {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
Scaffold(
bottomBar = {
NavigationBar {
topLevelItems.forEach { item ->
val selected = currentDestination?.hierarchy?.any { it.hasRoute(item.route::class) } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(item.icon, contentDescription = null) },
label = { Text(item.name) }
)
}
}
}
) { innerPadding ->
AppNavHost(navController, Modifier.padding(innerPadding))
}
}
The three lines inside navigate { ... } are the bottom navigation recipe; learn them by heart:
| Option | What it does | Without it |
|---|---|---|
popUpTo(startDestination) { saveState = true } |
Pops back to the start, saving the state of the screens it removes | Tabs pile up on the back stack; Back cycles through every tab you tapped |
launchSingleTop = true |
Reuses the top destination if you tap the same tab again | Tapping a tab twice creates duplicate screens |
restoreState = true |
Restores the state saved earlier for that tab | Every tab switch resets the tab to its first screen |
hierarchy.any { it.hasRoute(...) } marks a tab as selected when the current destination is the tab or any destination nested under it (important once tabs contain nested graphs). Use hasRoute(KClass) with type-safe routes. If you want to hide the bottom bar on detail screens, test the current destination: val showBar = currentDestination?.hasRoute<BookDetail>() != true.
The NavigationSuiteScaffold in lesson 58 switches between bottom bar, rail and drawer according to screen size, using the same navigation calls.
A nested graph groups destinations that belong together (login, register, forgot password) behind one entry point. Benefits: a clear start destination for the group, a place to scope a shared ViewModel, and the ability to pop the whole group when finished.
package com.techbase.library.nav
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
fun NavGraphBuilder.authGraph(navController: NavHostController, onLoggedIn: () -> Unit) {
navigation<AuthGraph>(startDestination = Login) {
composable<Login> {
LoginScreen(
onLoggedIn = onLoggedIn,
onRegister = { navController.navigate(Register) }
)
}
composable<Register> {
RegisterScreen(
onRegistered = onLoggedIn,
onBack = { navController.popBackStack() }
)
}
}
}
Use it as an extension of NavGraphBuilder so that each feature owns its graph, and call it inside the host:
NavHost(navController, startDestination = AuthGraph) {
authGraph(
navController,
onLoggedIn = {
navController.navigate(Home) {
popUpTo(AuthGraph) { inclusive = true } // remove the whole auth flow
}
}
)
composable<Home> { /* ... */ }
}
popUpTo(AuthGraph) { inclusive = true } pops everything up to and including the auth graph, so pressing Back on Home exits the app instead of returning to Login. That is the correct behaviour for sign-in, onboarding and splash screens.
A conditional start destination
Choose the start destination based on whether the user is signed in:
val start: Any = if (isSignedIn) Home else AuthGraph
NavHost(navController, startDestination = start) { /* ... */ }
The start destination is read once when the graph is created; do not try to change it later. To react to sign-out, navigate(AuthGraph) { popUpTo(0) } (clearing the whole stack). Show a brief splash while you read the signed-in state asynchronously (from DataStore, lesson 35) before building the NavHost, to avoid flashing the wrong screen.
| Need | Code |
|---|---|
| Go back one | navController.popBackStack() or navigateUp() |
| Go back to a specific destination | popBackStack<Home>(inclusive = false) |
| Navigate and remove the current screen (e.g. splash) | navigate(Home) { popUpTo<Splash> { inclusive = true } } |
| Clear everything and start fresh | navigate(Home) { popUpTo(0) { inclusive = true } } |
| Avoid duplicates at the top | launchSingleTop = true |
| Send a result back to the previous screen | navController.previousBackStackEntry?.savedStateHandle?.set("key", value) then popBackStack() |
| Intercept Back | BackHandler(enabled = hasUnsavedChanges) { showDiscardDialog() } |
Returning a result
// ChooseGenreScreen: set the result and go back
navController.previousBackStackEntry?.savedStateHandle?.set("genre", "Fiction")
navController.popBackStack()
// AddBookScreen: observe the result
val genre by navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow<String?>("genre", null)
?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) }
Needs import androidx.lifecycle.compose.collectAsStateWithLifecycle and the usual state imports. Keep results small and serialisable (strings, numbers, IDs).
BackHandler
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
@Composable
fun EditorScreen(hasChanges: Boolean, onDiscardRequest: () -> Unit) {
BackHandler(enabled = hasChanges) {
onDiscardRequest() // show "Discard changes?" instead of leaving
}
}
Predictive back (the system's back-gesture preview) works with Navigation Compose in current versions; use the standard components and BackHandler, and test on a recent Android version.
A deep link takes the user directly to a destination from outside the app: a web link, a notification, another app, or an adb command. There are two links to make: the navigation graph knows which destination handles which URL, and the manifest tells Android that your app can open that URL.
In the graph
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
composable<BookDetail>(
deepLinks = listOf(
navDeepLink<BookDetail>(basePath = "https://library.techbase.ng/book"),
navDeepLink<BookDetail>(basePath = "techbaselib://book")
)
) { backStackEntry ->
val args: BookDetail = backStackEntry.toRoute()
BookDetailScreen(args.bookId, args.highlight, onBack = { navController.popBackStack() })
}
With navDeepLink<BookDetail>(basePath = ...), the library builds the URI pattern from the route's properties: required properties become path segments (.../book/7) and optional (default) ones become query parameters (.../book/7?highlight=new). Check the generated pattern in the documentation for your version and test it.
In the manifest
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Custom scheme: works anywhere, no verification -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="techbaselib" android:host="book" />
</intent-filter>
<!-- Web links: verified App Links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="library.techbase.ng" android:pathPrefix="/book" />
</intent-filter>
</activity>
Notes:
- Android Studio's App Links Assistant (Tools menu) helps create filters and the verification file.
- App Links (
httpswithautoVerify) open your app directly without a chooser, but require you to host a Digital Asset Links file athttps://yourdomain/.well-known/assetlinks.jsonthat lists your package name and signing certificate fingerprint. Custom schemes need no verification but any app can claim them and users may get a chooser. - Set
launchModecarefully; asingleTopActivity receives the new intent inonNewIntent, and Navigation Compose handles it for you when theNavControlleris set up in the Activity.
Test with adb
adb shell am start -W -a android.intent.action.VIEW \
-d "techbaselib://book/7" com.techbase.library
adb shell am start -W -a android.intent.action.VIEW \
-d "https://library.techbase.ng/book/7?highlight=new" com.techbase.library
Expected: the app opens on Book 7. For the https link on a device where verification has not been established, you may see a chooser. You can check or force link handling with adb shell pm get-app-links com.techbase.library and adb shell pm set-app-links ... (see the official App Links documentation for the options on your Android version).
Backstack from a deep link
Navigation Compose builds a synthetic back stack for a deep link when its destination is in a nested graph: the start destination of the graph is placed under it. For a sensible Back, put the destination inside a graph whose start destination is a meaningful parent (for example, Catalogue), or handle the intent yourself.
Pending intents for notifications
When your app posts a notification (lesson 36), use NavDeepLinkBuilder or a TaskStackBuilder with the deep link URI so the tap lands on the right screen with a proper back stack:
import android.app.PendingIntent
import android.content.Context
import androidx.core.net.toUri
import androidx.navigation.NavDeepLinkBuilder
fun bookPendingIntent(context: Context, bookId: Int): PendingIntent =
NavDeepLinkBuilder(context)
.setGraph(R.navigation.main_graph) // for XML graphs
.setDestination(R.id.bookDetailFragment)
.createPendingIntent()
For Compose-only apps the simplest approach is an explicit Intent(ACTION_VIEW, "techbaselib://book/$bookId".toUri()) with setPackage(context.packageName) wrapped in PendingIntent.getActivity(...) with FLAG_IMMUTABLE. Always set FLAG_IMMUTABLE unless you need mutable behaviour.
A multi-step flow (checkout: address → payment → confirm) needs shared state across several destinations, which should die when the flow ends. Scope a ViewModel to the nested graph's back stack entry.
import androidx.compose.runtime.remember
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.navigation
import androidx.navigation.compose.composable
navigation<CheckoutGraph>(startDestination = Address) {
composable<Address> { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry<CheckoutGraph>() }
val vm: CheckoutViewModel = viewModel(parentEntry)
AddressScreen(vm, onNext = { navController.navigate(Payment) })
}
composable<Payment> { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry<CheckoutGraph>() }
val vm: CheckoutViewModel = viewModel(parentEntry) // the SAME instance
PaymentScreen(vm, onPay = { navController.navigate(Confirm) })
}
}
Both screens call viewModel(parentEntry) with the graph's entry as the owner, so they get the same instance. It is created when the user enters the graph and cleared when the graph is popped off the back stack. With Hilt, use hiltViewModel(parentEntry) (lesson 65). remember(entry) ensures the parent entry is looked up again when the child entry changes, which avoids a crash on the exit animation.
Be careful with shared ViewModels: do not make them a dumping ground for the whole app. Share only what the flow really needs.
In a multi-module app (lesson 65), feature modules should not depend on each other. Navigation therefore needs a contract:
:app -> depends on all feature modules; owns the NavHost
:feature:catalogue -> exposes fun NavGraphBuilder.catalogueGraph(onOpenBook: (Int) -> Unit)
:feature:book -> exposes fun NavGraphBuilder.bookGraph(onBack: () -> Unit)
:core:navigation -> (optional) shared route types used by more than one module
Each feature module exposes an extension on NavGraphBuilder and takes callbacks for navigation it does not own. The :app module wires the callbacks to navController.navigate(...). Features never receive the NavController itself (that would let any screen navigate anywhere). If two features need the same route type, put it in a small shared :core:navigation (or :api) module.
Best-practice checklist (from the official guidance, in our words)
- One
NavHost(or one per top-level surface), hoisted above screens. - Do not pass
NavControllerdown to leaf composables. Pass lambdas such asonOpenBook: (Int) -> Unit. This keeps screens testable and previewable. - Navigate from event handlers, not during composition. If a ViewModel decides to navigate, expose it as state (an event the UI consumes), not a
NavControllerreference. - IDs, not objects. The destination loads its own data.
- Name the start destination and keep it stable.
- Handle the same destination appearing twice (
launchSingleTop). - Avoid navigating twice on a fast double tap: check
lifecycle.currentState == RESUMEDor debounce clicks. - One source of truth for the selected tab: the back stack, not a separate variable.
The double-tap guard
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
fun NavBackStackEntry.lifecycleIsResumed() = lifecycle.currentState == Lifecycle.State.RESUMED
fun NavController.navigateOnce(from: NavBackStackEntry, route: Any) {
if (from.lifecycleIsResumed()) navigate(route)
}
Use it in screens as onOpenBook = { navController.navigateOnce(backStackEntry, BookDetail(it)) }. During the transition animation the old entry is no longer RESUMED, so a second tap is ignored.
You tested navigation in lesson 26 with TestNavHostController. For deeper flows, add assertions on the back stack:
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class NavigationTest {
@get:Rule val rule = createComposeRule()
private lateinit var navController: TestNavHostController
@Test
fun catalogueOpensBookDetail() {
rule.setContent {
navController = TestNavHostController(LocalContext.current).apply {
navigatorProvider.addNavigator(ComposeNavigator())
}
AppNavHost(navController)
}
navController.navigate(Catalogue)
rule.onNodeWithText("Things Fall Apart").performClick()
assertTrue(navController.currentBackStackEntry?.destination?.hasRoute<BookDetail>() == true)
}
}
(hasRoute<BookDetail>() is the reified overload from androidx.navigation.NavDestination.Companion.) Also test: the back button returns to the right screen (navController.popBackStack()), tabs restore their state, and the deep link path with navController.handleDeepLink(Intent(...)) (a test-only helper in the testing artifact).
Navigation 3
Google has released Navigation 3, a newer navigation library for Compose in which your app owns the back stack as a list (SnapshotStateList/NavBackStack) and renders it with a NavDisplay. The idea is simple: the back stack is state you hold, and the UI is a function of that state, consistent with everything in Compose. Navigation Compose (the library you learned in this lesson) remains widely used and supported, and is what almost all existing projects use. For a new project, read the current Navigation 3 documentation and decide; the concepts here (routes as types, scoped ViewModels, deep links, bottom bars with saved state, back stack control) transfer directly. APIs and stability may differ from what we show, so check the release status before adopting.
Exercise 1: Bottom bar with state
Create an app with three tabs (Home, Search, Settings). On the Search tab, a button opens Result(query: String). Show that after switching to Settings and back, the Search tab is still on the result screen. Write the key navigation call.
@Serializable data object Home
@Serializable data object Search
@Serializable data class Result(val query: String)
@Serializable data object Settings
// In the bottom bar:
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
For the Search tab to restore its nested state, group Search and Result in a nested graph (navigation<SearchGraph>(startDestination = Search) { composable<Search>{...}; composable<Result>{...} }) and make the tab navigate to SearchGraph. saveState/restoreState save the back stack of that graph, so returning to the tab shows Result again. Mark the tab selected with hierarchy.any { it.hasRoute(SearchGraph::class) }.
Exercise 2: Deep link design
Design deep links for an e-commerce app: product page, order tracking and a promotion with an optional coupon. Give the routes, the URL patterns, two adb test commands and the sensible back stack for each.
Routes: @Serializable data class Product(val id: Int), @Serializable data class Order(val orderId: String), @Serializable data class Promo(val code: String? = null). URLs: https://shop.example.ng/product/{id}, https://shop.example.ng/order/{orderId}, https://shop.example.ng/promo?code=NAIJA10. Tests: adb shell am start -W -a android.intent.action.VIEW -d "https://shop.example.ng/product/42" com.example.shop and ... -d "https://shop.example.ng/promo?code=NAIJA10" com.example.shop. Back stacks: Product opens over Home (or the Catalogue graph's start), Order opens over the Orders list, Promo over Home. Put each destination inside the graph whose start destination is its logical parent, so Back goes to a sensible screen and not out of the app.
Exercise 3: Find the navigation bugs
(a) Tapping Home five times shows five Home screens. (b) After login, Back returns to Login. (c) A product detail crashes after process death because the whole Product object was passed. (d) A button navigates twice on a quick double tap. Give the one-line fix for each.
(a) launchSingleTop = true. (b) popUpTo(AuthGraph) { inclusive = true } when navigating to Home. (c) Pass only productId in the route and let the ViewModel load the product (via SavedStateHandle.toRoute()). (d) Ignore clicks unless the back stack entry is RESUMED (navigateOnce), or disable the button after the first click.
Mistake #1: Passing NavController to every composable. Symptom: untestable, tightly coupled screens. Fix: pass lambdas.
Mistake #2: Passing whole objects. Symptom: crashes after process death, size limits. Fix: IDs and a repository.
Mistake #3: Forgetting saveState/restoreState on tabs. Symptom: tabs reset. Fix: the three-option recipe.
Mistake #4: Not clearing the login screens from the back stack. Fix: popUpTo(...) { inclusive = true }.
Mistake #5: Using popUpTo(0) unintentionally. Symptom: back stack wiped. Fix: only for sign-out or reset.
Mistake #6: Deep link without the manifest filter. Symptom: adb command does nothing. Fix: add both graph and manifest entries.
Mistake #7: Not verifying App Links. Symptom: chooser dialog. Fix: assetlinks.json and autoVerify.
Mistake #8: Navigating during composition. Symptom: repeated navigation, loops. Fix: in event handlers or LaunchedEffect keyed on a consumed event.
Mistake #9: Sharing one giant ViewModel across the app. Fix: scope to the graph that needs it.
Mistake #10: Changing start destination dynamically. Fix: decide before building the graph.
Question 1. What do saveState, restoreState and launchSingleTop do on a bottom bar?
saveState stores the back stack state of the tab you leave, restoreState restores it when you return, and launchSingleTop prevents duplicate copies of the same destination at the top.
Question 2. How does a ViewModel read a type-safe route argument?
With savedStateHandle.toRoute<RouteType>() (SavedStateHandle is injected into the ViewModel by the navigation entry's factory).
Question 3. What does popUpTo(AuthGraph) { inclusive = true } achieve after login?
It removes the whole authentication graph from the back stack so that pressing Back on the home screen does not return to login.
Question 4. What two places must declare a deep link for it to work?
The navigation graph (navDeepLink on the destination) and the Android manifest (an intent-filter for the URI scheme/host/path on the Activity).
Question 5. Why should feature modules not receive a NavController?
Passing it lets any screen navigate anywhere, creating coupling between features. Features should expose graph builders with callbacks, and the app module wires navigation.
Go deeper: official resources
- Jetpack Compose for Android developers, Pathway 3
- Jetpack Compose navigation codelab
- Jetpack Compose course page
- Android Basics with Compose, Unit 4 Pathway 1
Project goal: Build a library app whose navigation uses every concept in this lesson.
Requirements:
- Three bottom tabs (Home, Catalogue, Profile) with preserved state.
- A
BookDetail(bookId, highlight?)destination, with its ViewModel reading the route viaSavedStateHandle.toRoute(). - An
AuthGraph(Login, Register) shown first when signed out and removed from the back stack after login. - Two deep links (custom scheme and https) for
BookDetail, with the manifest filters, andadbcommands in your README. - A
BackHandleron a "write a review" screen that asks before discarding. - A shared
CheckoutViewModelacrossAddress→Payment→Confirmscoped to aCheckoutGraph. - A
TestNavHostControllertest for the tab navigation and one for the deep link.
Stretch goal: split the catalogue into a separate Gradle module exposing NavGraphBuilder.catalogueGraph(onOpenBook).
Routes.kt and the host were shown in the lesson. The extra pieces:
package com.techbase.library.nav
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
@Serializable data object CheckoutGraph
@Serializable data object Address
@Serializable data object Payment
@Serializable data object Confirm
@Serializable data object ReviewEditor
@Composable
fun AppNavHost(
navController: NavHostController,
signedIn: Boolean = true,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = if (signedIn) Home else AuthGraph,
modifier = modifier
) {
authGraph(navController, onLoggedIn = {
navController.navigate(Home) { popUpTo(AuthGraph) { inclusive = true } }
})
composable<Home> { HomeScreen { id -> navController.navigate(BookDetail(id)) } }
composable<Catalogue> { CatalogueScreen { id -> navController.navigate(BookDetail(id, "new")) } }
composable<Profile> { ProfileScreen() }
composable<BookDetail>(
deepLinks = listOf(
navDeepLink<BookDetail>(basePath = "https://library.techbase.ng/book"),
navDeepLink<BookDetail>(basePath = "techbaselib://book")
)
) { entry ->
val args: BookDetail = entry.toRoute()
BookDetailScreen(args.bookId, args.highlight,
onBack = { navController.popBackStack() },
onWriteReview = { navController.navigate(ReviewEditor) },
onBuy = { navController.navigate(CheckoutGraph) })
}
composable<ReviewEditor> { ReviewEditorScreen(onDone = { navController.popBackStack() }) }
navigation<CheckoutGraph>(startDestination = Address) {
composable<Address> { entry ->
val parent = remember(entry) { navController.getBackStackEntry<CheckoutGraph>() }
val vm: CheckoutViewModel = viewModel(parent)
AddressScreen(vm) { navController.navigate(Payment) }
}
composable<Payment> { entry ->
val parent = remember(entry) { navController.getBackStackEntry<CheckoutGraph>() }
val vm: CheckoutViewModel = viewModel(parent)
PaymentScreen(vm) { navController.navigate(Confirm) }
}
composable<Confirm> { entry ->
val parent = remember(entry) { navController.getBackStackEntry<CheckoutGraph>() }
val vm: CheckoutViewModel = viewModel(parent)
ConfirmScreen(vm) {
navController.navigate(Home) { popUpTo(CheckoutGraph) { inclusive = true } }
}
}
}
}
}
ReviewEditorScreen uses BackHandler(enabled = text.isNotBlank()) { showDiscardDialog = true }. The tab test:
package com.techbase.library.nav
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class NavigationTest {
@get:Rule val rule = createComposeRule()
@Test
fun navigatesToCatalogueThenBackToHome() {
lateinit var nav: TestNavHostController
rule.setContent {
nav = TestNavHostController(LocalContext.current).apply {
navigatorProvider.addNavigator(ComposeNavigator())
}
AppNavHost(nav)
}
rule.runOnIdle { nav.navigate(Catalogue) }
rule.runOnIdle { assertTrue(nav.currentBackStackEntry?.destination?.hasRoute<Catalogue>() == true) }
rule.runOnIdle { nav.popBackStack() }
rule.runOnIdle { assertTrue(nav.currentBackStackEntry?.destination?.hasRoute<Home>() == true) }
}
}
README deep-link commands: adb shell am start -W -a android.intent.action.VIEW -d "techbaselib://book/7" com.techbase.library and the https equivalent. Check: after login, Back exits the app; switching tabs keeps each tab's history; the checkout ViewModel keeps the address when you go forward and back, and is gone after confirming.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 55 project”.
On github.com click New repository, name it android-lesson-55-navigation-compose-in-depth, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 55 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Graphs, Destinations and the Back Stack
- Step-by-Step Build 1: A NavHost with Arguments
- Step-by-Step Build 2: Bottom Navigation with Preserved State
- Step-by-Step Build 3: Nested Navigation Graphs
- Step-by-Step Build 4: Back Stack Control
- Step-by-Step Build 5: Deep Links
- Step-by-Step Build 6: Sharing a ViewModel Across a Graph
- Step-by-Step Build 7: Multi-Module Navigation and Best Practices
- Step-by-Step Build 8: Testing Navigation and a Note on Navigation 3
- Guided Practice Exercises
- Common Mistakes (and How to Fix Them)
- Knowledge Check