In a real app, users don’t stay on one screen. They tap an article, go to a detail page, press back, open settings, log in, log out. Navigation is how you move between these screens in Compose. If you’ve used XML navigation with Fragments and nav_graph.xml, forget all of that — Compose navigation is completely different. There’s no XML, no Fragments, no nav_graph file. Everything is in Kotlin, and it’s actually simpler once you understand three things: NavController (who manages the screens), NavHost (where screens are displayed), and Routes (how screens are identified). Let’s build it up step by step.
Before We Start — The Mental Model
// Think of navigation like a STACK OF CARDS on a table:
//
// ┌─────────────┐
// │ Detail │ ← top card (visible, user sees this)
// ├─────────────┤
// │ Article List│ ← underneath
// ├─────────────┤
// │ Home │ ← bottom card
// └─────────────┘
//
// When user opens a new screen → a card is PLACED ON TOP of the stack
// When user presses Back → the top card is REMOVED, card below is now visible
// This stack is called the BACK STACK
//
// In Compose, THREE things manage this:
//
// NavController → the MANAGER (decides which card is on top)
// NavHost → the TABLE (the area on screen where cards are shown)
// Routes → the LABELS on each card ("home", "detail/123", "settings")
//
// That's it. Everything else is just details around these three.
Step 1 — Add the Dependencies
// In your build.gradle.kts (app module):
dependencies {
// This is the ONLY library you need for basic Compose navigation
implementation("androidx.navigation:navigation-compose:2.8.3")
// navigation-compose is a LIBRARY that provides NavHost, composable(), etc.
// If you use Hilt for dependency injection (most production apps do):
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Provides hiltViewModel() — a COMPOSABLE FUNCTION that creates ViewModels with Hilt
// For type-safe navigation (recommended — we'll cover this later):
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
// Also add the serialization plugin (in your plugins block):
plugins {
id("org.jetbrains.kotlin.plugin.serialization")
}
Step 2 — Create the NavController
The NavController is the brain of navigation. It knows which screen is currently shown, what’s in the back stack, and how to navigate forward or backward:
@Composable
fun MyApp() {
val navController = rememberNavController()
// rememberNavController() is a COMPOSABLE FUNCTION
//
// What it does:
// 1. Creates a NavHostController (a CLASS that manages navigation state)
// 2. Wraps it in remember {} so it survives recomposition
// 3. Returns the same instance every time this composable recomposes
//
// Think of it like: "create a navigation manager and keep it alive"
//
// You call this ONCE at the top level — don't create multiple NavControllers
// ... NavHost goes here (Step 3)
}
Step 3 — Set Up the NavHost
The NavHost is the area on screen where your screens are displayed. When navigation happens, NavHost swaps out the current screen for the new one:
@Composable
fun MyApp() {
val navController = rememberNavController()
NavHost(
// NavHost is a COMPOSABLE FUNCTION — the container for all your screens
navController = navController, // connect it to our NavController
startDestination = "home" // which screen shows FIRST
) {
// Inside this block, you REGISTER all your screens
// This block is NOT @Composable — it's NavGraphBuilder.()
// NavGraphBuilder is a CLASS that lets you add destinations
// Screen 1: Home
composable("home") {
// composable() is an EXTENSION FUNCTION on NavGraphBuilder
// "home" is the ROUTE — a string that identifies this screen
// The lambda IS @Composable — this is your actual screen content
HomeScreen()
}
// Screen 2: Article Detail
composable("detail/{articleId}") {
// "detail/{articleId}" is a route WITH A PARAMETER
// {articleId} is a PLACEHOLDER — replaced with actual value when navigating
val articleId = it.arguments?.getString("articleId") ?: ""
// "it" is NavBackStackEntry — a CLASS that holds this destination's info
DetailScreen(articleId = articleId)
}
// Screen 3: Settings
composable("settings") {
SettingsScreen()
}
}
}
Step 4 — Navigate Between Screens
// NAVIGATING FORWARD — push a new screen onto the stack
Button(onClick = {
navController.navigate("detail/abc-123")
// navigate() is a FUNCTION on NavController
// "detail/abc-123" matches the pattern "detail/{articleId}"
// So articleId = "abc-123" in the detail screen
}) {
Text("Open Article")
}
// NAVIGATING BACK — pop the top screen off the stack
Button(onClick = {
navController.popBackStack()
// popBackStack() is a FUNCTION on NavController
// Removes the current screen, previous screen becomes visible
}) {
Text("Go Back")
}
// Note: the system Back button/gesture AUTOMATICALLY calls popBackStack
// You only need to call it manually for custom back buttons in your UI
Wait — should I pass NavController to every screen?
// ❌ BAD: passing NavController deep into composables
@Composable
fun ArticleCard(article: Article, navController: NavController) {
Card(onClick = { navController.navigate("detail/${article.id}") }) { /* ... */ }
}
// Problems:
// - ArticleCard now DEPENDS on navigation — can't preview, can't test
// - Every composable knows about NavController — tight coupling
// ✅ GOOD: pass callback lambdas — composable doesn't know about navigation
@Composable
fun ArticleCard(article: Article, onClick: () -> Unit) {
Card(onClick = onClick) { /* ... */ }
}
// Wire navigation at the SCREEN level only:
composable("home") {
HomeScreen(
onArticleClick = { articleId ->
navController.navigate("detail/$articleId")
},
onSettingsClick = { navController.navigate("settings") }
)
}
@Composable
fun HomeScreen(
onArticleClick: (String) -> Unit, // callback — no NavController knowledge
onSettingsClick: () -> Unit
) {
ArticleCard(article, onClick = { onArticleClick(article.id) })
}
// This is the CORRECT pattern:
// NavController stays at the NavHost level
// Screens receive callbacks
// Child composables know nothing about navigation
Passing Arguments — Three Ways
Way 1: Path arguments (required, in the route)
// Define: composable("detail/{articleId}")
// Navigate: navController.navigate("detail/abc-123")
// Read: backStackEntry.arguments?.getString("articleId")
composable(
route = "detail/{articleId}",
arguments = listOf(
navArgument("articleId") {
// navArgument() is a TOP-LEVEL FUNCTION — defines argument metadata
type = NavType.StringType
// NavType is an ABSTRACT CLASS with predefined types:
// StringType, IntType, LongType, FloatType, BoolType
}
)
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId") ?: ""
DetailScreen(articleId = articleId)
}
Way 2: Query parameters (optional arguments)
composable(
route = "articles?category={category}&sort={sort}",
arguments = listOf(
navArgument("category") {
type = NavType.StringType
defaultValue = "all" // uses this if not provided
nullable = true
},
navArgument("sort") {
type = NavType.StringType
defaultValue = "newest"
}
)
) { backStackEntry ->
val category = backStackEntry.arguments?.getString("category") ?: "all"
val sort = backStackEntry.arguments?.getString("sort") ?: "newest"
ArticleListScreen(category, sort)
}
// Navigate:
navController.navigate("articles") // uses all defaults
navController.navigate("articles?category=tech") // category=tech, sort=newest
navController.navigate("articles?category=tech&sort=oldest") // both specified
Way 3: Type-safe navigation with @Serializable (RECOMMENDED)
This is the modern, recommended approach. Instead of error-prone strings, you use Kotlin objects as routes:
// Step 1: Define routes as @Serializable objects or data classes
@Serializable
// @Serializable is an ANNOTATION from kotlinx.serialization
object Home // no arguments → use object (singleton)
@Serializable
data class Detail(val articleId: String) // has arguments → use data class
@Serializable
data class ArticleList(
val category: String = "all", // optional with default
val sort: String = "newest"
)
@Serializable
object Settings
// Step 2: Use in NavHost — no strings anywhere!
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
// composable<T>() is an INLINE REIFIED EXTENSION FUNCTION on NavGraphBuilder
// Type parameter T is the route class — type-safe!
HomeScreen(
onArticleClick = { id ->
navController.navigate(Detail(articleId = id))
// Pass the OBJECT — arguments are constructor parameters
// Can't forget an argument — compiler error!
// Can't pass wrong type — compiler error!
}
)
}
composable<Detail> { backStackEntry ->
val route = backStackEntry.toRoute<Detail>()
// toRoute<T>() is an EXTENSION FUNCTION on NavBackStackEntry
// Deserializes back to your data class — type-safe!
DetailScreen(articleId = route.articleId)
}
composable<Settings> { SettingsScreen() }
}
// Navigate — fully type-safe:
navController.navigate(Detail(articleId = "abc-123"))
navController.navigate(ArticleList(category = "tech"))
navController.navigate(Settings)
// WHY type-safe is better:
// ❌ String: "detial/123" → compiles but CRASHES at runtime (typo!)
// ✅ Type-safe: Detial(articleId = "123") → COMPILE ERROR (class doesn't exist)
// ❌ String: "detail/" → crashes (missing argument)
// ✅ Type-safe: Detail() → COMPILE ERROR (articleId is required)
Reading Arguments in ViewModel
In real apps, the ViewModel reads navigation arguments automatically via SavedStateHandle:
@HiltViewModel
class DetailViewModel @Inject constructor(
private val repository: ArticleRepository,
private val savedStateHandle: SavedStateHandle
// SavedStateHandle is a CLASS — Hilt provides it automatically
// Navigation arguments are INJECTED into SavedStateHandle by the framework
) : ViewModel() {
// Type-safe:
private val route = savedStateHandle.toRoute<Detail>()
// toRoute<T>() is an EXTENSION FUNCTION on SavedStateHandle
private val articleId = route.articleId
// String routes alternative:
// private val articleId: String = savedStateHandle.get<String>("articleId") ?: ""
val article: StateFlow<Article?> = repository.getArticleFlow(articleId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
// In the composable — no manual arg passing needed:
composable<Detail> {
val viewModel: DetailViewModel = hiltViewModel()
// hiltViewModel() is a COMPOSABLE FUNCTION — creates ViewModel with Hilt
// SavedStateHandle already has articleId — automatic!
val article by viewModel.article.collectAsStateWithLifecycle()
DetailScreen(article = article)
}
Controlling the Back Stack
// Basic forward and back:
navController.navigate(Detail(articleId = "123")) // push
navController.popBackStack() // pop
// ═══ ADVANCED OPTIONS ════════════════════════════════════════════════
// SINGLE TOP — don't create duplicate if already at top
navController.navigate(Home) {
// Lambda receiver is NavOptionsBuilder — a CLASS
launchSingleTop = true
// Without this: Home → Home → Home (stacking duplicates!)
}
// POP UP TO — remove screens while navigating
navController.navigate(Home) {
popUpTo(Home) {
// popUpTo() is a FUNCTION on NavOptionsBuilder
inclusive = true // also remove Home itself → fresh Home
saveState = true // save state of removed screens (bottom nav)
}
restoreState = true // restore tab state when returning
launchSingleTop = true
}
// CLEAR EVERYTHING — logout
navController.navigate(Login) {
popUpTo(0) { inclusive = true } // pop everything → only Login remains
}
Bottom Navigation
// Step 1: Define tab routes
@Serializable object HomeTab
@Serializable object SearchTab
@Serializable object BookmarksTab
@Serializable object ProfileTab
data class BottomNavItem(val route: Any, val icon: ImageVector, val label: String)
val bottomNavItems = listOf(
BottomNavItem(HomeTab, Icons.Default.Home, "Home"),
BottomNavItem(SearchTab, Icons.Default.Search, "Search"),
BottomNavItem(BookmarksTab, Icons.Default.Bookmark, "Bookmarks"),
BottomNavItem(ProfileTab, Icons.Default.Person, "Profile")
)
// Step 2: Build the screen
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentEntry by navController.currentBackStackEntryAsState()
// currentBackStackEntryAsState() is an EXTENSION FUNCTION on NavController
Scaffold(
bottomBar = {
NavigationBar {
// NavigationBar is a COMPOSABLE FUNCTION from Material3
bottomNavItems.forEach { item ->
val isSelected = currentEntry?.destination?.route ==
item.route::class.qualifiedName
NavigationBarItem(
selected = isSelected,
onClick = {
navController.navigate(item.route) {
// THE THREE MAGIC OPTIONS for bottom nav:
popUpTo(navController.graph.findStartDestination().id) {
saveState = true // save scroll position, text, etc.
}
launchSingleTop = true // no duplicate tabs
restoreState = true // restore when switching back
}
},
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }
)
}
}
}
) { padding ->
NavHost(navController, startDestination = HomeTab, Modifier.padding(padding)) {
composable<HomeTab> { HomeScreen(onArticleClick = { navController.navigate(Detail(it)) }) }
composable<SearchTab> { SearchScreen() }
composable<BookmarksTab> { BookmarksScreen() }
composable<ProfileTab> { ProfileScreen() }
composable<Detail> { DetailScreen(it.toRoute<Detail>().articleId) }
}
}
}
Nested Navigation Graphs — Multi-Screen Flows
// Group related screens into a "flow" — like checkout or auth
@Serializable object CheckoutGraph // the graph (not a screen)
@Serializable object Cart
@Serializable object Shipping
@Serializable object Payment
NavHost(navController, startDestination = HomeTab) {
composable<HomeTab> { /* ... */ }
navigation<CheckoutGraph>(startDestination = Cart) {
// navigation<T>() is an INLINE EXTENSION FUNCTION on NavGraphBuilder
// Creates a NESTED graph — Cart is first screen when entering
composable<Cart> { CartScreen(onProceed = { navController.navigate(Shipping) }) }
composable<Shipping> { ShippingScreen(onProceed = { navController.navigate(Payment) }) }
composable<Payment> {
PaymentScreen(onDone = {
navController.navigate(HomeTab) {
popUpTo(CheckoutGraph) { inclusive = true }
// Pop ENTIRE checkout flow — clean!
}
})
}
}
}
// Enter the flow:
navController.navigate(CheckoutGraph) // goes to Cart
Sharing ViewModel across a flow
// All checkout screens share ONE ViewModel:
composable<Cart> {
val parentEntry = remember(it) { navController.getBackStackEntry(CheckoutGraph) }
// getBackStackEntry() is a FUNCTION on NavController
val viewModel: CheckoutViewModel = hiltViewModel(parentEntry)
// SAME ViewModel instance for Cart, Shipping, and Payment
CartScreen(viewModel)
}
composable<Shipping> {
val parentEntry = remember(it) { navController.getBackStackEntry(CheckoutGraph) }
val viewModel: CheckoutViewModel = hiltViewModel(parentEntry) // SAME instance
ShippingScreen(viewModel)
}
// When checkout is popped → ViewModel is cleared → clean!
Deep Links
// Let external URLs open specific screens in your app
composable<Detail>(
deepLinks = listOf(
navDeepLink<Detail>(basePath = "https://www.example.com/articles")
// navDeepLink<T>() is a TOP-LEVEL FUNCTION
// Maps Detail properties to URL segments automatically
)
) { /* ... */ }
// AndroidManifest.xml needs:
// <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="https" android:host="www.example.com" />
// </intent-filter>
// Test: adb shell am start -d "https://www.example.com/articles/abc-123"
Navigation Animations
composable<Detail>(
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
// slideInHorizontally is a FUNCTION from compose.animation
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) { /* ... */ }
// Default animations for ALL destinations:
NavHost(
navController = navController,
startDestination = Home,
enterTransition = { fadeIn(tween(300)) },
// fadeIn is a FUNCTION, tween() creates a duration-based animation
exitTransition = { fadeOut(tween(300)) }
) { /* ... */ }
Triggering Navigation from State Changes
// ❌ WRONG — navigates on EVERY recomposition
if (isLoggedIn) { navController.navigate(Home) } // runs repeatedly!
// ✅ CORRECT — use LaunchedEffect for one-time navigation
LaunchedEffect(isLoggedIn) {
// LaunchedEffect is a COMPOSABLE FUNCTION for side effects
// Runs when isLoggedIn CHANGES — not on every recomposition
if (isLoggedIn) {
navController.navigate(Home) { popUpTo(Login) { inclusive = true } }
}
}
Common Mistakes to Avoid
Mistake 1: Creating NavController in the wrong place
// ❌ Outside Compose or inside a child
class MainActivity { val navController = NavHostController(this) } // wrong!
// ✅ Once at the root
@Composable fun MyApp() { val navController = rememberNavController() }
Mistake 2: Passing NavController to child composables
// ❌ ArticleCard(article, navController) — tight coupling
// ✅ ArticleCard(article, onClick = { }) — clean, testable
Mistake 3: Bottom nav without saveState/restoreState
// ❌ navController.navigate(tab) — loses scroll, creates duplicates
// ✅ navController.navigate(tab) {
// popUpTo(start) { saveState = true }
// launchSingleTop = true; restoreState = true
// }
Mistake 4: Navigating inside composition body
// ❌ if (condition) { navController.navigate(Home) } — every recomposition
// ✅ LaunchedEffect(condition) { if (condition) navController.navigate(Home) }
Mistake 5: Not popping after completing a flow
// ❌ navController.navigate(Home) — checkout screens still in back stack
// ✅ navController.navigate(Home) { popUpTo(CheckoutGraph) { inclusive = true } }
Summary
rememberNavController()(composable function) — creates the navigation manager, call once at rootNavHost(composable function) — container that displays the current screencomposable<T>()(inline reified extension function) — registers a screen with a type-safe routenavigate()(function on NavController) pushes a screen;popBackStack()removes it- Use
@Serializableobjects/data classes for type-safe routes — no strings, no typos, compiler-checked toRoute<T>()(extension function) reads arguments type-safely from NavBackStackEntry or SavedStateHandle- Arguments flow into ViewModel via SavedStateHandle automatically — no manual passing
- Never pass NavController to child composables — use callback lambdas
- Bottom nav needs
launchSingleTop+saveState+restoreState navigation<T>()(inline extension function) creates nested graphs for flows- Share ViewModel in a flow via
hiltViewModel(parentEntry) - Use
LaunchedEffectfor state-driven navigation — never navigate in composition body navDeepLink(top-level function) maps URLs to screens
Compose Navigation is simpler than Fragment navigation once the mental model clicks: NavController manages the stack, NavHost displays the screen, routes identify each screen. Use type-safe routes with @Serializable, keep NavController at the top level, pass callbacks down, and handle bottom nav with saveState/restoreState. That covers 95% of what you’ll need in production.
Happy coding!
Comments (0)