Android
Navigation
Evolution
From raw Activity intents to type-safe Compose routes — every era, every problem it introduced, every solution that came next.
Navigation
Every screen is an Activity. Navigation is just startActivity(). Simple, direct, and dangerously limited.
Android launched with a beautifully simple navigation model: each screen is a self-contained Activity, and you navigate by firing an Intent. The OS manages a back stack automatically. No framework code, no abstractions — just platform primitives. For simple apps, this was elegant. As apps grew complex, the cracks appeared fast.
How it worked
// Navigate to another screen — that's it Intent intent = new Intent(this, DetailActivity.class); intent.putExtra("product_id", productId); // raw String key — no type safety startActivity(intent); // Reading the argument in DetailActivity String productId = getIntent().getStringExtra("product_id"); // Null? Wrong key? Wrong type? Runtime crash. No compile-time check. // For result — back in the old days startActivityForResult(intent, REQUEST_CODE); // deprecated in 2021 // Receive in the original activity @Override protected void onActivityResult(int reqCode, int resCode, Intent data) { if (reqCode == REQUEST_CODE && resCode == RESULT_OK) { String result = data.getStringExtra("result"); // magic string } }
The crunch point: As apps grew from 3 screens to 30, teams realized that having 30 Activity subclasses, each potentially a 1000-line God class managing its own lifecycle, state, and navigation, was becoming unmanageable. The app's navigation graph existed only in developers' heads.
& Transactions
Fragments promised modular UI and shared lifecycle. They delivered modularity — and a decade of memorable bugs.
Introduced in Android 3.0 (Honeycomb) for tablet layouts, Fragments quickly became the de-facto navigation primitive for phone apps too. The idea was sound: lightweight UI modules inside an Activity, reusable, composable, back-stack-aware. Reality was more complicated.
The FragmentManager & Transaction model
Every navigation action was a FragmentTransaction — a unit of work that adds, replaces, removes, or hides fragments, optionally saving state to the back stack. Multiple operations could be batched into one atomic transaction.
// Navigate to a detail screen supportFragmentManager .beginTransaction() .setCustomAnimations( R.anim.slide_in_right, R.anim.slide_out_left, // enter / exit R.anim.slide_in_left, R.anim.slide_out_right // popEnter / popExit ) .replace(R.id.container, DetailFragment.newInstance(productId)) .addToBackStack("detail") // name for selective popping .commit() // async — or commitNow() for sync // Passing arguments — still stringly-typed in this era companion object { fun newInstance(id: String) = DetailFragment().also { it.arguments = bundleOf("product_id" to id) } } // Receiving arguments in the Fragment val productId = requireArguments().getString("product_id") ?: error("Missing product_id argument") // still runtime crash // Pop back stack parentFragmentManager.popBackStack() // Or pop to a specific tag parentFragmentManager.popBackStack("home", 0)
"Fragments are a mistake" became a common take by 2015. The IllegalStateException: Can not perform this action after onSaveInstanceState error alone became one of the most Googled Android errors of all time. Teams started building their own navigation libraries (Conductor, Cicerone, Flow) to escape FragmentManager.
The "Single Activity" pattern
By 2016–2017, the community had converged on a best practice: one Activity per logical flow (or even per app), with Fragments as screens. This reduced overhead but made the navigation problem worse — now all navigation had to go through FragmentManager, and there was no enforced architecture for it.
// Teams invented their own routers... class Navigator(private val activity: AppCompatActivity) { fun goToDetail(productId: String) { activity.supportFragmentManager .beginTransaction() .replace(R.id.container, DetailFragment.newInstance(productId)) .addToBackStack(null) .commit() } fun goToProfile(userId: String) { /* ... */ } fun goToCheckout(cartId: String) { /* ... */ } fun goToSettings() { /* ... */ } // ... 20 more methods growing forever, no tests } // Each team had a different version of this. Each had different bugs.
The real problem revealed: Navigation is fundamentally a graph problem. FragmentManager gives you the tools to traverse a graph but no way to declare the graph itself. Every app was reimplementing graph traversal from scratch, with different bugs each time.
Component
Google finally provides the navigation graph. One XML file declares all screens, all routes, all arguments. The single source of truth teams had been hand-rolling for years.
Announced at Google I/O 2018 and released as part of Android Jetpack, the Navigation Component was a direct response to years of community pain. The core idea: declare your app's navigation as a directed graph in XML. A single NavController traverses it. No more scattered FragmentManager calls — just navigate(R.id.action_home_to_detail).
The NavGraph — navigation as data
<navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" app:startDestination="@id/homeFragment"> <fragment android:id="@+id/homeFragment" android:name="com.app.HomeFragment"> <action android:id="@+id/action_home_to_detail" app:destination="@id/detailFragment" app:enterAnim="@anim/slide_in_right" app:exitAnim="@anim/slide_out_left"/> </fragment> <fragment android:id="@+id/detailFragment" android:name="com.app.DetailFragment"> <argument android:name="productId" app:argType="string" /> <!-- Safe Args generates type-safe class --> <deepLink app:uri="https://myapp.com/product/{productId}" /> </fragment> <dialog android:id="@+id/confirmDialog" ... /> </navigation>
Safe Args — type safety at last
// Navigate with compile-time checked arguments val action = HomeFragmentDirections.actionHomeToDetail( productId = "prod_123" // generated function, typed parameter ) findNavController().navigate(action) // Read in DetailFragment — also type-safe val args: DetailFragmentArgs by navArgs() val productId: String = args.productId // compile-time guaranteed non-null String // Navigate to a bottom nav tab, clearing the stack val options = navOptions { popUpTo(R.id.homeFragment) { inclusive = false } launchSingleTop = true restoreState = true } findNavController().navigate(R.id.feedFragment, null, options) // NavController observes back stack as Flow findNavController().addOnDestinationChangedListener { _, dest, _ -> binding.toolbar.title = dest.label } // NavGraph scoped ViewModel — shared across sub-flow val checkoutVm: CheckoutViewModel by navGraphViewModels(R.id.checkout_graph)
The real win: Safe Args eliminated an entire class of runtime crashes that had plagued Android apps for years. The generated Directions and Args classes meant the compiler caught argument name typos and type mismatches that previously only surfaced in production.
Navigation 2.4 (2022): Added multiple back stack support — restoreState = true in nav options. This finally made BottomNavigationView correctly preserve state per tab. Previously, switching tabs would destroy and recreate the Fragment stack, losing scroll positions and loaded data.
Navigation
Navigation as Kotlin code. No XML, no annotation processing, no Fragments. Screens are composable functions; routes are strings or — since 2.8 — type-safe Kotlin objects.
Jetpack Compose reinvented Android UI with a declarative, functional model. Navigation had to follow. navigation-compose replaced the XML graph with a Kotlin DSL: screens are composable lambdas, registered with string routes on a NavHost. The NavController API stayed familiar, but everything underneath changed.
Compose Navigation — string routes era (2021–2024)
// NavHost defines the entire graph as a Kotlin DSL @Composable fun AppNavHost(navController: NavHostController) { NavHost(navController, startDestination = "home") { composable("home") { HomeScreen( onProductClick = { id -> navController.navigate("detail/$id") // still stringly-typed } ) } composable( route = "detail/{productId}", arguments = listOf(navArgument("productId") { type = NavType.StringType }) ) { backStackEntry -> val productId = backStackEntry.arguments?.getString("productId")!! DetailScreen(productId = productId, onBack = { navController.popBackStack() }) } composable("settings") { SettingsScreen() } navigation(startDestination = "cart", route = "checkout_flow") { composable("cart") { CartScreen() } composable("payment") { PaymentScreen() } composable("confirmation") { ConfirmationScreen() } } } }
Navigation 2.8 — Type-safe routes (2024)
Navigation 2.8 introduced type-safe routes using Kotlin Serialization. Routes are now data classes or objects — not strings. This is the approach that finally achieves what Safe Args did for XML navigation, but natively in Kotlin without code generation.
// Route definitions — plain Kotlin, fully serializable @Serializable object HomeRoute @Serializable data class DetailRoute(val productId: String) @Serializable object CheckoutGraph // nested graph route @Serializable object CartRoute // NavHost with type-safe destinations @Composable fun AppNavHost(navController: NavHostController) { NavHost(navController, startDestination = HomeRoute) { composable<HomeRoute> { HomeScreen( onProductClick = { id -> navController.navigate(DetailRoute(productId = id)) // ← type-safe! } ) } composable<DetailRoute> { backStackEntry -> val route: DetailRoute = backStackEntry.toRoute() // no getString() needed DetailScreen(productId = route.productId) // compile-time guaranteed } navigation<CheckoutGraph>(startDestination = CartRoute) { composable<CartRoute> { CartScreen() } } } } // Navigate — no strings, no magic, full IDE refactoring support navController.navigate(DetailRoute(productId = "prod_42")) navController.navigate(CheckoutGraph) navController.popBackStack<HomeRoute>(inclusive = false)
Navigation 2.8 is the endgame for type safety. Routes as serializable data classes means your IDE can rename DetailRoute.productId and every navigate call updates. No code generation, no XML, no magic strings. This is what Android navigation should have been from the start.
Passing complex types
// Custom NavType for non-primitive types @Serializable data class SearchRoute( val query: String, val filters: List<String> = emptyList(), // supported via serialization val page: Int = 0 ) // Passing nested serializable types via custom NavType @Serializable data class ProfileFilter(val showPrivate: Boolean, val sortBy: String) val ProfileFilterNavType = object : NavType<ProfileFilter>(isNullableAllowed = false) { override fun serializeAsValue(value: ProfileFilter) = Uri.encode(Json.encodeToString(value)) override fun parseValue(value: String) = Json.decodeFromString(Uri.decode(value)) override fun get(bundle: Bundle, key: String) = bundle.getString(key)?.let { parseValue(it) } override fun put(bundle: Bundle, key: String, value: ProfileFilter) = bundle.putString(key, serializeAsValue(value)) }
Every dimension, every era — the complete decision matrix for choosing a navigation approach in your Android project.
| Feature / Property | Era I · Activity | Era II · Fragment | Era III · Nav Component | Era IV · Compose Nav |
|---|---|---|---|---|
| Year introduced | 2008 | 2011 | 2018 | 2021 (2.8: 2024) |
| Type-safe arguments | None — raw Intent extras | None — raw Bundle strings | Safe Args (generated) | Type-safe routes (2.8+) |
| Visual graph | No | No | XML editor in Android Studio | Code only (no diagram) |
| Navigation declared in | Code (imperative) | Code (imperative) | XML (declarative) | Kotlin DSL (declarative) |
| Deep links | Manual Intent filters | Manual parsing | Declared in graph XML | Auto from route class |
| Back stack scoping | App-level only | Fragment back stack (manual) | NavGraph, popUpTo | NavGraph, popBackStack |
| Shared ViewModel scope | Not possible | Activity scope only | NavGraph-scoped ViewModel | navGraphViewModels |
| Fragment dependency | None | Full | Full (Fragment-backed) | None (Compose) |
| Animation support | Activity transitions | Manual XML anims | Declared in graph | Compose AnimatedContent |
| Predictive Back | Manual override | Manual override | Nav 2.7+ (partial) | Nav 2.7+ natively |
| Build time impact | None | None | Safe Args adds annotation processing | kotlinx.serialization only |
| Testability | ActivityTestRule | FragmentScenario | TestNavHostController | TestNavHostController |
| Multiple back stacks | No | Complex workarounds | Nav 2.4+ (restoreState) | Built in |
| Learning curve | Low (platform APIs) | Medium (lifecycle traps) | Medium (XML + Kotlin) | Medium (Compose required) |
| Recommended for new apps | No | No | View-based apps only | Yes — Compose apps |
The thread through every era
Every Dimension
A deep, dimension-by-dimension breakdown of all four eras — scoring, migration paths, gotchas, and a decision guide for your current project.
navigation-compose and kotlinx.serialization.id("org.jetbrains.kotlin.plugin.serialization")
id("androidx.navigation.safeargs.kotlin")
supportFragmentManager.beginTransaction()…commit() directly for navigation in 2025 is unnecessary. Nav Component wraps all of this with a safer, tested API. The only valid use is for non-navigation Fragment operations (dialogs, bottom sheets not in the nav graph).startActivity() — is the 2011 pattern. It has no type safety, no visual graph, no shared UI, and no back stack control. Only use multiple Activities for true process separation (e.g., a share extension, a lockscreen widget).