Blog Infos
Author
Published
Topics
, , , ,
Published

This is a follow-up to my previous article on Navigation 3. If you haven’t read it, you might want to check it out first here.

🤔 What is a “Scene Strategy”?

“Imagine you’re watching a dramatic Indian film. One moment the hero is crying, next moment — boom — scene changes! New background, new lighting, villain enters in slow motion. That entire switch? That’s a ‘scene’. And how that scene enters, exits, and steals the spotlight? That’s the job of a scene strategy!”

In Navigation 3, every screen (or “destination”) is treated like a scene. And how these scenes appear, disappear, and transition is controlled by something called a Scene Strategy.

Real-life Example:

Let’s say you’re navigating from a Home screen to a Details screen.

  • Should the previous screen stay in the background?
  • Should the transition feel like a alert dialog?
  • Or maybe some custom fancy transitions?

All of these are strategies! 🧠

🧍️‍⚖️ How It Works Under the Hood

Navigation 3 keeps two things separate — one is which screen you’re going to, and the other is how that screen should show up. The ‘how’ part is handled by something called a scene strategy.

There are built-in ones like:

  • SinglePaneSceneStrategy: behaves like normal full-screen transitions.
  • DialogSceneStrategy: opens a dialog-style screen.

But the real magic? You can create your own custom strategy. 🎩✨

🛠️ Creating Your Own Scene Strategy

Sometimes the default transitions just aren’t enough. Want your screen to slide in like a Bollywood hero’s entry? No problem! With Navigation 3, you can create your own SceneStrategy and control exactly how your screen appears.

SceneStrategy tells Navigation 3 how to:

  • Enter / Exit a screen.
  • Handle lifecycle events.
  • Manage backstack behavior.
The Real World Scenario (Multi Pane Layout):

Imagine you have two screens: a list screen and a detail screen. On a mobile phone in normal (portrait) mode, the list should take up the whole screen. When you tap on a list item, it should go to the detail screen. But if you turn the phone sideways (landscape), the screen should split into two parts — the list on the left side and the details on the right side.

Let’s jump right in!

Before we move ahead, we need to set up our Screens and NavDisplay. To keep things simple, we’ll reuse the same Screens and NavDisplay we built in the previous article on the Overview of Navigation 3.

@Serializable
data object NotesList: NavKey

@Serializable
data class NoteDetail(val noteId: String): NavKey

@Composable
fun TwoPaneSceneUI() {
  val backstack = rememberNavBackStack<NavKey>(NotesList)

    NavDisplay(
        backStack = backstack,
        entryProvider = { key ->
            when (key) {
                is NotesList -> NavEntry(key = key) {
                    NotesListScreen(
                        navigateToDetail = { route ->
                            if (backstack.size == 2) {
                                backstack[backstack.lastIndex] = route
                            } else {
                                backstack.add(route)
                            }
                        }
                    )
                }
                is NoteDetail -> NavEntry(key = key) {
                    NoteDetailScreen(note = key)
                }
                else -> throw IllegalArgumentException("Unknown key: $key")
            }
        },
        sceneStrategy = SinglePaneSceneStrategy(),
    )
}

On navigateToDetail callback from NotesListScreen, we are checking to add a new entry or update the existing entry depending on the number of entries we have in the backstack.

  • If one entry exist in the backstack, which means only ListScreen is displayed.
  • If two entries exists in the backstack, which means, DetailScreen is also displayed. So we just replaced the DetailScreen only.
✨ Prerequisites:

On creating a new scene strategy, we need to implement the Scene interface and SceneStrategy interface first.

  • Scene — A specific scene to render one or more NavEntry.
  • SceneStrategy — A strategy that tries to calculate a Scene given a list of NavEntry.
Scene:

 

public interface Scene<T : Any> {
    public val key: Any

    public val entries: List<NavEntry<T>>

    public val previousEntries: List<NavEntry<T>>

    public val content: @Composable () -> Unit
}

 

  • key — The key to identify the Scene.
  • entries — The list of NavEntry that can be displayed in this Scene.
  • previousEntries — The resulting NavEntry that should be computed after pressing back updates the backstack.
  • content — The content rendering the Scene itself.
SceneStrategy:

 

public fun interface SceneStrategy<T : Any> {
    
    @Composable
    public fun calculateScene(entries: List<NavEntry<T>>, onBack: (count: Int) -> Unit): Scene<T>?

    public infix fun then(sceneStrategy: SceneStrategy<T>): SceneStrategy<T> =
        SceneStrategy { entries, onBack ->
            calculateScene(entries, onBack) ?: sceneStrategy.calculateScene(entries, onBack)
        }
}

 

  • calculateScene — Given a back stack of entries, calculate whether this SceneStrategy should take on the task of rendering one or more of those entries.
  • Params:
    entries — The entries on the back stack that should be considered valid to render via a returned Scene.
    onBack — a callback that should be connected to any internal handling of system back done by the returned Scene. The passed Int should be the number of entries were popped.
Custom Two Pane Scene:

 

class TwoPaneScene<T: Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val listEntry: NavEntry<T>, // ListScreen entry
    val detailEntry: NavEntry<T> // DetailScreen entry
): Scene<T> {

    override val entries: List<NavEntry<T>>
        get() = listOf(listEntry, detailEntry)

    override val content: @Composable (() -> Unit)
        get() = {
            Row(
                modifier = Modifier
                    .fillMaxSize()
            ) {
                Box(
                    modifier = Modifier
                        .weight(0.3f)
                ) {
                    listEntry.content(listEntry.key)
                }
                Box(
                    modifier = Modifier
                        .weight(0.7f)
                ) {
                    detailEntry.content(detailEntry.key)
                }
            }
        }
}

 

  • entries — For two pane layout, we are only required to display two screens at a time. So, we have list of list and detail screen.
  • content — Design of the Scene with the two NavEntry.
Custom Two Pane Scene Strategy:

 

class TwoPaneSceneStrategy<T: Any>: SceneStrategy<T> {

    @Composable
    override fun calculateScene(
        entries: List<NavEntry<T>>,
        onBack: (Int) -> Unit
    ): Scene<T>? {

        val lastTwoEntries = entries.takeLast(2)
        val listEntry = lastTwoEntries.first()
        val detailEntry = lastTwoEntries.last()

        TwoPaneScene(
            key = listEntry.key to detailEntry.key,
            previousEntries = entries.dropLast(1),
            listEntry = listEntry,
            detailEntry = detailEntry
        )
    }
}

 

In entries, we know if we have two screens in the backstack, we can get that by using takeLast(2). Let’s assume for now, the first entry is ListScreen and last entry is DetailScreen. Now we can easily construct the TwoPaneScene.

What if we need more control in our SceneStrategy? We can configure that using metadata of NavEntry. To do that, first define companion object in SceneStrategy.

class TwoPaneSceneStrategy<T: Any>: SceneStrategy<T> {
    
    // ...
    
    companion object {
        const val TWO_PANE_KEY = "TwoPaneKey"
        fun twoPane() = mapOf(TWO_PANE_KEY to true)
    }
}

In companion object, we have defined a property and a method to use it to check for the metadata in NavEntry.

Now, we can use this property to check whether the SceneStrategy has right to adopt TwoPaneSceneStrategy or not.

// lastTwoEntries...

val hasTwoPaneKey = lastTwoEntries.all {
    it.metadata.containsKey(TWO_PANE_KEY) && it.metadata[TWO_PANE_KEY] == true
}

return if(lastTwoEntries.size == 2 && hasTwoPaneKey) {

    // list and detail entry...

    TwoPaneScene(
        key = listEntry.key to detailEntry.key,
        previousEntries = entries.dropLast(1),
        firstEntry = listEntry,
        secondEntry = detailEntry
    )
} else null
  • hasTwoPaneKey — check if the entries has a TwoPaneScene key in their metadata.

Now, do you think it will display the content as expected? No, the two pane scene strategy works no matter what, whether the screen is in portrait or landscape. So we need to check WindowSize to handle this.

