Skip to main content

Article

Charting a Course from Android Compose Nav2 to Nav3: Rework Bottom Sheet Navigation Destinations

Lucas Kivi

July 27, 2026

A toucan is reading a treasure map.
This is a part of a larger guide for migrating from Android Compose Navigation 2 to Navigation 3. Checkout the parent guide for an overview of the migration.

Bottom sheet destinations improved during the move from Nav2 to Nav3. The change stays true to the theme of more explicit control: we are given complete control over bottom sheet destinations, because they require you to create them entirely on your own. Google has provided a destination protocol consisting of a Scene and aSceneStrategy, which enables you to build the bottom sheet destination of your dreams.

Scene Introduction

The Scene is the fundamental unit in Navigation 3 that renders one or more NavEntry instances. Its sole responsibility is laying out entries.

The SceneStrategy is the mechanism that determines how a given list of NavEntry instances from the back stack should be arranged or transitioned into a Scene. It makes two main decisions:

  • It checks if a scene can be made from a provided set of entries
  • And if it can, it chooses the entries to pass to the Scene
I will not go into these very deeply; if you want to read more check out these docs .

Here is the simplest implementation of the protocol that comes out-of-the-box — a no-frills, single screen destination:

/**
* A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
* list.
*/
public class SinglePaneSceneStrategy<T : Any> : SceneStrategy<T> {

override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T> {
return SinglePaneScene(
key = entries.last().contentKey,
entry = entries.last(),
previousEntries = entries.dropLast(1),
)
}
}

This strategy simply chooses the last entry and the previous entries and passes them off to the Scene in accordance with its constructor. Then the Scene just delegates to the Entrys content.

internal data class SinglePaneScene<T : Any>(
override val key: Any,
val entry: NavEntry<T>,
override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {
override val entries: List<NavEntry<T>> = listOf(entry)

override val content: @Composable () -> Unit = { entry.Content() }

// equals(), hashCode(), and toString() overrides...
}

Now that we have seen the basic pattern for how a NavEntry enters the display, we can go into creating a bottom sheet Scene.

Bottom Sheets

Google gives us a hint at how to build bottom sheet destinations via a Nav3 recipe . This instructs you in cooking up a bottom sheet Scene with ModalBottomSheet behavior built in. This is the Scene they use:

internal data class BottomSheetScene<T : Any>(
override val key: T,
override val previousEntries: List<NavEntry<T>>,
override val overlaidEntries: List<NavEntry<T>>,
private val entry: NavEntry<T>,
private val modalBottomSheetProperties: ModalBottomSheetProperties,
private val onBack: () -> Unit,
) : OverlayScene<T> {

override val entries: List<NavEntry<T>> = listOf(entry)

override val content: @Composable (() -> Unit) = {
val lifecycleOwner = rememberLifecycleOwner()
ModalBottomSheet(
onDismissRequest = onBack,
properties = modalBottomSheetProperties,
) {
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
entry.Content()
}
}
}
}

BottomSheetScene implements an out-of-the-box interface called OverlayScene. Implementing an OverlayScene allows you to handle laying out your bottom sheet while it assures overlaidEntries are passed off to another Scene that will be laid out in the background.

Also, you can see that it builds a ModalBottomSheet directly within the Scene! This is my main point of concern. If we configure the ModalBottomSheet in the navigation layer, we can only manipulate its state from the “navigation host”. I want to be able to control the constructor in my screen’s definition so I can intercept the onDismissRequest, sheetState, or whatever else I want.

Here is a little example:

val coroutineScope = rememberCoroutineScope()
val sheetState: SheetState = rememberModalBottomSheetState()
val hideSheet: () -> Unit = { scope.launch { sheetState.hide() } }

ModalBottomSheet(
onDismissRequest = { /* Go to the ViewModel. */ },
sheetState = sheetState,
) { ... }

So what I really want from my “bottom sheet destination” is basic overlay behavior — I want my destination to manage the previous entry (overlaidEntries) and provide me a window to build to my liking! So let’s break down what you actually need to do to achieve this.

Nuts and Bolts

To do this we define our Scene.

