Mark
02/20/2026, 2:37 AMNavigationBackHandler but I still get the predictive animation of the top-most entry. The only workaround I’ve found is to pass in only the last item in the backStack to NavDisplay. Should I be doing something with NavigationEventInfo to get this working?Mark
02/20/2026, 2:37 AM@Composable
fun <T : Any> NavDisplayWithBackOverride(
backStack: List<T>,
modifier: Modifier = Modifier,
goBack: () -> Unit,
onBackOverride: (() -> Unit)?,
entryProvider: (key: T) -> NavEntry<T>,
) {
val backStack = remember(backStack, onBackOverride == null) {
if (onBackOverride == null) {
backStack
} else {
// Only show the last (current) entry when drawer is open
// This prevents predictive back from showing the underlying navigation
backStack.takeLast(1)
}
}
val navState = rememberNavigationEventState(NavigationEventInfo.None)
NavigationBackHandler(
state = navState,
isBackEnabled = onBackOverride != null,
onBackCompleted = onBackOverride ?: {},
)
NavDisplay(
backStack = backStack,
modifier = modifier,
onBack = goBack,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider,
)
}Ian Lake
02/20/2026, 3:24 AMNavigationBackHandler after the NavDisplay if you want it to take precedenceIan Lake
02/20/2026, 3:26 AMIan Lake
02/20/2026, 3:28 AMMark
02/20/2026, 3:28 AMIan Lake
02/20/2026, 3:29 AMMark
02/20/2026, 3:30 AMIan Lake
02/20/2026, 3:33 AMMark
02/20/2026, 3:49 AMIan Lake
02/20/2026, 3:50 AMMark
02/20/2026, 3:51 AMIan Lake
02/20/2026, 3:56 AMIan Lake
02/20/2026, 3:58 AMbackStack and onBack, you'll see it just does one thing before calling into the next overload: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigati[…]commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt;l=336
val entries =
rememberDecoratedNavEntries(
backStack = backStack,
entryDecorators = entryDecorators,
entryProvider = entryProvider,
)
NavDisplay(
entries,
...
)Ian Lake
02/20/2026, 4:00 AMval sceneState =
rememberSceneState(
entries,
sceneStrategies,
sceneDecoratorStrategies,
sharedTransitionScope,
onBack,
)
val scene = sceneState.currentScene
// Predictive Back Handling
val currentInfo = SceneInfo(scene)
val previousSceneInfos = sceneState.previousScenes.map { SceneInfo(it) }
val gestureState =
rememberNavigationEventState(currentInfo = currentInfo, backInfo = previousSceneInfos)
NavigationBackHandler(
state = gestureState,
isBackEnabled = scene.previousEntries.isNotEmpty(),
onBackCompleted = {
// If `enabled` becomes stale (e.g., it was set to false but a gesture was
// dispatched in the same frame), this may result in no entries being popped
// due to entries.size being smaller than scene.previousEntries.size
// but that's preferable to crashing with an IndexOutOfBoundsException
repeat(entries.size - scene.previousEntries.size) { onBack() }
},
)
NavDisplay(
sceneState,
gestureState,
...
)Ian Lake
02/20/2026, 4:00 AMIan Lake
02/20/2026, 4:02 AMNavDisplay doesn't do any back handling at all - it is just the display part and doesn't handle back at allIan Lake
02/20/2026, 4:03 AMisBackEnabled = !isPaneOpen || scene.previousEntries.isNotEmpty()Ian Lake
02/20/2026, 4:05 AMNavigationBackHandler? You probably want to disable every back handler in that entire second entry in your Column when the pane is open?Ian Lake
02/20/2026, 4:06 AMrememberNavigationEventDispatcherOwnerMark
02/20/2026, 4:06 AMMark
02/20/2026, 4:07 AMIan Lake
02/20/2026, 4:08 AMval navDisplayPaneOwner = rememberNavigationEventDispatcherOwner(enabled = !isPaneOpen)
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides navDisplayPaneOwner) {
YourNavDisplayPane()
}Ian Lake
02/20/2026, 4:09 AMenabled property changes everything inside the CompositionLocalProvider, whether that is NavDisplay itself or something inside one of the screens inside the NavDisplayIan Lake
02/20/2026, 4:09 AMisPaneOpen out of your first pane, but then you'll guarantee only the pane you want is intercepting back at a timeIan Lake
02/20/2026, 4:10 AMMark
02/20/2026, 4:11 AMIan Lake
02/20/2026, 4:11 AMNavigationBackHandler inside of itIan Lake
02/20/2026, 4:12 AMMark
02/20/2026, 4:13 AMCompositionLocalProvider will also override that. But I think this is relatively easy to trackIan Lake
02/20/2026, 4:13 AMrememberNavigationEventDispatcherOwner hooks up to the parent navigationEventDispatcherOwner, so it is a tree of owners, hence why all of them need to be enabled to have it filter down the treeIan Lake
02/20/2026, 4:14 AMMark
02/20/2026, 4:15 AMIan Lake
02/20/2026, 4:18 AMrememberNavigationEventState that your first screen uses to close the pane is also what gives you the transitionState that lets you animate, see the example that Material3 uses: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/[…]aterial3/adaptive/navigation3/ThreePaneScaffoldScene.kt;l=230Mark
02/20/2026, 6:21 AMif (drawerState.isOpen) {
NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) {
scope.launch {
drawerState.close()
}
}
}
val navDrawerEventDispatcherOwner = rememberNavigationEventDispatcherOwner(enabled = !drawerState.isOpen)
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides navDrawerEventDispatcherOwner) {
ModalNavigationDrawer(...)
}
and so it probably makes sense to declare something like:
@Composable
fun OverrideBackHandler(
enabled: Boolean,
onBack: () -> Unit,
content: @Composable () -> Unit,
) {
if (enabled) {
NavigationBackHandler(
state = rememberNavigationEventState(NavigationEventInfo.None),
onBackCompleted = onBack,
)
}
val navDrawerEventDispatcherOwner = rememberNavigationEventDispatcherOwner(enabled = !enabled)
CompositionLocalProvider(
value = LocalNavigationEventDispatcherOwner provides navDrawerEventDispatcherOwner,
content = content,
)
}
and then:
OverrideBackHandler(
enabled = drawerState.isOpen,
onBack = {
scope.launch {
drawerState.close()
}
},
) {
ModalNavigationDrawer(...)
}Ian Lake
02/20/2026, 6:57 AMIan Lake
02/20/2026, 6:58 AMMark
02/20/2026, 7:03 AMMark
02/20/2026, 7:10 AMModalBottomSheet and indeed that uses the eventDispatcherOwner we talked about (albeit without using CompositionLocalProvider) via `Dialog`:
val navigationEventDispatcher =
requireNotNull(findDefaultNavigationEventDispatcherOwner()) {
error("NavigationEventDispatcherOwner not found")
}.navigationEventDispatcher
DisposableEffect(navigationEventDispatcher) {
navigationEventDispatcher.addHandler(onBackHandler)
onDispose { onBackHandler.remove() }
}
but the ModalNavigationDrawer doesn’tMark
02/24/2026, 4:24 AMModalNavigationDrawer is being replaced by the ModalWideNavigationRail which explains why it doesn’t support proper back handling in nav 3.