Lesson 22 of 83 intermediate

Navigation in Android: Fragment Nav vs Compose Nav vs Deep Links

Understand the full navigation stack — back stack management, type-safe routes, deep links, and nested graphs

Open interactive version (quiz + challenge)

Real-world analogy

Navigation is like a GPS system for your app. Fragment Navigation Component is an older GPS that tracks which physical roads (fragments) you've traveled. Compose Navigation is a modern GPS that tracks destinations described by addresses (route strings or typed objects) rather than physical roads. Deep links are like 'What3Words' addresses — a short external URL that the GPS translates directly to an exact destination, bypassing the normal journey.

What is it?

Android Navigation manages how users move between screens. Fragment Navigation Component uses XML nav graphs and Safe Args. Compose Navigation uses NavController + NavHost with string-based or type-safe routes. Deep links map external URLs or custom scheme URIs to specific in-app destinations — App Links (HTTPS + verification) are preferred over custom schemes. Both systems handle back stack management, nested graphs, and argument passing.

Real-world relevance

A field operations enterprise app uses a nested nav graph for the 'Work Order' flow (list → detail → edit → signature capture) scoped to a shared WorkOrderViewModel. Deep links from push notifications open directly to WorkOrderDetailScreen via an App Link (https://fieldops.com/orders/{id}). Navigating from the ViewModel uses a Channel to avoid holding a NavController reference. The auth flow is a separate nested graph popped entirely on login success using popUpTo('auth') { inclusive = true }.

Key points

Code example

// ===== COMPOSE NAVIGATION — TYPE-SAFE ROUTES (Nav 2.8+) =====

// Route definitions
@Serializable object Home
@Serializable object WorkOrderList
@Serializable data class WorkOrderDetail(val orderId: String)
@Serializable data class WorkOrderEdit(val orderId: String)

// App-level NavHost
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController = navController, startDestination = Home) {

        composable<Home> { HomeScreen(navController) }

        // Nested graph — shared WorkOrderViewModel scoped to this graph
        navigation<WorkOrderGraph>(startDestination = WorkOrderList) {
            composable<WorkOrderList> {
                WorkOrderListScreen(navController)
            }
            composable<WorkOrderDetail>(
                deepLinks = listOf(navDeepLink<WorkOrderDetail>(
                    basePath = "https://fieldops.com/orders"
                ))
            ) { entry ->
                val route: WorkOrderDetail = entry.toRoute()
                WorkOrderDetailScreen(orderId = route.orderId, navController = navController)
            }
            composable<WorkOrderEdit> { entry ->
                val route: WorkOrderEdit = entry.toRoute()
                WorkOrderEditScreen(orderId = route.orderId)
            }
        }
    }
}

// Navigate to detail
navController.navigate(WorkOrderDetail(orderId = "WO-001"))

// Navigate with back stack management — clear auth graph on login
navController.navigate(Home) {
    popUpTo<AuthGraph> { inclusive = true }
    launchSingleTop = true
}

// ===== ViewModel navigation events =====
class WorkOrderViewModel : ViewModel() {
    private val _navEvents = Channel<WorkOrderNavEvent>(Channel.BUFFERED)
    val navEvents = _navEvents.receiveAsFlow()

    fun onEditTapped(orderId: String) {
        viewModelScope.launch { _navEvents.send(WorkOrderNavEvent.NavigateToEdit(orderId)) }
    }
}

sealed class WorkOrderNavEvent {
    data class NavigateToEdit(val orderId: String) : WorkOrderNavEvent()
    object NavigateBack : WorkOrderNavEvent()
}

// Collect in composable
@Composable
fun WorkOrderDetailScreen(orderId: String, navController: NavController) {
    val viewModel: WorkOrderViewModel = hiltViewModel()

    LaunchedEffect(Unit) {
        viewModel.navEvents.collect { event ->
            when (event) {
                is WorkOrderNavEvent.NavigateToEdit ->
                    navController.navigate(WorkOrderEdit(event.orderId))
                WorkOrderNavEvent.NavigateBack -> navController.popBackStack()
            }
        }
    }
}

// ===== SHARED VIEWMODEL scoped to nested graph =====
@Composable
fun WorkOrderEditScreen(orderId: String) {
    // ViewModel lives as long as the WorkOrderGraph is on the back stack
    val sharedViewModel: WorkOrderViewModel =
        hiltViewModel(LocalNavBackStackEntry.current.let { entry ->
            val graph = entry.destination.parent!!
            rememberNavController().getBackStackEntry(graph.id)
        })
}

// ===== ANDROIDMANIFEST for 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="fieldops.com" android:pathPrefix="/orders"/>
// </intent-filter>

