Two navigation systems can share one back stack, which is the only reason this works

Navigation Compose and the fragment navigator can coexist in one NavHost via the fragment interop artifact. That shared back stack is what makes an incremental screen-by-screen migration possible.

6 min read
androidcomposekotlininterop

Day 91 — Two systems, one shared back stack

Day 91 of 100, closing the interop pillar. Navigation is the last thing most migrations touch, because it's the one piece every screen depends on.

The symptom

A half-migrated app with two back stacks:

// Legacy screens
findNavController().navigate(R.id.action_orders_to_detail)

// New Compose screens
composeNavController.navigate("detail/$id")

Two navigation controllers, each with its own history. Pressing back from a Compose screen reached from a fragment pops the Compose stack, finds it empty, and exits the app — skipping every fragment behind it.

Deep links reach one system or the other and can't cross. popBackStack to a screen in the other system isn't expressible.

Why the obvious fix fails

The obvious fix is to coordinate them — track which system is active, forward back presses, synchronise the two histories.

That's a state machine over two stacks, and it has to handle every transition: fragment to compose, compose to fragment, deep link into either, process death restoring both. Each case is a special case, and the bugs are all in the transitions.

The other obvious approach — migrate all navigation at once — means every screen must be Compose before any of it works. For an app of any size that's a branch that lives for months.

One NavHost, one back stack, two kinds of destination

The actual mechanism

androidx.navigation:navigation-compose and the fragment navigator can share a single NavController. A composable destination and a fragment destination sit in one graph, one back stack:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val navHostFragment = supportFragmentManager
            .findFragmentById(R.id.nav_host) as NavHostFragment
        navHostFragment.navController.graph = navHostFragment.createGraph(
            startDestination = "orders",
        ) {
            composable("orders") { OrderListScreen(onSelect = { … }) }   // Compose
            fragment<LegacyDetailFragment>("detail/{id}")                // Fragment
            composable("settings") { SettingsScreen() }                  // Compose
        }
    }
}

Back works. Deep links work. popBackStack("orders", inclusive = false) works across both kinds of destination, because there is only one stack.

That single property is what makes an incremental migration viable: a screen can be converted from fragment<…> to composable(…) in isolation, and nothing else in the graph changes.

Type-safe routes

Current Navigation Compose supports serializable route types rather than strings, which removes the argument-parsing that string routes needed:

@Serializable data class OrderDetail(val id: String)
@Serializable data object Orders

NavHost(navController, startDestination = Orders) {
    composable<Orders> { OrderListScreen(onSelect = { navController.navigate(OrderDetail(it)) }) }
    composable<OrderDetail> { entry ->
        val route: OrderDetail = entry.toRoute()
        OrderDetailScreen(route.id)
    }
}

The compiler now checks that a navigation call supplies the right arguments — a navigate(OrderDetail()) with a missing id doesn't build. For a new graph this is the form to use; for a migration, string routes interoperate with the fragment navigator more straightforwardly, so mixing is common mid-flight.

What to migrate, in what order

Three rules that keep the intermediate states sane:

Move to a single NavController first, before converting any screen. If the app has several controllers — one per activity, or a manual fragment-transaction system — unifying them is a separate, self-contained change. Doing it at the same time as a screen conversion confuses two failure modes.

Convert leaf destinations first. A screen nothing navigates through — a detail view, a settings page. Its conversion touches one graph entry.

Convert the host last. Moving from NavHostFragment in XML to a Compose NavHost is the final step, and it's only safe once every destination is a composable.

The pieces that need attention

ViewModel scoping. A composable destination gets its own NavBackStackEntry as a ViewModel store owner, so viewModel() inside it is scoped to that destination — which is what you want, and different from a fragment's scoping in ways that matter for shared ViewModels. For state shared across destinations, scope to the parent graph entry explicitly:

val parentEntry = remember(entry) { navController.getBackStackEntry("checkout-graph") }
val sharedViewModel: CheckoutViewModel = viewModel(parentEntry)

Results. The fragment world's setFragmentResult has no Compose equivalent, and the idiomatic replacement is the previous entry's SavedStateHandle:

navController.previousBackStackEntry
    ?.savedStateHandle
    ?.set("selected-address", addressId)
navController.popBackStack()

Better still, hoist the state so no result-passing is needed — a shared ViewModel at the graph level, which is Day 12's argument arriving in navigation.

Transitions. composable takes enterTransition/exitTransition per destination, which replaces XML animation resources. Day 65's shared elements work across destinations here, since each composable scope is an AnimatedVisibilityScope.

Deep links. deepLink = listOf(navDeepLink { uriPattern = "app://orders/{id}" }) on a composable, and they resolve into the same graph as the fragment ones. Worth auditing during the migration rather than after — a deep link pointing at a destination that changed kind is a silent failure until a marketing campaign uses it.

What not to do

Don't pass the NavController down. Day 15's argument: it's a dependency, not an ambient. Hoist a callback instead:

composable("orders") {
    OrderListScreen(onSelect = { id -> navController.navigate("detail/$id") })
}

OrderListScreen is then previewable with {} and testable with a lambda. A screen taking a NavController is neither.

Don't build a parallel navigation abstraction during the migration. A wrapper that "makes both systems look the same" is a third system to maintain and delete later. The exception is a thin sealed type describing destinations — that's data, not a navigation system, and it survives the migration intact.

How to prove it

The shared-back-stack property is the thing to verify, and it's one manual test:

Navigate fragment → compose → fragment → compose, then press back four times. You should land back where you started, in order, and the fifth press should exit. Anything else means two stacks.

Process death is the second: navigate three deep, background the app, kill the process from the profiler, and reopen from Recents. The stack should restore — which it will, because NavController saves it, and won't if you've bypassed it with manual transactions.

Deep links are the third, and they're the check most likely to find something:

adb shell am start -W -a android.intent.action.VIEW -d "app://orders/4711"

Fired at a Compose destination and at a fragment destination, both should land correctly with a sensible back stack behind them — not a single screen with nothing to go back to.

What this generalizes to

The interop pillar's conclusion: an incremental migration needs one thing that both sides agree on. Here it's the back stack; in Day 88 it was the theme; in Day 90 it was stable identity. Wherever the two frameworks share a single source of truth, screens can move one at a time. Wherever they don't, you get two of something and a synchronisation problem.

That's the question worth asking before any framework migration: what is the shared spine, and does the new framework plug into it? If there is one, the migration is a sequence of small changes. If there isn't, it's a rewrite regardless of how it's scheduled.

Four days of interop reduce to that, plus its corollary: the mechanics are always easier than they look, and the sequencing is always harder. ComposeView is one class, AndroidView is three lambdas, a LazyColumn port is a lambda — and the decisions about which seam, which order, and what stays shared are what determine whether the migration finishes.

Tomorrow, Day 92 opens the tools pillar.


Day 91 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Navigation interop.