Sitemap

Custom Overlay Navigator for Jetpack Navigation 2

3 min readSep 11, 2026

--

Press enter or click to view image in full size
Photo by Anastasia Petrova on Unsplash

I recently ran into an interesting navigation problem while working with Jetpack Compose Navigation 2.

I had a campaign flow that needed to:

• Support deep links
• Run API-driven logic before deciding which popup to show
• Navigate from anywhere
• Preserve its state while the user is doing mobile number verification
• Resume and call the API again when the user navigates back

So I needed a transparent destination(trampoline) to decide what comes next.

A normal composable destination wasn’t a good fit. When the trampoline destination became the current destination, the existing screen underneath it disappeared. While the API was ongoing, users would see a blank white screen, which wasn’t acceptable from a UX perspective.

I considered using a Dialog destination, but DialogNavigator automatically pops its destinations when navigating to a normal composable destination due to FloatingWindow. In my case, I needed the flow to remain in the back stack and become active again when the user navigates back.

So I looked deeper into how AndroidX Navigation handles FloatingWindow destinations.

I created a custom OverlayNavigator based on the FloatingWindow concept.

The architecture looks roughly like this:

NavController
→ Normal Compose destinations
→ OverlayNavigator
→ OverlayHost

Get Htoo Kyaw’s stories in your inbox

Join Medium for free to get updates from this writer.

My destination implements FloatingWindow, allowing the existing NavHost content to remain underneath.

@Navigator.Name("overlay")
class OverlayNavigator : Navigator<OverlayNavigator.Destination>() {

/** Get the back stack from the [state]. */
internal val backStack
get() = state.backStack

override fun createDestination(): Destination {
return Destination(this) {}
}

override fun navigate(
entries: List<NavBackStackEntry>,
navOptions: NavOptions?,
navigatorExtras: Extras?
) {
entries.forEach { entry ->
state.push(entry)
}
}

override fun popBackStack(
popUpTo: NavBackStackEntry,
savedState: Boolean
) {
if (state.isNavigating) {
return
}

state.pop(popUpTo, savedState)
}

@NavDestination.ClassType(Composable::class)
class Destination(
navigator: OverlayNavigator,
internal val content: @Composable (NavBackStackEntry) -> Unit
) : NavDestination(navigator), FloatingWindow

internal companion object {
internal const val NAME = "overlay"
}
}

OverlayHost separately observes the NavController’s current back stack entry and renders the overlay only when its entry is the current destination.

@Composable
fun OverlayHost(
navController: NavHostController,
) {
val navigator =
navController.navigatorProvider[OverlayNavigator.NAME] as? OverlayNavigator ?: return

val saveableStateHolder = rememberSaveableStateHolder()
val currentEntry by navController.currentBackStackEntryAsState()
val overlayEntry = navigator.backStack
.collectAsState()
.value
.lastOrNull()

if (currentEntry == overlayEntry) {
overlayEntry?.let { backStackEntry ->
val destination = backStackEntry.destination as OverlayNavigator.Destination

backStackEntry.LocalOwnersProvider(saveableStateHolder) {
destination.content(backStackEntry)
}
}
}
}

The interesting part was handling the FloatingWindow contract.

When Navigation moves from the overlay to another normal destination, NavController automatically asks my OverlayNavigator to pop its destination.

But I didn’t want to actually remove the destination. I wanted it to remain in the navigator’s back stack so it could become active again when the user navigated back.

I found that NavigatorState.isNavigating lets my navigator distinguish this floating window cleanup from an explicit popBackStack():

override fun popBackStack(
popUpTo: NavBackStackEntry,
savedState: Boolean
) {
if (state.isNavigating) {
return
}

state.pop(popUpTo, savedState)
}

This gives the flow the behavior I needed:

Rewards
→ Overlay Flow
→ Details

The overlay becomes hidden while Details is active, but it remains in the navigation state.

Then:

Details
→ Back

The overlay becomes active again. And when the flow is explicitly completed or dismissed, it is actually popped.

Jetpack Navigation’s Navigator class is quite extensible. Sometimes the best solution is to understand its internals and introduce a small custom navigator that matches the application’s navigation semantics.

Thanks for reading!

--

--