Line-by-line walkthrough

  1. 1. Route definitions as @Serializable objects/data classes replace 'home', 'detail/{id}' strings — the serialization library handles URL encoding/decoding of parameters
  2. 2. NavHost(startDestination = Home) uses the object type as the destination, not a string — type-checked at compile time
  3. 3. navigation(startDestination = WorkOrderList) creates a nested graph; all composables inside share back stack context with the graph
  4. 4. composable(deepLinks = listOf(navDeepLink(basePath = ...))) registers the App Link for this destination — basePath + serialized route fields form the full URL
  5. 5. entry.toRoute() deserializes the route arguments back into the data class — type-safe access to orderId
  6. 6. navController.navigate(Home) { popUpTo { inclusive = true } } removes the entire auth graph from the back stack on successful login — prevents back navigation to login
  7. 7. Channel(BUFFERED) in the ViewModel buffers events if the composable is not yet collecting — prevents dropped events during recomposition
  8. 8. LaunchedEffect(Unit) in the composable collects the channel flow — receives navigation events and calls navController.navigate() from the composable layer where NavController lives
  9. 9. hiltViewModel(backStackEntry) scopes the ViewModel to the graph's NavBackStackEntry — the same instance is returned for all composables in the graph
  10. 10. android:autoVerify='true' in the manifest intent-filter triggers OS verification of the assetlinks.json — required for App Links to open without disambiguation dialog

Spot the bug

class OrderViewModel @Inject constructor(
    private val repo: OrderRepository,
    private val navController: NavController  // Bug 1
) : ViewModel() {

    fun onOrderTapped(orderId: String) {
        navController.navigate("order_detail/$orderId")  // Bug 2
    }
}

@Composable
fun OrderListScreen() {
    val navController = rememberNavController()  // Bug 3
    val viewModel: OrderViewModel = hiltViewModel()

    LazyColumn {
        items(viewModel.orders, key = { it.id }) { order ->
            OrderRow(order = order, onClick = { viewModel.onOrderTapped(order.id) })
        }
    }
}

// NavHost somewhere in app root
NavHost(navController = appNavController, startDestination = "orders") {
    composable("orders") { OrderListScreen() }
    composable("order_detail/{orderId}") { entry ->
        val orderId = entry.arguments?.getString("orderId")  // Bug 4
        OrderDetailScreen(orderId = orderId!!)
    }
}
Need a hint?
Think about memory leaks, ViewModel navigation responsibility, NavController scope, and type safety.
Show answer
Bug 1: Injecting NavController into a ViewModel causes a memory leak — NavController holds an Activity reference. ViewModels outlive Activities. Fix: Remove NavController from ViewModel. Use a Channel<NavEvent> in the ViewModel and collect it in the composable to trigger navigation. Bug 2: String-based route 'order_detail/$orderId' bypasses type safety. With Navigation 2.8+ use @Serializable data class OrderDetail(val orderId: String) and navController.navigate(OrderDetail(orderId)). Bug 3: rememberNavController() inside OrderListScreen creates a SEPARATE NavController — it is not connected to the app-level NavHost. This NavController cannot navigate to 'order_detail'. Fix: Pass the app-level navController as a parameter to OrderListScreen, or retrieve it via LocalNavController (if set up). Bug 4: entry.arguments?.getString('orderId') is the old string-based approach — fragile and requires the key string to match exactly. With type-safe routes, use entry.toRoute<OrderDetail>().orderId for compile-time safety.

Explain like I'm 5

Navigation is like the map of a treasure hunt. Fragment Navigation is the old paper map with specific paths drawn on it. Compose Navigation is a digital map where you type in the destination's name or address and it figures out the path. Deep links are like a QR code someone sends you — scan it (tap a notification) and your app's map instantly takes you to the right treasure chest, skipping all the steps in between. Nested graphs are like a map within a map — a sub-quest inside the main quest.

Fun fact

Android App Links verification works by Google (or the OS) fetching a JSON file from https://yourdomain.com/.well-known/assetlinks.json that lists your app's package name and SHA-256 certificate fingerprint. If this file is unreachable or incorrect, the OS falls back to showing the disambiguation dialog — the same behavior as a custom scheme. This is why deep link testing in CI should always verify the assetlinks.json is reachable.

Hands-on challenge

Build a 3-tab app (Home, Projects, Profile) where the Projects tab contains a nested nav graph: ProjectList → ProjectDetail → ProjectEdit. Deep link from a push notification to ProjectDetail using an App Link. The ProjectDetail and ProjectEdit share a ProjectViewModel scoped to the projects graph. Navigate from ViewModel using a Channel. Ensure that pressing back from ProjectEdit returns to ProjectDetail, and back from ProjectDetail returns to ProjectList (not Home).

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery