Nav3: fix Lifecycle for overlaid nav keys

This is a post in a series on how I tackled Nav3 concepts and wired things together.

Dialogs are modeled as an overlay scene; the destination behind the screen must remain rendered. Right now, its lifecycle stays resumed. That creates a number of practical challenges and problems:

  • You cannot react to lifecycle changes — for example, to stop playing a video or animation.
  • You cannot limit result collection to wait until the dialog is closed.
  • dropUnlessResumed does not work because the underlying destination remains resumed.

The last issue caught me off guard. dropUnlessResumed is meant to prevent repeated navigation clicks — in other words, to avoid opening the same destination (a dialog in my case) twice.

Button(onClick = dropUnlessResumed { onNavigate(DialogNavKey) }) {
     Text("Open dialog")
}

Having two navigation keys with the same contentKey on your back stack is extremely dangerous and can lead to bugs. Preventing this with dropUnlessResumed is a good starting point, but you can also implement additional safeguards in your navigation logic.

If it does happen, the dialogs are not rendered at all — only their scrim is visible. If your dialogs prevent dismissal by tapping on the scrim (outside) and also block the back gesture, there is suddenly no way back, and the user becomes stuck in this void.

So, we need to limit the lifecycle of the overlaid destination ourselves. NavEntryDecorator is a great place to do this, although we have to rely on a few quirks:

  • Pass our back stack (NavigationState in my case).
  • Rely on the default contentKey computation — the toString() method.
internal class MaxLifecycleLimiterNavEntryDecorator<T : Any>(
    navigationState: NavigationState,
) :
    NavEntryDecorator<T>(
        onPop = {},
        decorate = { navEntry ->
            val maxLifecycle by remember {
                derivedStateOf {
                    val isOnTop = navEntry.contentKey == navigationState.currentStack.lastOrNull()?.toString()
                    when (isOnTop) {
                        true -> Lifecycle.State.RESUMED
                        false -> Lifecycle.State.STARTED
                    }
                }
            }
            val lifecycleOwner = rememberLifecycleOwner(maxLifecycle)
            CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
                navEntry.Content()
            }
        },
    )

@Composable
fun <T : Any> rememberMaxLifecycleLimiterNavEntryDecorator(
    navigationState: NavigationState,
): NavEntryDecorator<T> =
    remember(navigationState) {
        MaxLifecycleLimiterNavEntryDecorator(navigationState)
    }

Use the rememberMaxLifecycleLimiterNavEntryDecorator() to create decorators for your NavDisplay:

val entryDecorators = listOf<NavEntryDecorator<NavKey>>(
     rememberSaveableStateHolderNavEntryDecorator(),
     rememberViewModelStoreNavEntryDecorator(),
     rememberMaxLifecycleLimiterNavEntryDecorator(navigationState),
)

I reported this issue, and I expect it will be fixed within Nav3 1.1’s alphas.