🪟 Calculate Window Size:
  1. Add the dependency:
[versions]
windowsizeclass = "1.3.2"

[libraries]
androidx-compose-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "windowsizeclass" }

In the app build.gradle file.

implementation(libs.androidx.compose.material3.windowsizeclass)

To check the width of the screen, we can use currentWindowAdaptiveInfo().

val windowClass = currentWindowAdaptiveInfo().windowSizeClass

if(!windowClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
    return null
}

// lastTwoEntries...
// ...

We have checked the minimum mobile window size breakpoint, which is 600dp. If the size is less than the breakpoint, we return null. Returning null will display the default SinglePaneScene.

✌🏻 Using TwoPaneSceneStrategy in NavDisplay:

We have done everything we need to display our custom SceneStrategy. Now, the main part, how to use our TwoPaneSceneStrategy in NavDisplay? Its simple than we think.

NavDisplay(
    backStack = backstack,
    entryProvider = { key ->
        when (key) {
            is NotesList -> NavEntry(
              key = key, 
              metadata = TwoPaneSceneStrategy.twoPane() // Passing metadata
            ) {
                NotesListScreen(...)
            }
            is NoteDetail -> NavEntry(
              key = key, 
              metadata = TwoPaneSceneStrategy.twoPane()// Passing metadata
            ) {
                NoteDetailScreen(...)
            }
            else -> throw IllegalArgumentException("Unknown key: $key")
        }
    },
    sceneStrategy = TwoPaneSceneStrategy(), // Our custom TwoPaneSceneStrategy
)

In NavDisplay,

  • sceneStrategy — we need to use our custom TwoPaneSceneStrategy in the sceneStrategy parameter.
  • metadata — we need to use the two pane key in the metadata of NavEntry.
Handling BackPress:

Additionally we can configure the back press from the TwoPaneSceneStrategy. It can be done using BackHandler and passing 2 as the input on onBack callback. It will remove the last two entries from the backstack if the device is in landscape mode.

Here is the final form of our TwoPaneSceneStrategy:

class TwoPaneSceneStrategy<T: Any>: SceneStrategy<T> {

    @Composable
    override fun calculateScene(
        entries: List<NavEntry<T>>,
        onBack: (Int) -> Unit
    ): Scene<T>? {
        val windowClass = currentWindowAdaptiveInfo().windowSizeClass

        if(!windowClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        BackHandler {
            onBack(2) // we exit the two-pane layout.
        }

        val lastTwoEntries = entries.takeLast(2)
        val hasTwoPaneKey = lastTwoEntries.all {
            it.metadata.containsKey(TWO_PANE_KEY) && it.metadata[TWO_PANE_KEY] == true
        }

        return if(lastTwoEntries.size == 2 && hasTwoPaneKey) {
            val listEntry = lastTwoEntries.first()
            val detailEntry = lastTwoEntries.last()

            TwoPaneScene(
                key = listEntry.key to detailEntry.key,
                previousEntries = entries.dropLast(1),
                listEntry = listEntry,
                detailEntry = detailEntry
            )
        } else null
    }

    companion object {
        const val TWO_PANE_KEY = "TwoPaneKey"
        fun twoPane() = mapOf(TWO_PANE_KEY to true)
    }
}

If you run the app, you will see a nice transition between single pane to dual pane on orientation change like this:

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

🧠 Final Thoughts

Scene Strategy in Navigation 3 is like giving your app its own stage manager. You get to decide how every scene looks, feels, and behaves.

And the best part? It’s still Compose. Meaning it’s reactivedeclarative, and super flexible.

💬 Let’s Talk

Have you tried building your own scene strategy? Got a cool animation idea? Or just curious how to make your bottom sheet look like an iOS-style card? Drop a comment or ping me — I’d love to hear from you!

 

☕ Enjoying the content? Buy me a coffee to keep the ideas flowing!

📚 My Articles:

This article was previously published on proandroiddev.com.

Menu