nav 3 (CMP, but testing on Android): how are you s...
# compose-android
m
nav 3 (CMP, but testing on Android): how are you supposed to override back handling? I tried using
NavigationBackHandler
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?
Copy code
@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,
    )
}
i
Order matters: put your
NavigationBackHandler
after the NavDisplay if you want it to take precedence
But you don't want to be doing this kind of 'just-in-time' logic at all
You need to know ahead of time what back needs to do, so whatever logic is in your onBackOverride needs to be the logic to enable your back handler in the first place
m
I have a composable where a pane is conditionally shown (based on ui state) and, when shown, I want back to dismiss that pane. So it’s not part of the backstack.
i
Then that pane should have its own NavigationBackHandler and the pane should be after your NavDisplay. Then you don't need any of this
m
Are there tricks to this? For example, if I have a column where the first item is the pane, and the second is the NavDisplay
i
you mean the first item in the column is what conditionally shows the pane?
m
right
i
And in your case, the pane is completely contained within that first item and not actually an overlay over the entire column?
m
Right, in portrait it’s like that. In landscape/tablet it might be somewhere else.
i
Gotcha. So the nice part is that NavDisplay's back handling is totally optional - that's exactly why there are multiple overloads to NavDisplay - each one gives you one more layer of control. That's exactly what our KotlinConf talk this year is about actually: https://kotlinconf.com/schedule/?day=2026-05-21&amp;session=6ebc6d2c-5752-5647-be3d-ce633d0ce687
So if you look at the NavDisplay that takes a
backStack
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
Copy code
val entries =
    rememberDecoratedNavEntries(
        backStack = backStack,
        entryDecorators = entryDecorators,
        entryProvider = entryProvider,
    )

NavDisplay(
  entries,
  ...
)
And what does that next layer do? Well, it sets up the predictive back, 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=541
Copy code
val 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,
    ...
)
(one thing we're going to be doing in a future version of Nav3 is to make this more of a two liner than the ~6 method calls it is now)
👍🏾 1
That last overload of
NavDisplay
doesn't do any back handling at all - it is just the display part and doesn't handle back at all
So you can do a lot just by picking the right layer - you could use that later layer and just use
isBackEnabled = !isPaneOpen || scene.previousEntries.isNotEmpty()
But thinking out loud, there's one other problem: what if the current screen has its own
NavigationBackHandler
? You probably want to disable every back handler in that entire second entry in your Column when the pane is open?
There's an API for that too, which would make this actually even easier where you don't have to touch your NavDisplay pane at all -
rememberNavigationEventDispatcherOwner
thank you color 1
m
Yes, it’s similar to having a NavDrawer (not on backstack) where it takes precedence.
So I was using my NavDisplayWithBackOverride to handle both these cases together
i
Well, your override doesn't change the whole subtree. Instead, you write code like:
Copy code
val navDisplayPaneOwner = rememberNavigationEventDispatcherOwner(enabled = !isPaneOpen)
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides navDisplayPaneOwner) {
  YourNavDisplayPane()
}
That single
enabled
property changes everything inside the
CompositionLocalProvider
, whether that is
NavDisplay
itself or something inside one of the screens inside the
NavDisplay
You still need to hoist the
isPaneOpen
out of your first pane, but then you'll guarantee only the pane you want is intercepting back at a time
In retrospect, I should have led with that suggestion lol
m
So this is like a higher priority NavigationBackHandler? But then I suppose same case of it can only really be used once in the hierarchy or you have the same problem again
i
it controls the entire subtree and any
NavigationBackHandler
inside of it
you can use it at multiple levels, but if a higher level is disabled, a lower level saying 'enabled' isn't going to do anything
m
Yes, although a nested call to
CompositionLocalProvider
will also override that. But I think this is relatively easy to track
i
The
rememberNavigationEventDispatcherOwner
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 tree
Adding more steps along the way doesn't change the parent/child relationships
m
This is all very good (I really like the API BTW), and thanks very much for taking the time to explain. I’m just left with wondering how to animate this pane. Should I try to make use of the same technique used by NavDisplay? or just general animations?
i
The
rememberNavigationEventState
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=230
thank you color 1
m
If I’m doing this correctly, it seems that whenever I set the NavigationEventDispatcherOwner I also set the NavigationBackHandler. For example, to handle the Nav drawer back situation:
Copy code
if (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:
Copy code
@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:
Copy code
OverrideBackHandler(
    enabled = drawerState.isOpen,
    onBack = {
        scope.launch {
            drawerState.close()
        }
    },
) {
    ModalNavigationDrawer(...)
}
i
I don't know what you are doing here either, since ModalBottomSheet supports handling back correctly out of the box if you pass it your drawerState. You shouldn't be doing anything custom if you are using that component.
It seems like you are almost doing too much and confusing yourself, rather than doing the minimum necessary?
m
If I don’t do this workaround, and the drawer is open, then back gesture will show predictive animation on underlying screen. I asked this question a few weeks back, and the recommendation was to include the open nav drawer on the back stack.
You mentioned
ModalBottomSheet
and indeed that uses the eventDispatcherOwner we talked about (albeit without using
CompositionLocalProvider
) via `Dialog`:
Copy code
val navigationEventDispatcher =
        requireNotNull(findDefaultNavigationEventDispatcherOwner()) {
            error("NavigationEventDispatcherOwner not found")
        }.navigationEventDispatcher
    DisposableEffect(navigationEventDispatcher) {
        navigationEventDispatcher.addHandler(onBackHandler)
        onDispose { onBackHandler.remove() }
    }
but the
ModalNavigationDrawer
doesn’t
Looks like the
ModalNavigationDrawer
is being replaced by the
ModalWideNavigationRail
which explains why it doesn’t support proper back handling in nav 3.