/**
* An [OverlayScene] that renders [topEntry] over [previousEntries].
*/
internal data class SimpleOverlayScene<T : Any>(
override val key: Any,
override val previousEntries: List<NavEntry<T>>,
private val topEntry: NavEntry<T>,
) : OverlayScene<T> {
override val entries: List<NavEntry<T>> = listOf(topEntry)
override val overlaidEntries: List<NavEntry<T>> = previousEntries
override val content: @Composable (() -> Unit) = topEntry::Content
}

This implementation takes a single NavEntry, sets the topEntry’s content directly, and sets the overlaidEntries to all previousEntries. This means topEntry’s content overlays all previous entries. The main difference from the recipe’s implementation is it never handles creating an actual bottom sheet.

Next, we need to define our SceneStrategy:

/**
* A [SceneStrategy] that displays entries tagged by [register] as a simple overlay scene, delegating
* all UI to the entry's own content.
*/
class SimpleOverlaySceneStrategy<T : Any> : SceneStrategy<T> {

override fun SceneStrategyScope<T>.calculateScene(
entries: List<NavEntry<T>>,
): Scene<T>? =
entries
.lastOrNull()
?.takeIf { topEntry -> SceneKey in topEntry.metadata }
?.let { topEntry ->
SimpleOverlaySceneImpl(
key = topEntry.contentKey,
previousEntries = entries.dropLast(1),
topEntry = topEntry,
)
}

companion object {
/**
* Call this on [NavEntry.metadata] to mark an entry as one that should be displayed by
* [SimpleOverlaySceneStrategy].
*/
fun register(): Map<String, Any> = mapOf(SceneKey to Unit)

const val SceneKey = "SimpleOverlaySceneStrategy.SceneKey"
}
}

As you can see in SimpleOverlaySceneStrategy.calculateScene(…), in order to activate the scene strategy the topEntry’s metadata must contain the correct key.

        entries
.lastOrNull()
?.takeIf { topEntry -> SceneKey in topEntry.metadata }
?.let { ... }

If the key is found the SimpleOverlayScene is created, otherwise it returns null and the NavDisplay continues looking for a SceneStrategy that can handle the entries of concern.

We add this metadata to an entry in its declaration.

entry<Route.FooSheet>(
metadata = SimpleOverlaySceneStrategy.register(),
) { route ->
FooSheet(...)
}

I have come to like defining a register() method like this in the SceneStrategy’s companion object. Creating a Map in the definition of the entry just feels a bit clumsy — the register() abstraction is an idiomatic and reusable registration process.

Then to enable the SceneStrategy for a given NavDisplay you must supply it at the call site in some way.

val sceneStrategies = listOf(
SimpleOverlaySceneStrategy(),
...,
SinglePaneSceneStrategy(),
)
NavDisplay(
sceneStrategies = sceneStrategies,
...,
)

There are multiple NavDisplay overloads so keep in mind that this is just one example, but the basic concept is the same across all overloads.

One important thing to consider is that sceneStrategies ordering matters! Their corresponding calculateScene methods are calculated in order. So the first strategy that claims an entry wins! Most apps will have SinglePaneSceneStrategy in their list and that serves as a catch-all!

Before

In Nav2 bottom sheet navigation was pretty poorly supported so you had one of two options:

  • Declare bottom sheet destinations via the Material Navigation NavGraphBuilder.bottomSheet(...) function
  • Manage sheets manually, totally separately from the nav component

Neither of these options were perfect, and the latter was the only way to get complete control of the dialog used for the bottom sheet.

After

In Nav3 the preferred model is:

  • Implement an OverlayScene and a corresponding SceneStrategy that uses it
  • In your implementation, support the type of bottom sheet destination your heart desires
  • Add some metadata to the bottom sheet entry that corresponds to the OverlayScene
  • Declare the new SceneStrategy in the NavDisplay

This takes advantage of the amazing control afforded by Nav3 and gives us explicit control of how the bottom sheet destination behaves — how refreshing!

Migration guidance

As a rule of thumb during the migration:

If a you want a bottom sheet navigation destination, implement an OverlayScene and employ it via a SceneStrategy, and then provide it to the NavDisplay.

Maybe eventually Nav3 will make some out of-the-box “materialy” bottom sheet SceneStrategys but that is not necessary. Take advantage of the explicit control you are granted with Nav3’s beautiful scene APIs.

Lucas creates strategic scenes at Livefront.