skill: add Android skills

This commit is contained in:
2026-07-14 10:34:54 +08:00
parent e260c02629
commit ab82fb7cf0
182 changed files with 62992 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
Navigation 3 is a new navigation library designed to work with Compose. With
Navigation 3, you have full control over your back stack, and navigating to and
from destinations is as simple as adding and removing items from a list. It
creates a flexible app navigation system by providing:
- Conventions for modeling a back stack, where each entry on the back stack represents content that the user has navigated to
- A UI that automatically updates with back stack changes (including animations)
- A scope for items in the back stack, allowing state to be retained while an item is in the back stack
- An adaptive layout system that allows multiple destinations to be displayed at the same time, and allowing seamless switching between those layouts
- A mechanism for content to communicate with its parent layout (metadata)
At a high level, you implement Navigation 3 in the following ways:
1. Define the content that users can navigate to in your app, each with a unique key, and add a function to resolve that key to the content. See [Resolve keys
to content](https://developer.android.com/guide/navigation/navigation-3/basics#resolve-keys).
2. Create a back stack that keys are pushed onto and removed as users navigate your app. See [Create a back stack](https://developer.android.com/guide/navigation/navigation-3/basics#create-back).
3. Use a [`NavDisplay`](https://developer.android.com/reference/kotlin/androidx/navigation3/ui/NavDisplay.composable) to display your app's back stack. Whenever the back stack changes, it updates the UI to display relevant content. See [Display
the back stack](https://developer.android.com/guide/navigation/navigation-3/basics#display-back).
4. Modify `NavDisplay`'s [scene strategies](https://developer.android.com/guide/navigation/navigation-3/custom-layouts) as needed to support adaptive layouts and different platforms.
You can see the [full source code](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation3/) for Navigation 3 on AOSP.
## Improvements upon Jetpack Navigation
Navigation 3 improves upon the original Jetpack Navigation API in the following
ways:
- Provides a simpler integration with Compose
- Offers you full control of the back stack
- Makes it possible to create layouts that can read more than one destination from the back stack at the same time, allowing them to adapt to changes in window size and other inputs.
Read more about Navigation 3's principles and API design choices in [this blog
post](https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html).
## Code samples
The [recipes repository](https://github.com/android/nav3-recipes) contains examples of how to use the
Navigation 3 building blocks to solve common navigation challenges.

View File

@@ -0,0 +1,498 @@
To migrate your app from [Navigation 2](https://developer.android.com/guide/navigation) to Navigation 3, follow these steps:
1. Add the Navigation 3 dependencies.
2. Update your navigation routes to implement the `NavKey` interface.
3. Create classes to hold and modify your navigation state.
4. Replace `NavController` with these classes.
5. Move your destinations from `NavHost`'s `NavGraph` into an `entryProvider`.
6. Replace `NavHost` with `NavDisplay`.
7. Remove Navigation 2 dependencies.
> [!IMPORTANT]
> **Important:** We released an agent skill to help you install and migrate to Jetpack Navigation 3. Try out the skill from the [Android skills repository](https://github.com/android/skills).
<br />
## AI Prompt
### Migrate from Navigation 2 to Navigation 3
This prompt will use this guide to migrate to navigation 3.
Migrate from Navigation 2 to Navigation 3 using the official
migration guide.
### Using AI prompts
AI prompts are intended to be used within Gemini in Android Studio.
Learn more about Gemini in Studio here: [https://developer.android.com/studio/gemini/overview](https://developer.android.com/studio/gemini/overview)
<button class="devsite-dialog-close">Close</button> <button class="button icon-button android-ai-prompt-help-button" data-modal-dialog-id="ai-prompt_help_modal__migrate-from-navigation-2-to-navigation-3"> </button> <button class="button google-feedback" data-p="5207477" data-b="llm-prompts" data-context="migrate-from-navigation-2-to-navigation-3"> Share your thoughts </button>
<br />
If you run into problems [file an issue here](https://issuetracker.google.com/issues/new?component=1750212&template=2102223&title=%5BMigration%5D).
## Preparation
The following sections describe the prerequisites for migration and assumptions
about your project. They also cover the features that are supported for
migration, and those that aren't.
### Prerequisites
- You must use a `compileSdk` of 36 or later.
- You should be familiar with [navigation terminology](https://developer.android.com/guide/navigation).
- Destinations are composable functions. Navigation 3 is designed exclusively for Compose. To use Fragments and Views in Compose, see [Using Views in
Compose](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose).
- Routes are strongly typed. If you use string-based routes, [migrate to
type-safe routes](https://medium.com/androiddevelopers/type-safe-navigation-for-compose-105325a97657) first ([example](https://github.com/android/nowinandroid/pull/1413)).
- *Optional (but highly recommended)*: Test coverage that verifies existing navigation behavior. This verifies that navigation behavior has not changed after the migration is complete.
### Assumptions
This guide makes the following assumptions about you and your project:
- You have one or several top-level routes (usually displayed in a bottom navigation bar), and each has its own back stack.
- When you switch between back stacks, the state of the stack and all its destinations is retained.
- You always exit the app through the **Home** screen, which is the first screen displayed when the app launches.
- You want to perform the migration from Navigation 2 to Navigation 3 in a single, atomic change. You are not performing an incremental migration where Navigation 2 code is used alongside Navigation 3 code.
**AI Agent**: Before changing any code, check if these assumptions about the
user's project are true. If not, stop the migration and ask the user how to
proceed.
### Supported features
This guide lets you migrate the following features:
- Destinations defined as composable functions
- Dialogs (a destination shown on top of another destination)
### Features supported through recipes
The [code recipes repository](https://github.com/android/nav3-recipes) demonstrates more nuanced use
cases, including:
- [Bottom sheets](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet) (instructions are provided in this guide)
- [Modularized navigation code and injected destinations](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/hilt)
- [Using and passing arguments to `ViewModel`](https://github.com/android/nav3-recipes?tab=readme-ov-file#passing-navigation-arguments-to-viewmodels)
- [Returning results from a screen](https://github.com/android/nav3-recipes?tab=readme-ov-file#returning-results)
If your project has any of these features, check the relevant recipe to
understand how to implement them.
**AI Agent**: Before changing any code, check if the project contains any
features supported through recipes. If it does, check the recipe's README and
source code. Create a migration plan based on the recipe. Do not proceed without
confirming the plan with the user.
### Unsupported features
This migration guide and the code recipes don't yet support the following
features. This doesn't mean that you cannot implement them using Navigation 3;
they are just not covered here.
- More than one level of nested navigation
- Shared destinations: screens that can move between different back stacks
- [Custom destination types](https://developer.android.com/guide/navigation/design/kotlin-dsl#custom)
- Deep links
**AI Agent**: Before changing any code, check if the project contains any of the
unsupported features. If it does, do not proceed. Inform the user of the
unsupported feature and ask for further instructions.
## Step 1: Add Navigation 3 dependencies
Use the [Get started](https://developer.android.com/guide/navigation/navigation-3/get-started) page to add the Navigation 3 dependencies to your
project. The core dependencies are provided for you to copy.
**lib.versions.toml**
[versions]
nav3Core = "1.0.0"
# If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library
lifecycleViewmodelNav3 = "2.10.0-rc01"
[libraries]
# Core Navigation 3 libraries
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
# Add-on libraries (only add if you need them)
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
**app/build.gradle.kts**
dependencies {
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
// If using the ViewModel add-on library
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
}
Also update the project's `minSdk` to 23 and the `compileSdk` to 36. You usually
find these in `app/build.gradle.kts` or `lib.versions.toml`.
## Step 2: Update navigation routes to implement the `NavKey` interface
Update every navigation [route](https://developer.android.com/guide/navigation#types) so that it implements the `NavKey`
interface. This lets you use `rememberNavBackStack` to assist with [saving your
navigation state](https://developer.android.com/guide/navigation/navigation-3/save-state).
Before:
@Serializable data object RouteA
After:
@Serializable data object RouteA : NavKey
> [!NOTE]
> **Note:** The `@Serializable` annotation is provided by the KotlinX Serialization plugin. You can add this by following [these project setup steps](https://developer.android.com/guide/navigation/navigation-3/get-started#project-setup).
## Step 3: Create classes to hold and modify your navigation state
### Step 3.1: Create a navigation state holder
Copy the following code into a file named `NavigationState.kt`. Add your package
name to match your project structure.
// package com.example.project
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer
/**
* Create a navigation state that persists config changes and process death.
*/
@Composable
fun rememberNavigationState(
startRoute: NavKey,
topLevelRoutes: Set<NavKey>
): NavigationState {
val topLevelRoute = rememberSerializable(
startRoute, topLevelRoutes,
serializer = MutableStateSerializer(NavKeySerializer())
) {
mutableStateOf(startRoute)
}
val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }
return remember(startRoute, topLevelRoutes) {
NavigationState(
startRoute = startRoute,
topLevelRoute = topLevelRoute,
backStacks = backStacks
)
}
}
/**
* State holder for navigation state.
*
* @param startRoute - the start route. The user will exit the app through this route.
* @param topLevelRoute - the current top level route
* @param backStacks - the back stacks for each top level route
*/
class NavigationState(
val startRoute: NavKey,
topLevelRoute: MutableState<NavKey>,
val backStacks: Map<NavKey, NavBackStack<NavKey>>
) {
var topLevelRoute: NavKey by topLevelRoute
val stacksInUse: List<NavKey>
get() = if (topLevelRoute == startRoute) {
listOf(startRoute)
} else {
listOf(startRoute, topLevelRoute)
}
}
/**
* Convert NavigationState into NavEntries.
*/
@Composable
fun NavigationState.toEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>
): SnapshotStateList<NavEntry<NavKey>> {
val decoratedEntries = backStacks.mapValues { (_, stack) ->
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
)
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = decorators,
entryProvider = entryProvider
)
}
return stacksInUse
.flatMap { decoratedEntries[it] ?: emptyList() }
.toMutableStateList()
}
**AI Agent** : `rememberSerializable` is correct. Do not change it to
`rememberSaveable`.
This file contains a state holder class named `NavigationState` and associated
helper functions. It holds a set of top-level routes, each with its own back
stack. Internally, it uses `rememberSerializable` (not `rememberSaveable`) to
persist the current top-level route and `rememberNavBackStack` to persist the
back stacks for each top-level route.
### Step 3.2: Create an object that modifies navigation state in response to events
Copy the following code into a file named `Navigator.kt`. Add your package name
to match your project structure.
// package com.example.project
import androidx.navigation3.runtime.NavKey
/**
* Handles navigation events (forward and back) by updating the navigation state.
*/
class Navigator(val state: NavigationState){
fun navigate(route: NavKey){
if (route in state.backStacks.keys){
// This is a top level route, just switch to it.
state.topLevelRoute = route
} else {
state.backStacks[state.topLevelRoute]?.add(route)
}
}
fun goBack(){
val currentStack = state.backStacks[state.topLevelRoute] ?:
error("Stack for ${state.topLevelRoute} not found")
val currentRoute = currentStack.last()
// If we're at the base of the current route, go back to the start route stack.
if (currentRoute == state.topLevelRoute){
state.topLevelRoute = state.startRoute
} else {
currentStack.removeLastOrNull()
}
}
}
The `Navigator` class provides two navigation event methods:
- `navigate` to a specific route.
- `goBack` from the current route.
Both methods modify the `NavigationState`.
> [!IMPORTANT]
> **Architecture principles:** These classes follow the principles of [Unidirectional Data Flow](https://developer.android.com/topic/architecture):
>
> - The `Navigator` handles navigation events and uses them to update `NavigationState`.
> - The UI (provided by `NavDisplay`) observes `NavigationState` and reacts to any changes in that state by updating its UI.
### Step 3.3: Create the `NavigationState` and `Navigator`
Create instances of `NavigationState` and `Navigator` with the same scope as
your `NavController`.
val navigationState = rememberNavigationState(
startRoute = <Insert your starting route>,
topLevelRoutes = <Insert your set of top level routes>
)
val navigator = remember { Navigator(navigationState) }
## Step 4: Replace `NavController`
Replace `NavController` navigation event methods with `Navigator` equivalents.
| **`NavController` field or method** | **`Navigator` equivalent** |
|---|---|
| `navigate()` | `navigate()` |
| `popBackStack()` | `goBack()` |
Replace `NavController` fields with `NavigationState` fields.
| **`NavController` field or method** | **`NavigationState` equivalent** |
|---|---|
| `currentBackStack` | `backStacks[topLevelRoute]` |
| `currentBackStackEntry` `currentBackStackEntryAsState()` `currentBackStackEntryFlow` `currentDestination` | `backStacks[topLevelRoute].last()` |
| Get the top level route: Traverse up the hierarchy from the current back stack entry to find it. | `topLevelRoute` |
Use `NavigationState.topLevelRoute` to determine the item that is currently
selected in a navigation bar.
Before:
val isSelected = navController.currentBackStackEntryAsState().value?.destination.isRouteInHierarchy(key::class)
fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
this?.hierarchy?.any {
it.hasRoute(route)
} ?: false
After:
val isSelected = key == navigationState.topLevelRoute
Verify that you have removed all references to `NavController`, including
any imports.
## Step 5: Move your destinations from `NavHost`'s `NavGraph` into an `entryProvider`
In Navigation 2, you [define your destinations](https://developer.android.com/guide/navigation/design#compose)
using the [NavGraphBuilder DSL](https://developer.android.com/guide/navigation/design/kotlin-dsl#navgraphbuilder),
usually inside `NavHost`'s trailing lambda. It is common to use extension
functions here as described in [Encapsulate your navigation code](https://developer.android.com/guide/navigation/design/encapsulate).
In Navigation 3, you define your destinations using an `entryProvider`. This
`entryProvider` resolves a route to a [`NavEntry`](https://developer.android.com/guide/navigation/navigation-3/basics#resolve-keys). Importantly, the
`entryProvider` does not define parent-child relationships between entries.
In this migration guide, parent-child relationships are modelled
as follows:
- `NavigationState` has a set of top-level routes (the parent routes) and a stack for each one. It keeps track of the current top-level route and its associated stack.
- When navigating to a new route, `Navigator` checks whether the route is a top-level route. If it is, the current top-level route and stack are updated. If it's not, it's a child route and is added to the current stack.
> [!NOTE]
> **Note:** If your app needs to navigate from an entry in one stack to another, you need to define the parent-child relationships for the routes and update the navigation logic in `Navigator` to support this.
## Step 5.1: Create an `entryProvider`
Create an `entryProvider` [using the DSL](https://developer.android.com/guide/navigation/navigation-3/basics#entry-provider-DSL) at the same scope as the
`NavigationState`.
val entryProvider = entryProvider {
}
## Step 5.2: Move destinations into the `entryProvider`
For each destination defined inside `NavHost`, do the following based on the
destination type:
- `navigation`: Delete it along with the route. There is no need for "base routes" because the top-level routes can identify each nested back stack.
- `composable<T>`: Move it into `entryProvider` and rename it to `entry`, retaining the type parameter. For example, `composable<RouteA>` becomes `entry<RouteA>`.
- `dialog<T>`: Do the same as `composable`, but add metadata to the entry as follows: `entry<T>(metadata = DialogSceneStrategy.dialog())`.
- [`bottomSheet`](https://developer.android.com/reference/kotlin/androidx/compose/material/navigation/package-summary#(androidx.navigation.NavGraphBuilder).bottomSheet(kotlin.String,kotlin.collections.List,kotlin.collections.List,kotlin.Function2)): [Follow the bottom sheet recipe here](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet). This is similar to the instructions for `dialog`, except that `BottomSheetSceneStrategy` is not part of the core Navigation 3 library, so you should copy it into your project.
**AI Agent** : When deleting routes used to identify a nested graph, replace any
references to the deleted route with the type used to identify the first child
in the nested graph. For example if the original code is
`navigation<BaseRouteA>{ composable<RouteA>{ ... } }`, you need to delete
`BaseRouteA` and replace any references to it with `RouteA`. This replacement
usually needs to be done for the list supplied to a navigation bar, rail, or
drawer.
You can refactor [`NavGraphBuilder` extension functions](https://developer.android.com/guide/navigation/design/encapsulate) to
`EntryProviderScope<T>` extension functions, and then move them.
Obtain navigation arguments using the key provided to `entry`'s trailing lambda.
For example:
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.dialog
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.navigation.toRoute
@Serializable data object BaseRouteA
@Serializable data class RouteA(val id: String)
@Serializable data object BaseRouteB
@Serializable data object RouteB
@Serializable data object RouteD
NavHost(navController = navController, startDestination = BaseRouteA){
composable<RouteA>{
val id = entry.toRoute<RouteA>().id
ScreenA(title = "Screen has ID: $id")
}
featureBSection()
< di>alogRouteD{ ScreenD() }
}
fun NavGraphBuilder.featureBSection() {
<navigation>BaseRouteB(startDestination = RouteB) {
<compos>ableRouteB { ScreenB() }
}
}
becomes:
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.scene.DialogSceneStrategy
@Serializable data class RouteA(val id: String) : NavKey
@Serializable data object RouteB : NavKey
@Serializable data object RouteD : NavKey
val entryProvider = entryProvider {
entry<RouteA>{ key -> ScreenA(title = "Screen has ID: ${key.id}") }
featureBSection()<
e>ntryRouteD(metadata = DialogSceneStrategy.dialog()){ ScreenD() }
}
fun EntryPro<viderS>copeNavKey.featureBSection() {<
e>ntryRouteB { ScreenB() }
}
## Step 6: Replace `NavHost` with `NavDisplay`
Replace `NavHost` with `NavDisplay`.
- Delete `NavHost` and replace it with `NavDisplay`.
- Specify `entries = navigationState.toEntries(entryProvider)` as a parameter. This converts the navigation state into the entries that `NavDisplay` shows using the `entryProvider`.
- Connect `NavDisplay.onBack` to `navigator.goBack()`. This causes `navigator` to update the navigation state when `NavDisplay`'s built-in back handler completes.
- If you have dialog destinations, add `DialogSceneStrategy` to `NavDisplay`'s `sceneStrategies` parameter.
For example:
import androidx.navigation3.ui.NavDisplay
NavDisplay(
entries = navigationState.toEntries(entryProvider),
onBack = { navigator.goBack() },
sceneStrategies = remember { listOf(DialogSceneStrategy()) }
)
## Step 7: Remove Navigation 2 dependencies
Remove all Navigation 2 imports and library dependencies.
## Summary
Congratulations! Your project is now migrated to Navigation 3. If you or your AI
agent has run into any problems using this guide, [file a bug
here](https://issuetracker.google.com/issues/new?component=1750212&template=2102223&title=%5BMigration%5D).

View File

@@ -0,0 +1,147 @@
# Animations Recipe
This recipe shows how to override the default animations at the `NavDisplay` level, and at the individual destination level.
## How it works
The `NavDisplay` composable takes `transitionSpec`, `popTransitionSpec`, and `predictivePopTransitionSpec` parameters to define the animations for forward, backward, and predictive back navigation respectively. These animations will be applied to all destinations by default.
In this example, we use `slideInHorizontally` and `slideOutHorizontally` to create a sliding animation for forward and backward navigation.
It is also possible to override these animations for a specific destination by providing a different `transitionSpec` and `popTransitionSpec` to the `entry` composable. In this recipe, `ScreenC` has a custom vertical slide animation.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/animations)
```
package com.example.nav3recipes.animations
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.metadata
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentMauve
import com.example.nav3recipes.content.ContentOrange
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
@Serializable
private data object ScreenA : NavKey
@Serializable
private data object ScreenB : NavKey
@Serializable
private data object ScreenC : NavKey
class AnimatedActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(ScreenA)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<ScreenA> {
ContentOrange("This is Screen A") {
Button(onClick = dropUnlessResumed { backStack.add(ScreenB) }) {
Text("Go to Screen B")
}
}
}
entry<ScreenB> {
ContentMauve("This is Screen B") {
Button(onClick = dropUnlessResumed { backStack.add(ScreenC) }) {
Text("Go to Screen C")
}
}
}
entry<ScreenC>(
metadata = metadata {
// Slide new content up, keeping the old content in place underneath
put(NavDisplay.TransitionKey) {
slideInVertically(
initialOffsetY = { it },
animationSpec = tween(1000)
) togetherWith ExitTransition.KeepUntilTransitionsFinished
}
// Slide old content down, revealing the new content in place underneath
put(NavDisplay.PopTransitionKey) {
EnterTransition.None togetherWith
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(1000)
)
}
// Slide old content down, revealing the new content in place underneath
put(NavDisplay.PredictivePopTransitionKey) {
EnterTransition.None togetherWith
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(1000)
)
}
}
) {
ContentGreen("This is Screen C")
}
},
transitionSpec = {
// Slide in from right when navigating forward
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(1000)
) togetherWith slideOutHorizontally(
targetOffsetX = { -it },
animationSpec = tween(1000)
)
},
popTransitionSpec = {
// Slide in from left when navigating back
slideInHorizontally(
initialOffsetX = { -it },
animationSpec = tween(1000)
) togetherWith slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(1000)
)
},
predictivePopTransitionSpec = {
// Slide in from left when navigating back
slideInHorizontally(
initialOffsetX = { -it },
animationSpec = tween(1000)
) togetherWith slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(1000)
)
}
)
}
}
}
```

View File

@@ -0,0 +1,89 @@
# Basic Recipe
This recipe shows a basic example of how to use the Navigation 3 API with two screens.
## How it works
This example defines two routes: `RouteA` and `RouteB`. `RouteA` is a `data object` representing a simple screen, while `RouteB` is a `data class` that takes an `id` as a parameter.
A `mutableStateListOf<Any>` is used to manage the navigation back stack.
The `NavDisplay` composable is used to display the current screen. Its `entryProvider` parameter is a lambda that takes a route from the back stack and returns a `NavEntry`. Inside the `entryProvider`, a `when` statement is used to determine which composable to display based on the route.
To navigate from `RouteA` to `RouteB`, we simply add a `RouteB` instance to the back stack. The `id` is passed as an argument to the `RouteB` data class.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basic)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.basic
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
private data object RouteA
private data class RouteB(val id: String)
class BasicActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = remember { mutableStateListOf<Any>(RouteA) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = { key ->
when (key) {
is RouteA -> NavEntry(key) {
ContentGreen("Welcome to Nav3") {
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("123"))
}) {
Text("Click to navigate")
}
}
}
is RouteB -> NavEntry(key) {
ContentBlue("Route id: ${key.id} ")
}
else -> {
error("Unknown route: $key")
}
}
}
)
}
}
}
```

View File

@@ -0,0 +1,85 @@
# Basic DSL Recipe
This recipe shows a basic example of how to use the Navigation 3 API with two screens, using the `entryProvider` DSL and a persistent back stack.
## How it works
This example is similar to the basic recipe, but with a few key differences:
1. **Persistent Back Stack** : It uses `rememberNavBackStack(RouteA)` to create and remember the back stack. This makes the back stack persistent across configuration changes (e.g., screen rotation). To use `rememberNavBackStack`, the navigation keys must be serializable, which is why `RouteA` and `RouteB` are annotated with `@Serializable` and implement the `NavKey` interface.
2. **`entryProvider` DSL** : Instead of a `when` statement, this example uses the `entryProvider` DSL to define the content for each route. The `entry<RouteType>` function is used to associate a route type with its composable content.
The navigation logic remains the same: to navigate from `RouteA` to `RouteB`, we add a `RouteB` instance to the back stack.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basicdsl)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.basicdsl
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
@Serializable
private data object RouteA : NavKey
@Serializable
private data class RouteB(val id: String) : NavKey
class BasicDslActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(RouteA)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<RouteA> {
ContentGreen("Welcome to Nav3") {
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("123"))
}) {
Text("Click to navigate")
}
}
}
entry<RouteB> { key ->
ContentBlue("Route id: ${key.id} ")
}
}
)
}
}
}
```

View File

@@ -0,0 +1,90 @@
# Basic Saveable Recipe
This recipe shows a basic example of how to create a persistent back stack that survives configuration changes.
## How it works
To make the back stack persistent, we use the `rememberNavBackStack` function. This function creates and remembers the back stack across configuration changes (e.g., screen rotation).
A requirement for using `rememberNavBackStack` is that the navigation keys (routes) must be serializable. In this example, `RouteA` and `RouteB` are annotated with `@Serializable` and implement the `NavKey` interface.
This example uses a `when` statement within the `entryProvider` to map routes to their corresponding composables, but it could also be used with the `entryProvider` DSL.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basicsaveable)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.basicsaveable
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
@Serializable
private data object RouteA : NavKey
@Serializable
private data class RouteB(val id: String) : NavKey
class BasicSaveableActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(RouteA)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = { key ->
when (key) {
is RouteA -> NavEntry(key) {
ContentGreen("Welcome to Nav3") {
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("123"))
}) {
Text("Click to navigate")
}
}
}
is RouteB -> NavEntry(key) {
ContentBlue("Route id: ${key.id} ")
}
else -> {
error("Unknown route: $key")
}
}
}
)
}
}
}
```

View File

@@ -0,0 +1,195 @@
# Bottom Sheet Recipe
This recipe demonstrates how to display a destination as a modal bottom sheet.
## How it works
To show a destination as a bottom sheet, you need to do two things:
1. **Use `BottomSheetSceneStrategy`** : Create an instance of `BottomSheetSceneStrategy` and pass it to the `sceneStrategy` parameter of the `NavDisplay` composable.
2. **Add metadata to the destination** : For the destination that you want to display as a bottom sheet, add `BottomSheetSceneStrategy.bottomSheet()` to its metadata. This is done in the `entry` function.
In this example, `RouteB` is configured to be a bottom sheet. When you navigate from `RouteA` to `RouteB`, `RouteB` will be displayed in a modal bottom sheet that slides up from the bottom of the screen.
The content of the bottom sheet can be styled as needed. In this recipe, the content is clipped to have rounded corners.
For more information, see the official documentation on [custom layouts](https://developer.android.com/guide/navigation/navigation-3/custom-layouts).
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.bottomsheet
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
@Serializable
private data object RouteA : NavKey
@Serializable
private data class RouteB(val id: String) : NavKey
class BottomSheetActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(RouteA)
val bottomSheetStrategy = remember { BottomSheetSceneStrategy<NavKey>() }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(bottomSheetStrategy),
entryProvider = entryProvider {
entry<RouteA> {
ContentGreen("Welcome to Nav3") {
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("123"))
}) {
Text("Click to open bottom sheet")
}
}
}
entry<RouteB>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) { key ->
ContentBlue(
title = "Route id: ${key.id}",
modifier = Modifier.clip(
shape = RoundedCornerShape(16.dp)
)
)
}
}
)
}
}
}
```
```
package com.example.nav3recipes.bottomsheet
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.rememberLifecycleOwner
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavMetadataKey
import androidx.navigation3.runtime.get
import androidx.navigation3.runtime.metadata
import androidx.navigation3.scene.OverlayScene
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategy
import androidx.navigation3.scene.SceneStrategyScope
import com.example.nav3recipes.bottomsheet.BottomSheetSceneStrategy.Companion.bottomSheet
/** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */
@OptIn(ExperimentalMaterial3Api::class)
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()
}
}
}
}
/**
* A [SceneStrategy] that displays entries that have added [bottomSheet] to their [NavEntry.metadata]
* within a [ModalBottomSheet] instance.
*
* This strategy should always be added before any non-overlay scene strategies.
*/
@OptIn(ExperimentalMaterial3Api::class)
class BottomSheetSceneStrategy<T : Any> : SceneStrategy<T> {
override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
val lastEntry = entries.lastOrNull() ?: return null
val bottomSheetProperties = lastEntry.metadata[BottomSheetKey] ?: return null
return bottomSheetProperties.let { properties ->
@Suppress("UNCHECKED_CAST")
BottomSheetScene(
key = lastEntry.contentKey as T,
previousEntries = entries.dropLast(1),
overlaidEntries = entries.dropLast(1),
entry = lastEntry,
modalBottomSheetProperties = properties,
onBack = onBack
)
}
}
companion object {
/**
* Function to be called on the [NavEntry.metadata] to mark this entry as something that
* should be displayed within a [ModalBottomSheet].
*
* @param modalBottomSheetProperties properties that should be passed to the containing
* [ModalBottomSheet].
*/
fun bottomSheet(modalBottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties()) =
metadata {
put(BottomSheetKey, modalBottomSheetProperties)
}
object BottomSheetKey : NavMetadataKey<ModalBottomSheetProperties>
}
}
```

View File

@@ -0,0 +1,200 @@
# Common UI Recipe
This recipe demonstrates how to implement a common navigation UI pattern with a bottom navigation bar and multiple back stacks, where each tab in the navigation bar has its own navigation history.
## How it works
This example has three top-level destinations: `Home`, `ChatList`, and `Camera`. The `ChatList` destination also has a sub-route, `ChatDetail`.
### `TopLevelBackStack`
The core of this recipe is the `TopLevelBackStack` class, which is responsible for managing the navigation state. It works as follows:
- It maintains a separate back stack for each top-level destination (tab).
- It keeps track of the currently selected top-level destination.
- It provides a single, flattened back stack that can be used by the `NavDisplay` composable. This flattened back stack is a combination of the individual back stacks of all the tabs.
### UI Structure
The UI is built using a `Scaffold` composable, with a `NavigationBar` as the `bottomBar`.
- The `NavigationBar` displays an item for each top-level destination. When an item is clicked, it calls `topLevelBackStack.addTopLevel` to switch to the corresponding tab, preserving the navigation history of each tab.
- The `NavDisplay` composable is placed in the content area of the `Scaffold`. It is responsible for displaying the current screen based on the flattened back stack provided by `TopLevelBackStack`.
This approach allows for a common navigation pattern where users can switch between different sections of the app, and each section maintains its own navigation history.
### State Preservation
It's important to note how the navigation state is managed in this recipe. When a user navigates away from a top-level destination (e.g., by pressing the back button until they return to a previous tab), the entire navigation history for that destination is cleared. The state is not saved. When the user returns to that tab later, they will start from its initial screen.
**Note** : In this example, the `Home` route can move above the `ChatList` and `Camera` routes, meaning navigating back from `Home` doesn't necessarily leave the app. The app will exit when the user goes back from a single remaining top level route in the back stack.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/commonui)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.commonui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentPurple
import com.example.nav3recipes.content.ContentRed
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
private sealed interface TopLevelRoute {
val icon: ImageVector
}
private data object Home : TopLevelRoute { override val icon = Icons.Default.Home }
private data object ChatList : TopLevelRoute { override val icon = Icons.Default.Face }
private data object ChatDetail
private data object Camera : TopLevelRoute { override val icon = Icons.Default.PlayArrow }
private val TOP_LEVEL_ROUTES : List<TopLevelRoute> = listOf(Home, ChatList, Camera)
class CommonUiActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val topLevelBackStack = remember { TopLevelBackStack<Any>(Home) }
Scaffold(
bottomBar = {
NavigationBar {
TOP_LEVEL_ROUTES.forEach { topLevelRoute ->
val isSelected = topLevelRoute == topLevelBackStack.topLevelKey
NavigationBarItem(
selected = isSelected,
onClick = {
topLevelBackStack.addTopLevel(topLevelRoute)
},
icon = {
Icon(
imageVector = topLevelRoute.icon,
contentDescription = null
)
}
)
}
}
}
) { _ ->
NavDisplay(
backStack = topLevelBackStack.backStack,
onBack = { topLevelBackStack.removeLast() },
entryProvider = entryProvider {
entry<Home>{
ContentRed("Home screen")
}
entry<ChatList>{
ContentGreen("Chat list screen"){
Button(onClick = dropUnlessResumed {
topLevelBackStack.add(ChatDetail)
}) {
Text("Go to conversation")
}
}
}
entry<ChatDetail>{
ContentBlue("Chat detail screen")
}
entry<Camera>{
ContentPurple("Camera screen")
}
},
)
}
}
}
}
class TopLevelBackStack<T: Any>(startKey: T) {
// Maintain a stack for each top level route
private var topLevelStacks : LinkedHashMap<T, SnapshotStateList<T>> = linkedMapOf(
startKey to mutableStateListOf(startKey)
)
// Expose the current top level route for consumers
var topLevelKey by mutableStateOf(startKey)
private set
// Expose the back stack so it can be rendered by the NavDisplay
val backStack = mutableStateListOf(startKey)
private fun updateBackStack() =
backStack.apply {
clear()
addAll(topLevelStacks.flatMap { it.value })
}
fun addTopLevel(key: T){
// If the top level doesn't exist, add it
if (topLevelStacks[key] == null){
topLevelStacks.put(key, mutableStateListOf(key))
} else {
// Otherwise just move it to the end of the stacks
topLevelStacks.apply {
remove(key)?.let {
put(key, it)
}
}
}
topLevelKey = key
updateBackStack()
}
fun add(key: T){
topLevelStacks[topLevelKey]?.add(key)
updateBackStack()
}
fun removeLast(){
val removedKey = topLevelStacks[topLevelKey]?.removeLastOrNull()
// If the removed key was a top level key, remove the associated top level stack
topLevelStacks.remove(removedKey)
topLevelKey = topLevelStacks.keys.last()
updateBackStack()
}
}
```

View File

@@ -0,0 +1,230 @@
# Conditional Navigation Recipe
This recipe demonstrates how to implement conditional navigation, where certain destinations are only accessible if a condition is met (in this case, if the user is logged in).
## How it works
This example has a `Profile` destination that requires the user to be logged in. If the user is not logged in and attempts to navigate to `Profile`, they are redirected to a `Login` screen. After a successful login, they are automatically navigated to the `Profile` screen.
### `AppBackStack`
The core of this recipe is the custom `AppBackStack` class, which encapsulates the logic for conditional navigation.
- **`RequiresLogin` interface** : A marker interface, `RequiresLogin`, is used to identify destinations that require the user to be logged in. The `Profile` destination implements this interface.
- **Redirecting to Login** : When the `add` function is called with a destination that implements `RequiresLogin` and the user is not logged in, `AppBackStack` stores the intended destination and adds the `Login` route to the back stack instead.
- **Handling Login** : When the `login` function is called, it sets the user's status to logged in. If there is a stored destination that the user was trying to access, it adds that destination to the back stack and removes the `Login` screen.
- **Handling Logout** : When the `logout` function is called, it sets the user's status to logged out and removes any destinations from the back stack that require the user to be logged in.
This approach provides a clean way to handle conditional navigation by centralizing the logic in a custom back stack implementation.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/conditional)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.conditional
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentYellow
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
/**
* Class for representing navigation keys in the app.
*
* Note: We use a sealed class because KotlinX Serialization handles
* polymorphic serialization of sealed classes automatically.
*
* @param requiresLogin - true if the navigation key requires that the user is logged in
* to navigate to it
*/
@Serializable
sealed class ConditionalNavKey(val requiresLogin: Boolean = false) : NavKey
/**
* Key representing home screen
*/
@Serializable
private data object Home : ConditionalNavKey()
/**
* Key representing profile screen that is only accessible once the user has logged in
*/
@Serializable
private data object Profile : ConditionalNavKey(requiresLogin = true)
/**
* Key representing login screen
*
* @param redirectToKey - navigation key to redirect to after successful login
*/
@Serializable
private data class Login(
val redirectToKey: ConditionalNavKey? = null
) : ConditionalNavKey()
class ConditionalActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack<ConditionalNavKey>(Home)
var isLoggedIn by rememberSaveable {
mutableStateOf(false)
}
val navigator = remember {
Navigator(
backStack = backStack,
onNavigateToRestrictedKey = { redirectToKey -> Login(redirectToKey) },
isLoggedIn = { isLoggedIn }
)
}
NavDisplay(
backStack = backStack,
onBack = { navigator.goBack() },
entryProvider = entryProvider {
entry<Home> {
ContentGreen("Welcome to Nav3. Logged in? ${isLoggedIn}") {
Column {
Button(onClick = dropUnlessResumed { navigator.navigate(Profile) }) {
Text("Profile")
}
Button(onClick = dropUnlessResumed { navigator.navigate(Login()) }) {
Text("Login")
}
}
}
}
entry<Profile> {
ContentBlue("Profile screen (only accessible once logged in)") {
Button(onClick = dropUnlessResumed {
isLoggedIn = false
navigator.navigate(Home)
}) {
Text("Logout")
}
}
}
entry<Login> { key ->
ContentYellow("Login screen. Logged in? $isLoggedIn") {
Button(onClick = dropUnlessResumed {
isLoggedIn = true
key.redirectToKey?.let { targetKey ->
backStack.remove(key)
navigator.navigate(targetKey)
}
}) {
Text("Login")
}
}
}
}
)
}
}
}
// An overload of `rememberNavBackStack` that returns a subtype of `NavKey`.
// See https://issuetracker.google.com/issues/463382671 for a discussion of this function
@Composable
fun <T : NavKey> rememberNavBackStack(vararg elements: T): NavBackStack<T> {
return rememberSerializable(
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
) {
NavBackStack(*elements)
}
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.conditional
import androidx.navigation3.runtime.NavBackStack
/**
* Provides navigation events with built-in support for conditional access. If the user attempts to
* navigate to a [ConditionalNavKey] that requires login ([ConditionalNavKey.requiresLogin] is true)
* but is not currently logged in, the Navigator will redirect the user to a login key.
*
* @property backStack The back stack that is modified by this class
* @property onNavigateToRestrictedKey A lambda that is called when the user attempts to navigate
* to a key that requires login. This should return the key that represents the login screen. The
* user's target key is supplied as a parameter so that after successful login the user can be
* redirected to their target destination.
* @property isLoggedIn A lambda that returns whether the user is logged in.
*/
class Navigator(
private val backStack: NavBackStack<ConditionalNavKey>,
private val onNavigateToRestrictedKey: (targetKey: ConditionalNavKey?) -> ConditionalNavKey,
private val isLoggedIn: () -> Boolean,
) {
fun navigate(key: ConditionalNavKey) {
if (key.requiresLogin && !isLoggedIn()) {
val loginKey = onNavigateToRestrictedKey(key)
backStack.add(loginKey)
} else {
backStack.add(key)
}
}
fun goBack() = backStack.removeLastOrNull()
}
```

View File

@@ -0,0 +1,155 @@
# Deep Link Advanced Recipe
This recipe demonstrates how to apply the principles of navigation in the context of deep links by
managing a synthetic backStack and Task stacks.
# Recipe Structure
This recipe simulates a real-world scenario where "App A" deeplinks
into "App B".
"App A" is simulated by the module [com.example.nav3recipes.deeplink.advanced](https://developer.android.com/app/src/main/java/com/example/nav3recipes/deeplink/advanced), which
contains the `CreateAdvancedDeepLinkActivity` that allows you to create a deeplink intent and
trigger that in either the existing Task, or in a new Task.
"App B" is simulated by the module [advanceddeeplinkapp](https://developer.android.com/advanceddeeplinkapp/src/main/java/com/example/nav3recipes/deeplink/advanced), which contains
the MainActivity that you deeplink into. That module shows you how to build a synthetic backStack
and how to manage the Task stack properly in order to support both Back and Up buttons.
# Core implementation
The core helper functions for navigateUp and building synthetic backStack can be
found [here](https://developer.android.com/static/advanceddeeplinkapp/src/main/java/com/example/nav3recipes/deeplink/advanced/util/DeepLinkBackStackUtil.kt)
# Further Read
Check out the [deep link guide](https://developer.android.com/docs/deeplink-guide) for a
comprehensive guide on Deep linking principles and how to apply them in Navigation 3.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/advanced)
```
package com.example.nav3recipes.deeplink.advanced
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.common.deeplink.EntryScreen
import com.example.nav3recipes.common.deeplink.LIST_FIRST_NAMES
import com.example.nav3recipes.common.deeplink.LIST_LOCATIONS
import com.example.nav3recipes.common.deeplink.MenuDropDown
import com.example.nav3recipes.common.deeplink.PaddedButton
import com.example.nav3recipes.common.deeplink.TextContent
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
internal const val ADVANCED_PATH_BASE = "https://www.nav3deeplink.com"
/**
* The recipe entry point that allows users to create a deep link and make a request with it.
*
* **HOW THIS RECIPE WORKS** This recipe simulates a real-world scenario where "App A" deeplinks
* into "App B".
*
* "App A" is simulated by this current module [com.example.nav3recipes.deeplink.advanced], which
* contains the [AdvancedCreateDeepLinkActivity] that allows you to create a deeplink intent and
* trigger that in either the existing Task, or in a new Task.
*
* "App B" is simulated by the module [com.example.nav3recipes.deeplink.advanced], which contains
* the MainActivity that you deeplink into. That module shows you how to build a synthetic backStack
* and how to manage the Task stack properly in order to support both Back and Up buttons.
*
* See the [README](README.md) file of current module for more info on advanced deep linking.
*/
class AdvancedCreateDeepLinkActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
EntryScreen("Sandbox - Build Your Deeplink Intent") {
val initFirstName = MENU_OPTIONS_FIRST_NAME.values.first().first()
val initLocation = MENU_OPTIONS_LOCATION.values.last().first()
val initTaskStack = MENU_OPTIONS_TASK_STACK.values.first().first()
var firstName by remember { mutableStateOf(initFirstName) }
var location by remember { mutableStateOf(initLocation) }
var taskStack by remember { mutableStateOf(initTaskStack) }
// select first name
MenuDropDown(
menuOptions = MENU_OPTIONS_FIRST_NAME,
) { _, selected ->
firstName = selected
}
// select first name
MenuDropDown(
menuOptions = MENU_OPTIONS_LOCATION,
) { _, selected ->
location = selected
}
// select current task stack or build new task stack
MenuDropDown(
menuOptions = MENU_OPTIONS_TASK_STACK,
) { _, selected ->
taskStack = selected
}
// build final deeplink URL and Intent
val finalUrl = "${ADVANCED_PATH_BASE}/user/$firstName/$location"
// display Intent info
val flagString = if (taskStack == TAG_NEW_TASK) {
"Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK"
} else "<none>"
val intentString = """
| Final Intent:
| data = "$finalUrl"
| action = Intent.ACTION_VIEW
| flags = $flagString
""".trimMargin()
TextContent(intentString)
// deeplink to target
PaddedButton("Deeplink Away!", onClick = dropUnlessResumed {
val intent = Intent().apply {
data = finalUrl.toUri()
action = Intent.ACTION_VIEW
if (taskStack == TAG_NEW_TASK) {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
startActivity(intent)
})
}
}
}
}
private const val TAG_FIRST_NAME = "firstName"
private const val TAG_LOCATION = "location"
private const val TAG_TASK_STACK = "Task stack"
private const val TAG_CURRENT_TASK = "Use Current Task Stack"
private const val TAG_NEW_TASK = "Start New Task Stack"
private val MENU_OPTIONS_FIRST_NAME = mapOf(
TAG_FIRST_NAME to LIST_FIRST_NAMES
)
private val MENU_OPTIONS_LOCATION = mapOf(
TAG_LOCATION to LIST_LOCATIONS
)
private val MENU_OPTIONS_TASK_STACK = mapOf(
TAG_TASK_STACK to listOf(TAG_CURRENT_TASK, TAG_NEW_TASK),
)
```

View File

@@ -0,0 +1,744 @@
# Deep Link Basic Recipe
This recipe demonstrates how to parse a deep link URL from an Android Intent into a Navigation key.
## How it works
It consists of two activities - `CreateDeepLinkActivity` to construct and trigger the deeplink request, and the `MainActivity` to show how an app can handle that request.
## Demonstrated forms of deeplink
The `MainActivity` has several backStack keys to demonstrate different types of supported deeplinks:
1. `HomeKey` - deeplink with an exact url (no deeplink arguments)
2. `UsersKey` - deeplink with path arguments
3. `SearchKey` - deeplink with query arguments
See `MainActivity.deepLinkPatterns` for the actual url pattern of each.
## Recipe structure
This recipe consists of three main packages:
1. `basic.deeplink` - Contains the two activities
2. `basic.deeplink.ui` - Contains the activity UI code, i.e. global string variables, deeplink URLs etc
3. `basic.deeplink.util` - Contains the classes and helper methods to parse and match the deeplinks
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/basic)
```
package com.example.nav3recipes.deeplink.basic
import androidx.navigation3.runtime.NavKey
import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_FILTER
import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME
import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_SEARCH
import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_USERS
import kotlinx.serialization.Serializable
internal interface NavRecipeKey: NavKey {
val name: String
}
@Serializable
internal object HomeKey: NavRecipeKey {
override val name: String = STRING_LITERAL_HOME
}
@Serializable
internal data class UsersKey(
val filter: String,
): NavRecipeKey {
override val name: String = STRING_LITERAL_USERS
companion object {
const val FILTER_KEY = STRING_LITERAL_FILTER
const val FILTER_OPTION_RECENTLY_ADDED = "recentlyAdded"
const val FILTER_OPTION_ALL = "all"
}
}
@Serializable
internal data class SearchKey(
val firstName: String? = null,
val ageMin: Int? = null,
val ageMax: Int? = null,
val location: String? = null,
): NavRecipeKey {
override val name: String = STRING_LITERAL_SEARCH
}
```
```
package com.example.nav3recipes.deeplink.basic
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.net.toUri
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.common.deeplink.EntryScreen
import com.example.nav3recipes.common.deeplink.FriendsList
import com.example.nav3recipes.common.deeplink.LIST_USERS
import com.example.nav3recipes.common.deeplink.TextContent
import com.example.nav3recipes.deeplink.basic.ui.URL_HOME_EXACT
import com.example.nav3recipes.deeplink.basic.ui.URL_SEARCH
import com.example.nav3recipes.deeplink.basic.ui.URL_USERS_WITH_FILTER
import com.example.nav3recipes.deeplink.basic.util.DeepLinkMatchResult
import com.example.nav3recipes.deeplink.basic.util.DeepLinkMatcher
import com.example.nav3recipes.deeplink.basic.util.DeepLinkPattern
import com.example.nav3recipes.deeplink.basic.util.DeepLinkRequest
import com.example.nav3recipes.deeplink.basic.util.KeyDecoder
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
/**
* Parses a target deeplink into a NavKey. There are several crucial steps involved:
*
* STEP 1.Parse supported deeplinks (URLs that can be deeplinked into) into a readily readable
* format (see [DeepLinkPattern])
* STEP 2. Parse the requested deeplink into a readily readable, format (see [DeepLinkRequest])
* **note** the parsed requested deeplink and parsed supported deeplinks should be cohesive with each
* other to facilitate comparison and finding a match
* STEP 3. Compare the requested deeplink target with supported deeplinks in order to find a match
* (see [DeepLinkMatchResult]). The match result's format should enable conversion from result
* to backstack key, regardless of what the conversion method may be.
* STEP 4. Associate the match results with the correct backstack key
*
* This recipes provides an example for each of the above steps by way of kotlinx.serialization.
*
* **This recipe is designed to focus on parsing an intent into a key, and therefore these additional
* deeplink considerations are not included in this scope**
* - Create synthetic backStack
* - Multi-modular setup
* - DI
* - Managing TaskStack
* - Up button ves Back Button
*
*/
class MainActivity : ComponentActivity() {
/** STEP 1. Parse supported deeplinks */
// internal so that landing activity can link to this in the kdocs
internal val deepLinkPatterns: List<DeepLinkPattern<out NavKey>> = listOf(
// "https://www.nav3recipes.com/home"
DeepLinkPattern(HomeKey.serializer(), (URL_HOME_EXACT).toUri()),
// "https://www.nav3recipes.com/users/with/{filter}"
DeepLinkPattern(UsersKey.serializer(), (URL_USERS_WITH_FILTER).toUri()),
// "https://www.nav3recipes.com/users/search?{firstName}&{age}&{location}"
DeepLinkPattern(SearchKey.serializer(), (URL_SEARCH.toUri())),
)
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
// retrieve the target Uri
val uri: Uri? = intent.data
// associate the target with the correct backstack key
val key: NavKey = uri?.let {
/** STEP 2. Parse requested deeplink */
val request = DeepLinkRequest(uri)
/** STEP 3. Compared requested with supported deeplink to find match*/
val match = deepLinkPatterns.firstNotNullOfOrNull { pattern ->
DeepLinkMatcher(request, pattern).match()
}
/** STEP 4. If match is found, associate match to the correct key*/
match?.let {
//leverage kotlinx.serialization's Decoder to decode
// match result into a backstack key
KeyDecoder(match.args)
.decodeSerializableValue(match.serializer)
}
} ?: HomeKey // fallback if intent.uri is null or match is not found
/**
* Then pass starting key to backstack
*/
setContent {
val backStack: NavBackStack<NavKey> = rememberNavBackStack(key)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<HomeKey> { key ->
EntryScreen(key.name) {
TextContent("<matches exact url>")
}
}
entry<UsersKey> { key ->
EntryScreen("${key.name} : ${key.filter}") {
TextContent("<matches path argument>")
val list = when {
key.filter.isEmpty() -> LIST_USERS
key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS
else -> LIST_USERS.take(5)
}
FriendsList(list)
}
}
entry<SearchKey> { search ->
EntryScreen(search.name) {
TextContent("<matches query parameters, if any>")
val matchingUsers = LIST_USERS.filter { user ->
(search.firstName == null || user.firstName == search.firstName) &&
(search.location == null || user.location == search.location) &&
(search.ageMin == null || user.age >= search.ageMin) &&
(search.ageMax == null || user.age <= search.ageMax)
}
FriendsList(matchingUsers)
}
}
}
)
}
}
}
```
```
package com.example.nav3recipes.deeplink.basic
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.common.deeplink.EMPTY
import com.example.nav3recipes.common.deeplink.EntryScreen
import com.example.nav3recipes.common.deeplink.FIRST_NAME_JOHN
import com.example.nav3recipes.common.deeplink.FIRST_NAME_JULIE
import com.example.nav3recipes.common.deeplink.FIRST_NAME_MARY
import com.example.nav3recipes.common.deeplink.FIRST_NAME_TOM
import com.example.nav3recipes.common.deeplink.LOCATION_BC
import com.example.nav3recipes.common.deeplink.LOCATION_BR
import com.example.nav3recipes.common.deeplink.LOCATION_CA
import com.example.nav3recipes.common.deeplink.LOCATION_US
import com.example.nav3recipes.common.deeplink.MenuDropDown
import com.example.nav3recipes.common.deeplink.MenuTextInput
import com.example.nav3recipes.common.deeplink.PaddedButton
import com.example.nav3recipes.common.deeplink.TextContent
import com.example.nav3recipes.deeplink.basic.ui.PATH_BASE
import com.example.nav3recipes.deeplink.basic.ui.PATH_INCLUDE
import com.example.nav3recipes.deeplink.basic.ui.PATH_SEARCH
import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
/**
* This activity allows the user to create a deep link and make a request with it.
*
* **HOW THIS RECIPE WORKS** it consists of two activities - [CreateDeepLinkActivity] to construct
* and trigger the deeplink request, and the [MainActivity] to show how an app can handle
* that request.
*
* **DEMONSTRATED FORMS OF DEEPLINK** The [MainActivity] has a several backStack keys to
* demonstrate different types of supported deeplinks:
* 1. [HomeKey] - deeplink with an exact url (no deeplink arguments)
* 2. [UsersKey] - deeplink with path arguments
* 3. [SearchKey] - deeplink with query arguments
* See [MainActivity.deepLinkPatterns] for the actual url pattern of each.
*
* **RECIPE STRUCTURE** This recipe consists of three main packages:
* 1. basic.deeplink - Contains the two activities
* 2. basic.deeplink.ui - Contains the activity UI code, i.e. global string variables, deeplink URLs etc
* 3. basic.deeplink.util - Contains the classes and helper methods to parse and match
* the deeplinks
*
* See [MainActivity] for how the requested deeplink is handled.
*/
class CreateDeepLinkActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
/**
* UI for deeplink sandbox
*/
EntryScreen("Sandbox - Build Your Deeplink") {
TextContent("Base url:\n${PATH_BASE}/")
var showFilterOptions by remember { mutableStateOf(false) }
val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) }
var showQueryOptions by remember { mutableStateOf(false) }
var selectedFilter by remember { mutableStateOf("") }
val selectedSearchQuery = remember { mutableStateMapOf<String, String>() }
// manage path options
MenuDropDown(
menuOptions = MENU_OPTIONS_PATH,
) { _, selection ->
selectedPath.value = selection
when (selection) {
PATH_SEARCH -> {
showQueryOptions = true
showFilterOptions = false
}
PATH_INCLUDE -> {
showQueryOptions = false
showFilterOptions = true
}
else -> {
showQueryOptions = false
showFilterOptions = false
}
}
}
// manage path filter options, reset state if menu is closed
LaunchedEffect(showFilterOptions) {
selectedFilter = if (showFilterOptions) {
MENU_OPTIONS_FILTER.values.first().first()
} else {
""
}
}
if (showFilterOptions) {
MenuDropDown(
menuOptions = MENU_OPTIONS_FILTER,
) { _, selected ->
selectedFilter = selected
}
}
// manage query options, reset state if menu is closed
LaunchedEffect(showQueryOptions) {
if (showQueryOptions) {
val initEntry = MENU_OPTIONS_SEARCH.entries.first()
selectedSearchQuery[initEntry.key] = initEntry.value.first()
} else {
selectedSearchQuery.clear()
}
}
if (showQueryOptions) {
MenuTextInput(
menuLabels = MENU_LABELS_SEARCH,
) { label, selected ->
selectedSearchQuery[label] = selected
}
MenuDropDown(
menuOptions = MENU_OPTIONS_SEARCH,
) { label, selected ->
selectedSearchQuery[label] = selected
}
}
// form final deeplink url
val arguments = when (selectedPath.value) {
PATH_INCLUDE -> "/${selectedFilter}"
PATH_SEARCH -> {
buildString {
selectedSearchQuery.forEach { entry ->
if (entry.value.isNotEmpty()) {
val prefix = if (isEmpty()) "?" else "&"
append("$prefix${entry.key}=${entry.value}")
}
}
}
}
else -> ""
}
val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments"
TextContent("Final url:\n$finalUrl")
// deeplink to target
PaddedButton("Deeplink Away!", onClick = dropUnlessResumed {
val intent = Intent(
this@CreateDeepLinkActivity,
MainActivity::class.java
)
// start activity with the url
intent.data = finalUrl.toUri()
startActivity(intent)
})
}
}
}
}
private const val KEY_PATH = "path"
private val MENU_OPTIONS_PATH = mapOf(
KEY_PATH to listOf(
STRING_LITERAL_HOME,
PATH_INCLUDE,
PATH_SEARCH,
),
)
private val MENU_OPTIONS_FILTER = mapOf(
UsersKey.FILTER_KEY to listOf(UsersKey.FILTER_OPTION_RECENTLY_ADDED, UsersKey.FILTER_OPTION_ALL),
)
private val MENU_OPTIONS_SEARCH = mapOf(
SearchKey::firstName.name to listOf(
EMPTY,
FIRST_NAME_JOHN,
FIRST_NAME_TOM,
FIRST_NAME_MARY,
FIRST_NAME_JULIE
),
SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US)
)
private val MENU_LABELS_SEARCH = listOf(SearchKey::ageMin.name, SearchKey::ageMax.name)
```
```
package com.example.nav3recipes.deeplink.basic.util
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.AbstractDecoder
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.modules.EmptySerializersModule
import kotlinx.serialization.modules.SerializersModule
/**
* Decodes the list of arguments into a a back stack key
*
* **IMPORTANT** This decoder assumes that all argument types are Primitives.
*/
@OptIn(ExperimentalSerializationApi::class)
internal class KeyDecoder(
private val arguments: Map<String, Any>,
) : AbstractDecoder() {
override val serializersModule: SerializersModule = EmptySerializersModule()
private var elementIndex: Int = -1
private var elementName: String = ""
/**
* Decodes the index of the next element to be decoded. Index represents a position of the
* current element in the [descriptor] that can be found with [descriptor].getElementIndex.
*
* The returned index will trigger deserializer to call [decodeValue] on the argument at that
* index.
*
* The decoder continually calls this method to process the next available argument until this
* method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more
* arguments to decode.
*
* This method should sequentially return the element index for every element that has its value
* available within [arguments].
*/
override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
var currentIndex = elementIndex
while (true) {
// proceed to next element
currentIndex++
// if we have reached the end, let decoder know there are not more arguments to decode
if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE
val currentName = descriptor.getElementName(currentIndex)
// Check if bundle has argument value. If so, we tell decoder to process
// currentIndex. Otherwise, we skip this index and proceed to next index.
if (arguments.contains(currentName)) {
elementIndex = currentIndex
elementName = currentName
return elementIndex
}
}
}
/**
* Returns argument value from the [arguments] for the argument at the index returned by
* [decodeElementIndex]
*/
override fun decodeValue(): Any {
val arg = arguments[elementName]
checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" }
return arg
}
override fun decodeNull(): Nothing? = null
// we want to know if it is not null, so its !isNull
override fun decodeNotNullMark(): Boolean = arguments[elementName] != null
}
```
```
package com.example.nav3recipes.deeplink.basic.util
import android.net.Uri
/**
* Parse the requested Uri and store it in a easily readable format
*
* @param uri the target deeplink uri to link to
*/
internal class DeepLinkRequest(
val uri: Uri
) {
/**
* A list of path segments
*/
val pathSegments: List<String> = uri.pathSegments
/**
* A map of query name to query value
*/
val queries = buildMap {
uri.queryParameterNames.forEach { argName ->
this[argName] = uri.getQueryParameter(argName)!!
}
}
// TODO add parsing for other Uri components, i.e. fragments, mimeType, action
}
```
````
package com.example.nav3recipes.deeplink.basic.util
import android.net.Uri
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.SerialKind
import kotlinx.serialization.encoding.CompositeDecoder
import java.io.Serializable
/**
* Parse a supported deeplink and stores its metadata as a easily readable format
*
* The following notes applies specifically to this particular sample implementation:
*
* The supported deeplink is expected to be built from a serializable backstack key [T] that
* supports deeplink. This means that if this deeplink contains any arguments (path or query),
* the argument name must match any of [T] member field name.
*
* One [DeepLinkPattern] should be created for each supported deeplink. This means if [T]
* supports two deeplink patterns:
* ```
* val deeplink1 = www.nav3recipes.com/home
* val deeplink2 = www.nav3recipes.com/profile/{userId}
* ```
* Then two [DeepLinkPattern] should be created
* ```
* val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1)
* val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2)
* ```
*
* This implementation assumes a few things:
* 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match
* 2. all query arguments are optional by way of nullable/has default value
*
* @param T the backstack key type that supports the deeplinking of [uriPattern]
* @param serializer the serializer of [T]
* @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}"
*/
internal class DeepLinkPattern<T : NavKey>(
val serializer: KSerializer<T>,
val uriPattern: Uri
) {
/**
* Help differentiate if a path segment is an argument or a static value
*/
private val regexPatternFillIn = Regex("\\{(.+?)\\}")
// TODO make these lazy
/**
* parse the path into a list of [PathSegment]
*
* order matters here - path segments need to match in value and order when matching
* requested deeplink to supported deeplink
*/
val pathSegments: List<PathSegment> = buildList {
uriPattern.pathSegments.forEach { segment ->
// first, check if it is a path arg
var result = regexPatternFillIn.find(segment)
if (result != null) {
// if so, extract the path arg name (the string value within the curly braces)
val argName = result.groups[1]!!.value
// from [T], read the primitive type of this argument to get the correct type parser
val elementIndex = serializer.descriptor.getElementIndex(argName)
if (elementIndex == CompositeDecoder.UNKNOWN_NAME) {
throw IllegalArgumentException(
"Path parameter '{$argName}' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'."
)
}
val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
// finally, add the arg name and its respective type parser to the map
add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind)))
} else {
// if its not a path arg, then its just a static string path segment
add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING)))
}
}
}
/**
* Parse supported queries into a map of queryParameterNames to [TypeParser]
*
* This will be used later on to parse a provided query value into the correct KType
*/
val queryValueParsers: Map<String, TypeParser> = buildMap {
uriPattern.queryParameterNames.forEach { paramName ->
val elementIndex = serializer.descriptor.getElementIndex(paramName)
// Ignore static query parameters that are not in the Serializable class
if (elementIndex != CompositeDecoder.UNKNOWN_NAME) {
val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
this[paramName] = getTypeParser(elementDescriptor.kind)
}
}
}
/**
* Metadata about a supported path segment
*/
class PathSegment(
val stringValue: String,
val isParamArg: Boolean,
val typeParser: TypeParser
)
}
/**
* Parses a String into a Serializable Primitive
*/
private typealias TypeParser = (String) -> Serializable
private fun getTypeParser(kind: SerialKind): TypeParser {
return when (kind) {
PrimitiveKind.STRING -> Any::toString
PrimitiveKind.INT -> String::toInt
PrimitiveKind.BOOLEAN -> String::toBoolean
PrimitiveKind.BYTE -> String::toByte
PrimitiveKind.CHAR -> String::toCharArray
PrimitiveKind.DOUBLE -> String::toDouble
PrimitiveKind.FLOAT -> String::toFloat
PrimitiveKind.LONG -> String::toLong
PrimitiveKind.SHORT -> String::toShort
else -> throw IllegalArgumentException(
"Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive."
)
}
}
````
```
package com.example.nav3recipes.deeplink.basic.util
import android.util.Log
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.KSerializer
internal class DeepLinkMatcher<T : NavKey>(
val request: DeepLinkRequest,
val deepLinkPattern: DeepLinkPattern<T>
) {
/**
* Match a [DeepLinkRequest] to a [DeepLinkPattern].
*
* Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
*/
fun match(): DeepLinkMatchResult<T>? {
if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null
if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null
if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null
// exact match (url does not contain any arguments)
if (request.uri == deepLinkPattern.uriPattern)
return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf())
val args = mutableMapOf<String, Any>()
// match the path
request.pathSegments
.asSequence()
// zip to compare the two objects side by side, order matters here so we
// need to make sure the compared segments are at the same position within the url
.zip(deepLinkPattern.pathSegments.asSequence())
.forEach { it ->
// retrieve the two path segments to compare
val requestedSegment = it.first
val candidateSegment = it.second
// if the potential match expects a path arg for this segment, try to parse the
// requested segment into the expected type
if (candidateSegment.isParamArg) {
val parsedValue = try {
candidateSegment.typeParser.invoke(requestedSegment)
} catch (e: IllegalArgumentException) {
Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e)
return null
}
args[candidateSegment.stringValue] = parsedValue
} else if(requestedSegment != candidateSegment.stringValue){
// if it's path arg is not the expected type, its not a match
return null
}
}
// match queries (if any)
request.queries.forEach { query ->
val name = query.key
// If the pattern does not define this query parameter, ignore it.
// This prevents a NullPointerException.
val queryStringParser = deepLinkPattern.queryValueParsers[name]?: return@forEach
val queryParsedValue = try {
queryStringParser.invoke(query.value)
} catch (e: IllegalArgumentException) {
Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e)
return null
}
args[name] = queryParsedValue
}
// provide the serializer of the matching key and map of arg names to parsed arg values
return DeepLinkMatchResult(deepLinkPattern.serializer, args)
}
}
/**
* Created when a requested deeplink matches with a supported deeplink
*
* @param [T] the backstack key associated with the deeplink that matched with the requested deeplink
* @param serializer serializer for [T]
* @param args The map of argument name to argument value. The value is expected to have already
* been parsed from the raw url string back into its proper KType as declared in [T].
* Includes arguments for all parts of the uri - path, query, etc.
* */
internal data class DeepLinkMatchResult<T : NavKey>(
val serializer: KSerializer<T>,
val args: Map<String, Any>
)
const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"
```
```
package com.example.nav3recipes.deeplink.basic.ui
import com.example.nav3recipes.deeplink.basic.SearchKey
/**
* String resources
*/
internal const val STRING_LITERAL_FILTER = "filter"
internal const val STRING_LITERAL_HOME = "home"
internal const val STRING_LITERAL_USERS = "users"
internal const val STRING_LITERAL_SEARCH = "search"
internal const val STRING_LITERAL_INCLUDE = "include"
internal const val PATH_BASE = "https://www.nav3recipes.com"
internal const val PATH_INCLUDE = "$STRING_LITERAL_USERS/$STRING_LITERAL_INCLUDE"
internal const val PATH_SEARCH = "$STRING_LITERAL_USERS/$STRING_LITERAL_SEARCH"
internal const val URL_HOME_EXACT = "$PATH_BASE/$STRING_LITERAL_HOME"
internal const val URL_USERS_WITH_FILTER = "$PATH_BASE/$PATH_INCLUDE/{$STRING_LITERAL_FILTER}"
internal val URL_SEARCH = "$PATH_BASE/$PATH_SEARCH" +
"?${SearchKey::ageMin.name}={${SearchKey::ageMin.name}}" +
"&${SearchKey::ageMax.name}={${SearchKey::ageMax.name}}" +
"&${SearchKey::firstName.name}={${SearchKey::firstName.name}}" +
"&${SearchKey::location.name}={${SearchKey::location.name}}"
```

View File

@@ -0,0 +1,107 @@
# Dialog Recipe
This recipe demonstrates how to display a destination as a dialog.
## How it works
To show a destination as a dialog, you need to do two things:
1. **Use `DialogSceneStrategy`** : Create an instance of `DialogSceneStrategy` and pass it to the `sceneStrategy` parameter of the `NavDisplay` composable.
2. **Add metadata to the destination** : For the destination that you want to display as a dialog, add `DialogSceneStrategy.dialog()` to its metadata. This is done in the `entry` function. You can also pass a `DialogProperties` object to customize the dialog's behavior and appearance.
In this example, `RouteB` is configured to be a dialog. When you navigate from `RouteA` to `RouteB`, `RouteB` will be displayed in a dialog window.
The content of the dialog can be styled as needed. In this recipe, the content is clipped to have rounded corners.
For more information, see the official documentation on [custom layouts](https://developer.android.com/guide/navigation/navigation-3/custom-layouts).
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/dialog)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.dialog
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.scene.DialogSceneStrategy
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
@Serializable
private data object RouteA : NavKey
@Serializable
private data class RouteB(val id: String) : NavKey
class DialogActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(RouteA)
val dialogStrategy = remember { DialogSceneStrategy<NavKey>() }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(dialogStrategy),
entryProvider = entryProvider {
entry<RouteA> {
ContentGreen("Welcome to Nav3") {
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("123"))
}) {
Text("Click to open dialog")
}
}
}
entry<RouteB>(
metadata = DialogSceneStrategy.dialog(
DialogProperties(windowTitle = "Route B dialog")
)
) { key ->
ContentBlue(
title = "Route id: ${key.id}",
modifier = Modifier.clip(
shape = RoundedCornerShape(16.dp)
)
)
}
}
)
}
}
}
```

View File

@@ -0,0 +1,141 @@
# Material List-Detail Recipe
This recipe demonstrates how to create an adaptive list-detail layout using the `ListDetailSceneStrategy` from the Material 3 Adaptive library. This layout automatically adjusts to show one, two, or three panes depending on the available screen width.
## How it works
This example has three destinations: `ConversationList`, `ConversationDetail`, and `Profile`.
### `ListDetailSceneStrategy`
The key to this recipe is the `rememberListDetailSceneStrategy`, which provides the logic for the adaptive layout.
- **Pane Roles**: Each destination is assigned a role using metadata:
- `ListDetailSceneStrategy.listPane()`: For the primary (list) content. This pane is always visible. A placeholder can be provided to be shown in the detail pane area when no detail content is selected.
- `ListDetailSceneStrategy.detailPane()`: For the secondary (detail) content.
- `ListDetailSceneStrategy.extraPane()`: For tertiary content.
- **Adaptive Layout** : The `ListDetailSceneStrategy` automatically handles the layout. On smaller screens, only one pane is shown at a time. On wider screens, it will show the list and detail panes side-by-side. On very wide screens, it can show all three panes: list, detail, and extra.
- **Navigation** : Navigation between the panes is handled by adding and removing destinations from the back stack as usual. The `ListDetailSceneStrategy` observes the back stack and adjusts the layout accordingly.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/material/listdetail)
```
package com.example.nav3recipes.material.listdetail
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentRed
import com.example.nav3recipes.content.ContentYellow
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
@Serializable
private object ConversationList : NavKey
@Serializable
private data class ConversationDetail(val id: String) : NavKey
@Serializable
private data object Profile : NavKey
class MaterialListDetailActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(ConversationList)
// Override the defaults so that there isn't a horizontal space between the panes.
// See b/418201867
val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()
val directive = remember(windowAdaptiveInfo) {
calculatePaneScaffoldDirective(windowAdaptiveInfo)
.copy(horizontalPartitionSpacerSize = 0.dp)
}
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>(directive = directive)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<ConversationList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = {
ContentYellow("Choose a conversation from the list")
}
)
) {
ContentRed("Welcome to Nav3") {
Button(onClick = dropUnlessResumed {
backStack.add(ConversationDetail("ABC"))
}) {
Text("View conversation")
}
}
}
entry<ConversationDetail>(
metadata = ListDetailSceneStrategy.detailPane()
) { conversation ->
ContentBlue("Conversation ${conversation.id} ") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = dropUnlessResumed {
backStack.add(Profile)
}) {
Text("View profile")
}
}
}
}
entry<Profile>(
metadata = ListDetailSceneStrategy.extraPane()
) {
ContentGreen("Profile")
}
}
)
}
}
}
```

View File

@@ -0,0 +1,145 @@
# Material Supporting Pane Recipe
This recipe demonstrates how to create an adaptive layout with a main pane and a supporting pane using the `SupportingPaneSceneStrategy` from the Material 3 Adaptive library. This layout is useful for displaying supplementary content alongside the main content on larger screens.
## How it works
This example has three destinations: `MainVideo`, `RelatedVideos`, and `Profile`.
### `SupportingPaneSceneStrategy`
The `rememberSupportingPaneSceneStrategy` provides the logic for this adaptive layout.
- **Pane Roles**: Each destination is assigned a role using metadata:
- `SupportingPaneSceneStrategy.mainPane()`: For the primary content. This pane is always visible.
- `SupportingPaneSceneStrategy.supportingPane()`: For the supplementary content. This pane is shown alongside the main pane on larger screens.
- `SupportingPaneSceneStrategy.extraPane()`: For tertiary content that can be displayed alongside the supporting pane on even larger screens.
- **Adaptive Layout** : The `SupportingPaneSceneStrategy` automatically handles the layout. On smaller screens, only the main pane is shown. On larger screens, the supporting pane is shown next to the main pane.
- **Back Navigation** : The `BackNavigationBehavior` is customized in this example to `PopUntilCurrentDestinationChange`. This means that when the user presses the back button, the supporting pane will be dismissed, revealing the main pane underneath.
- **Navigation** : Navigation is handled by adding and removing destinations from the back stack. The `SupportingPaneSceneStrategy` observes these changes and adjusts the layout accordingly.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/material/supportingpane)
```
package com.example.nav3recipes.material.supportingpane
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation3.SupportingPaneSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentRed
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
@Serializable
private object MainVideo : NavKey
@Serializable
private data object RelatedVideos : NavKey
@Serializable
private data object Profile : NavKey
class MaterialSupportingPaneActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(MainVideo)
// Override the defaults so that there isn't a horizontal or vertical space between the panes.
// See b/444438086
val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()
val directive = remember(windowAdaptiveInfo) {
calculatePaneScaffoldDirective(windowAdaptiveInfo)
.copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp)
}
// Override the defaults so that the supporting pane can be dismissed by pressing back.
// See b/445826749
val supportingPaneStrategy = rememberSupportingPaneSceneStrategy<NavKey>(
backNavigationBehavior = BackNavigationBehavior.PopUntilCurrentDestinationChange,
directive = directive
)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(supportingPaneStrategy),
entryProvider = entryProvider {
entry<MainVideo>(
metadata = SupportingPaneSceneStrategy.mainPane()
) {
ContentRed("Video content") {
Button(onClick = dropUnlessResumed {
backStack.add(RelatedVideos)
}) {
Text("View related videos")
}
}
}
entry<RelatedVideos>(
metadata = SupportingPaneSceneStrategy.supportingPane()
) {
ContentBlue("Related videos") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = dropUnlessResumed {
backStack.add(Profile)
}) {
Text("View profile")
}
}
}
}
entry<Profile>(
metadata = SupportingPaneSceneStrategy.extraPane()
) {
ContentGreen("Profile")
}
}
)
}
}
}
```

View File

@@ -0,0 +1,283 @@
# Modular Navigation Recipe (Hilt)
This recipe demonstrates how to structure a multi-module application using Navigation 3 and Dagger/Hilt for dependency injection. The goal is to create a decoupled architecture where navigation is defined and implemented in separate feature modules.
## How it works
The application is divided into several modules:
- **`app` module** : This is the main application module. It initializes a common `Navigator` and injects a set of `EntryProviderInstaller`s from the feature modules. It then uses these installers to build the final `entryProvider` for the `NavDisplay`.
- **`common` module**: This module contains the core navigation logic, including:
- A `Navigator` class that manages the back stack.
- An `EntryProviderInstaller` type, which is a function that feature modules use to contribute their navigation entries to the application's `entryProvider`.
- **Feature modules (e.g., `conversation`, `profile`)**: Each feature is split into two sub-modules:
- **`api` module**: Defines the public API for the feature, including its navigation routes. This allows other modules to navigate to this feature without needing to know about its implementation details.
- **`impl` module** : Provides the implementation of the feature, including its composables and an `EntryProviderInstaller` that maps the feature's routes to its composables. This installer is then provided to the `app` module using Dagger/Hilt.
This modular approach allows for a clean separation of concerns, making the codebase more scalable and maintainable. Each feature is responsible for its own navigation logic, and the `app` module only combines these pieces together.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/hilt)
```
package com.example.nav3recipes.modular.hilt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.multibindings.IntoSet
// API
object Profile
// IMPLEMENTATION
@Module
@InstallIn(ActivityRetainedComponent::class)
object ProfileModule {
@IntoSet
@Provides
fun provideEntryProviderInstaller() : EntryProviderInstaller = {
entry<Profile>{
ProfileScreen()
}
}
}
@Composable
private fun ProfileScreen() {
val profileColor = MaterialTheme.colorScheme.surfaceVariant
Column(
modifier = Modifier
.fillMaxSize()
.background(profileColor)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Profile Screen",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
```
```
package com.example.nav3recipes.modular.hilt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class HiltModularActivity : ComponentActivity() {
@Inject
lateinit var navigator: Navigator
@Inject
lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setEdgeToEdgeConfig()
setContent {
Scaffold { paddingValues ->
NavDisplay(
backStack = navigator.backStack,
modifier = Modifier.padding(paddingValues),
onBack = { navigator.goBack() },
entryProvider = entryProvider {
entryProviderScopes.forEach { builder -> this.builder() }
}
)
}
}
}
}
```
```
package com.example.nav3recipes.modular.hilt
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.ui.theme.colors
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.multibindings.IntoSet
// API
object ConversationList
data class ConversationDetail(val id: Int) {
val color: Color
get() = colors[id % colors.size]
}
// IMPL
@Module
@InstallIn(ActivityRetainedComponent::class)
object ConversationModule {
@IntoSet
@Provides
fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller =
{
entry<ConversationList> {
ConversationListScreen(
onConversationClicked = { conversationDetail ->
navigator.goTo(conversationDetail)
}
)
}
entry<ConversationDetail> { key ->
ConversationDetailScreen(key) { navigator.goTo(Profile) }
}
}
}
@Composable
private fun ConversationListScreen(
onConversationClicked: (ConversationDetail) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(10) { index ->
val conversationId = index + 1
val conversationDetail = ConversationDetail(conversationId)
val backgroundColor = conversationDetail.color
ListItem(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = dropUnlessResumed {
onConversationClicked(conversationDetail)
}),
headlineContent = {
Text(
text = "Conversation $conversationId",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
},
colors = ListItemDefaults.colors(
containerColor = backgroundColor // Set container color directly
)
)
}
}
}
@Composable
private fun ConversationDetailScreen(
conversationDetail: ConversationDetail,
onProfileClicked: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(conversationDetail.color)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Conversation Detail Screen: ${conversationDetail.id}",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = dropUnlessResumed(block = onProfileClicked)) {
Text("View Profile")
}
}
}
```
```
package com.example.nav3recipes.modular.hilt
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation3.runtime.EntryProviderScope
import dagger.hilt.android.scopes.ActivityRetainedScoped
typealias EntryProviderInstaller = EntryProviderScope<Any>.() -> Unit
@ActivityRetainedScoped
class Navigator(startDestination: Any) {
val backStack : SnapshotStateList<Any> = mutableStateListOf(startDestination)
fun goTo(destination: Any){
backStack.add(destination)
}
fun goBack(){
backStack.removeLastOrNull()
}
}
```
```
package com.example.nav3recipes.modular.hilt
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.hilt.android.scopes.ActivityRetainedScoped
@Module
@InstallIn(ActivityRetainedComponent::class)
object AppModule {
@Provides
@ActivityRetainedScoped
fun provideNavigator() : Navigator = Navigator(startDestination = ConversationList)
}
```

View File

@@ -0,0 +1,287 @@
# Modular Navigation Recipe (Koin)
This recipe demonstrates how to structure a multi-module application using Navigation 3 and Koin for dependency injection. The goal is to create a decoupled architecture where navigation is defined and implemented in separate feature modules. It relies on the [`koin-compose-navigation3`](https://insert-koin.io/docs/reference/koin-compose/navigation3) artifact.
## How it works
The application is divided into several Android modules:
- **`app` module** : This is the main application module. It `includes()` the feature modules and initializes a common `Navigator`.
- **`common` module** : This module contains the core navigation logic used by both the application module and the feature modules. Namely, it defines a `Navigator` class that manages the back stack.
- **Feature modules (e.g., `conversation`, `profile`)**: Each feature is split into two sub-modules:
- **`api` module**: Defines the public API for the feature, including its navigation routes. This allows other modules to navigate to this feature without needing to know about its implementation details.
- **`impl` module** : Provides the implementation of the feature, including its composables and Koin `Module`. The Koin module uses the [`navigation`](https://insert-koin.io/docs/reference/koin-compose/navigation3/#declaring-navigation-entries) DSL to define the entry provider installers for the feature module.
This modular approach allows for a clean separation of concerns, making the codebase more scalable and maintainable. Each feature is responsible for its own navigation logic, and the `app` module only combines these pieces together.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/koin)
```
package com.example.nav3recipes.modular.koin
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.koin.androidx.scope.dsl.activityRetainedScope
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.dsl.module
import org.koin.dsl.navigation3.navigation
// API
object Profile
// IMPL
@OptIn(KoinExperimentalAPI::class)
val profileModule = module {
activityRetainedScope {
navigation<Profile> { ProfileScreen() }
}
}
@Composable
private fun ProfileScreen() {
val profileColor = MaterialTheme.colorScheme.surfaceVariant
Column(
modifier = Modifier
.fillMaxSize()
.background(profileColor)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Profile Screen",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
```
```
package com.example.nav3recipes.modular.koin
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.ui.theme.colors
import org.koin.androidx.scope.dsl.activityRetainedScope
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.dsl.module
import org.koin.dsl.navigation3.navigation
// API
object ConversationList
data class ConversationDetail(val id: Int) {
val color: Color
get() = colors[id % colors.size]
}
// IMPL
@OptIn(KoinExperimentalAPI::class)
val conversationModule = module {
activityRetainedScope {
navigation<ConversationList> {
ConversationListScreen(
onConversationClicked = { conversationDetail ->
get<Navigator>().goTo(conversationDetail)
}
)
}
navigation<ConversationDetail> { key ->
ConversationDetailScreen(key) {
get<Navigator>().goTo(Profile)
}
}
}
}
@Composable
private fun ConversationListScreen(
onConversationClicked: (ConversationDetail) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(10) { index ->
val conversationId = index + 1
val conversationDetail = ConversationDetail(conversationId)
val backgroundColor = conversationDetail.color
ListItem(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = dropUnlessResumed {
onConversationClicked(conversationDetail)
}),
headlineContent = {
Text(
text = "Conversation $conversationId",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
},
colors = ListItemDefaults.colors(
containerColor = backgroundColor // Set container color directly
)
)
}
}
}
@Composable
private fun ConversationDetailScreen(
conversationDetail: ConversationDetail,
onProfileClicked: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(conversationDetail.color)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Conversation Detail Screen: ${conversationDetail.id}",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = dropUnlessResumed(block = onProfileClicked)) {
Text("View Profile")
}
}
}
```
```
package com.example.nav3recipes.modular.koin
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
class Navigator(startDestination: Any) {
val backStack : SnapshotStateList<Any> = mutableStateListOf(startDestination)
fun goTo(destination: Any){
backStack.add(destination)
}
fun goBack(){
backStack.removeLastOrNull()
}
}
```
```
package com.example.nav3recipes.modular.koin
import org.koin.androidx.scope.dsl.activityRetainedScope
import org.koin.dsl.module
val appModule = module {
includes(profileModule,conversationModule)
activityRetainedScope {
scoped {
Navigator(startDestination = ConversationList)
}
}
}
```
```
package com.example.nav3recipes.modular.koin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import org.koin.android.ext.android.inject
import org.koin.android.scope.AndroidScopeComponent
import org.koin.androidx.compose.navigation3.getEntryProvider
import org.koin.androidx.scope.activityRetainedScope
import org.koin.core.Koin
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.core.component.KoinComponent
import org.koin.core.scope.Scope
import org.koin.dsl.koinApplication
/**
* This recipe demonstrates how to use a modular approach with Navigation 3,
* where different parts of the application are defined in separate modules and injected
* into the main app using Koin.
*
* Features (Conversation and Profile) are split into two modules:
* - api: defines the public facing routes for this feature
* - impl: defines the entryProviders for this feature, these are injected into the app's main activity
* The common module defines:
* - a common navigator class that exposes a back stack and methods to modify that back stack
* - a type that should be used by feature modules to inject entryProviders into the app's main activity
* The app module creates the navigator by supplying a start destination and provides this navigator
* to the rest of the app module (i.e. MainActivity) and the feature modules.
*/
@OptIn(KoinExperimentalAPI::class)
class KoinModularActivity : ComponentActivity(), AndroidScopeComponent, KoinComponent {
// Local Koin Context Instance
companion object {
private val localKoin = koinApplication {
modules(appModule)
}.koin
}
// Override default Koin context to use the local one
override fun getKoin(): Koin = localKoin
override val scope : Scope by activityRetainedScope()
val navigator: Navigator by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setEdgeToEdgeConfig()
setContent {
Scaffold { paddingValues ->
NavDisplay(
backStack = navigator.backStack,
modifier = Modifier.padding(paddingValues),
onBack = { navigator.goBack() },
entryProvider = getEntryProvider()
)
}
}
}
}
```

View File

@@ -0,0 +1,436 @@
# Multiple back stacks recipe
This recipe demonstrates how to create multiple back stacks.
The app has three top level routes: `RouteA`, `RouteB` and `RouteC`. These routes have sub routes `RouteA1`, `RouteB1` and `RouteC1` respectively. The content for the sub routes is a counter that can be used to verify state retention through configuration changes and process death.
The app's navigation state is held in the `NavigationState` class. The state itself is created using `rememberNavigationState`.
Navigation events are handled by the `Navigator`. It updates the navigation state.
The navigation state is converted into `NavEntry`s with `NavigationState.toDecoratedEntries`. These entries are then displayed by `NavDisplay`.
Key behaviors:
- This app follows the "exit through home" pattern where the user always exits through the starting back stack. This means that `RouteA`'s entries are *always* in the list of entries.
- Navigating to a top level route that is not the starting route *replaces* the other entries. For example, navigating A-\>B-\>C would result in entries for A+C, B's entries are removed.
Important implementation details:
- Each top level route has its own `SaveableStateHolderNavEntryDecorator`. This is the object responsible for managing the state for the entries in its back stack.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/multiplestacks)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.multiplestacks
import androidx.navigation3.runtime.NavKey
/**
* Handles navigation events (forward and back) by updating the navigation state.
*/
class Navigator(val state: NavigationState){
fun navigate(route: NavKey){
if (route in state.backStacks.keys){
// This is a top level route, just switch to it
state.topLevelRoute = route
} else {
state.backStacks[state.topLevelRoute]?.add(route)
}
}
fun goBack(){
val currentStack = state.backStacks[state.topLevelRoute] ?:
error("Stack for ${state.topLevelRoute} not found")
val currentRoute = currentStack.last()
// If we're at the base of the current route, go back to the start route stack.
if (currentRoute == state.topLevelRoute){
state.topLevelRoute = state.startRoute
} else {
currentStack.removeLastOrNull()
}
}
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.multiplestacks
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer
/**
* Create a navigation state that persists config changes and process death.
*
* @param startRoute - The top level route to start on. This should also be in `topLevelRoutes`.
* @param topLevelRoutes - The top level routes in the app.
*/
@Composable
fun rememberNavigationState(
startRoute: NavKey,
topLevelRoutes: Set<NavKey>
): NavigationState {
val topLevelRoute = rememberSerializable(
startRoute, topLevelRoutes,
serializer = MutableStateSerializer(NavKeySerializer())
) {
mutableStateOf(startRoute)
}
// Create a back stack for each top level route.
val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }
return remember(startRoute, topLevelRoutes) {
NavigationState(
startRoute = startRoute,
topLevelRoute = topLevelRoute,
backStacks = backStacks
)
}
}
/**
* State holder for navigation state. This class does not modify its own state. It is designed
* to be modified using the `Navigator` class.
*
* @param startRoute - the start route. The user will exit the app through this route.
* @param topLevelRoute - the state object that backs the top level route.
* @param backStacks - the back stacks for each top level route.
*/
class NavigationState(
val startRoute: NavKey,
topLevelRoute: MutableState<NavKey>,
val backStacks: Map<NavKey, NavBackStack<NavKey>>
) {
/**
* The top level route.
*/
var topLevelRoute: NavKey by topLevelRoute
/**
* Convert the navigation state into `NavEntry`s that have been decorated with a
* `SaveableStateHolder`.
*
* @param entryProvider - the entry provider used to convert the keys in the
* back stacks to `NavEntry`s.
*/
@Composable
fun toDecoratedEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>
): List<NavEntry<NavKey>> {
// For each back stack, create a `SaveableStateHolder` decorator and use it to decorate
// the entries from that stack. When backStacks changes, `rememberDecoratedNavEntries` will
// be recomposed and a new list of decorated entries is returned.
val decoratedEntries = backStacks.mapValues { (_, stack) ->
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
)
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = decorators,
entryProvider = entryProvider
)
}
// Only return the entries for the stacks that are currently in use.
return getTopLevelRoutesInUse()
.flatMap { decoratedEntries[it] ?: emptyList() }
}
/**
* Get the top level routes that are currently in use. The start route is always the first route
* in the list. This means the user will always exit the app through the starting route
* ("exit through home" pattern). The list will contain a maximum of one other route. This is a
* design decision. In your app, you may wish to allow more than two top level routes to be
* active.
*
* Note that even if a top level route is not in use its state is still retained.
*
* @return the current top level routes that are in use.
*/
private fun getTopLevelRoutesInUse() : List<NavKey> =
if (topLevelRoute == startRoute) {
listOf(startRoute)
} else {
listOf(startRoute, topLevelRoute)
}
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.multiplestacks
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
@Serializable
data object RouteA : NavKey
@Serializable
data object RouteA1 : NavKey
@Serializable
data object RouteB : NavKey
@Serializable
data object RouteB1 : NavKey
@Serializable
data object RouteC : NavKey
@Serializable
data object RouteC1 : NavKey
private val TOP_LEVEL_ROUTES = mapOf<NavKey, NavBarItem>(
RouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"),
RouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"),
RouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"),
)
data class NavBarItem(
val icon: ImageVector,
val description: String
)
class MultipleStacksActivity : ComponentActivity() {
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val navigationState = rememberNavigationState(
startRoute = RouteA,
topLevelRoutes = TOP_LEVEL_ROUTES.keys
)
val navigator = remember { Navigator(navigationState) }
val entryProvider = entryProvider {
featureASection(onSubRouteClick = { navigator.navigate(RouteA1) })
featureBSection(onSubRouteClick = { navigator.navigate(RouteB1) })
featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) })
}
Scaffold(bottomBar = {
NavigationBar {
TOP_LEVEL_ROUTES.forEach { (key, value) ->
val isSelected = key == navigationState.topLevelRoute
NavigationBarItem(
selected = isSelected,
onClick = { navigator.navigate(key) },
icon = {
Icon(
imageVector = value.icon,
contentDescription = value.description
)
},
label = { Text(value.description) }
)
}
}
}) {
NavDisplay(
entries = navigationState.toDecoratedEntries(entryProvider),
onBack = { navigator.goBack() }
)
}
}
}
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.multiplestacks
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentMauve
import com.example.nav3recipes.content.ContentOrange
import com.example.nav3recipes.content.ContentPink
import com.example.nav3recipes.content.ContentPurple
import com.example.nav3recipes.content.ContentRed
fun EntryProviderScope<NavKey>.featureASection(
onSubRouteClick: () -> Unit,
) {
entry<RouteA> {
ContentRed("Route A") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = dropUnlessResumed(block = onSubRouteClick)) {
Text("Go to A1")
}
}
}
}
entry<RouteA1> {
ContentPink("Route A1") {
var count by rememberSaveable {
mutableIntStateOf(0)
}
Button(onClick = { count++ }) {
Text("Value: $count")
}
}
}
}
fun EntryProviderScope<NavKey>.featureBSection(
onSubRouteClick: () -> Unit,
) {
entry<RouteB> {
ContentGreen("Route B") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = dropUnlessResumed(block = onSubRouteClick)) {
Text("Go to B1")
}
}
}
}
entry<RouteB1> {
ContentPurple("Route B1") {
var count by rememberSaveable {
mutableIntStateOf(0)
}
Button(onClick = { count++ }) {
Text("Value: $count")
}
}
}
}
fun EntryProviderScope<NavKey>.featureCSection(
onSubRouteClick: () -> Unit,
) {
entry<RouteC> {
ContentMauve("Route C") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = dropUnlessResumed(block = onSubRouteClick)) {
Text("Go to C1")
}
}
}
}
entry<RouteC1> {
ContentOrange("Route C1") {
var count by rememberSaveable {
mutableIntStateOf(0)
}
Button(onClick = { count++ }) {
Text("Value: $count")
}
}
}
}
```

View File

@@ -0,0 +1,371 @@
# Passing Arguments to ViewModels (Hilt)
This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using Hilt for dependency injection.
## How it works
This example uses Dagger/Hilt's assisted injection feature:
1. The `ViewModel` is annotated with `@HiltViewModel` and its constructor uses `@AssistedInject` to receive the navigation key (which is annotated with `@Assisted`).
2. An `@AssistedFactory` interface is defined to create the `ViewModel`.
3. The `hiltViewModel` composable function is used to obtain the `ViewModel` instance. A `creationCallback` is provided to pass the navigation key to the factory, making it available to the `ViewModel`.
**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/hilt)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.passingarguments.viewmodels.hilt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.passingarguments.viewmodels.basic.RouteB
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
data object RouteA
data class RouteB(val id: String)
@AndroidEntryPoint
class HiltViewModelsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = remember { mutableStateListOf<Any>(RouteA) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
// In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why)
// we also need to add the default `NavEntryDecorator`s as well. These provide
// extra information to the entry's content to enable it to display correctly
// and save its state.
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<RouteA> {
ContentGreen("Welcome to Nav3") {
LazyColumn {
items(10) { i ->
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("$i"))
}) {
Text("$i")
}
}
}
}
}
entry<RouteB> { key ->
val viewModel = hiltViewModel<RouteBViewModel, RouteBViewModel.Factory>(
// Note: We need a new ViewModel for every new RouteB instance. Usually
// we would need to supply a `key` String that is unique to the
// instance, however, the ViewModelStoreNavEntryDecorator (supplied
// above) does this for us, using `NavEntry.contentKey` to uniquely
// identify the viewModel.
//
// tl;dr: Make sure you use rememberViewModelStoreNavEntryDecorator()
// if you want a new ViewModel for each new navigation key instance.
creationCallback = { factory ->
factory.create(key)
}
)
ScreenB(viewModel = viewModel)
}
}
)
}
}
}
@Composable
fun ScreenB(viewModel: RouteBViewModel) {
ContentBlue("Route id: ${viewModel.navKey.id} ")
}
@HiltViewModel(assistedFactory = RouteBViewModel.Factory::class)
class RouteBViewModel @AssistedInject constructor(
@Assisted val navKey: RouteB
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(navKey: RouteB): RouteBViewModel
}
}
```
# Passing Arguments to ViewModels (Basic)
This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using a custom `ViewModelProvider.Factory`.
## How it works
1. A custom `ViewModelProvider.Factory` is created that takes the navigation key as a constructor parameter.
2. Inside the `entry` composable, `viewModel(factory = ...)` is used to create the `ViewModel` instance, passing the current navigation key to the factory. This makes the navigation key available to the `ViewModel`.
**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/basic)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.passingarguments.viewmodels.basic
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
data object RouteA
data class RouteB(val id: String)
class BasicViewModelsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = remember { mutableStateListOf<Any>(RouteA) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
// In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why)
// we also need to add the default `NavEntryDecorator`s as well. These provide
// extra information to the entry's content to enable it to display correctly
// and save its state.
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<RouteA> {
ContentGreen("Welcome to Nav3") {
LazyColumn {
items(10) { i ->
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("$i"))
}) {
Text("$i")
}
}
}
}
}
entry<RouteB> { key ->
// Note: We need a new ViewModel for every new RouteB instance. Usually
// we would need to supply a `key` String that is unique to the
// instance, however, the ViewModelStoreNavEntryDecorator (supplied
// above) does this for us, using `NavEntry.contentKey` to uniquely
// identify the viewModel.
//
// tl;dr: Make sure you use rememberViewModelStoreNavEntryDecorator()
// if you want a new ViewModel for each new navigation key instance.
ScreenB(viewModel = viewModel(factory = RouteBViewModel.Factory(key)))
}
}
)
}
}
}
@Composable
fun ScreenB(viewModel: RouteBViewModel = viewModel()) {
ContentBlue("Route id: ${viewModel.key.id} ")
}
class RouteBViewModel(
val key: RouteB
) : ViewModel() {
class Factory(
private val key: RouteB,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RouteBViewModel(key) as T
}
}
}
```
# Passing Arguments to ViewModels (Koin)
This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using Koin for dependency injection.
## How it works
1. A Koin module is defined that provides the `ViewModel`.
2. The `koinViewModel` composable function is used to get the `ViewModel` instance.
3. The navigation key is passed to the `ViewModel`'s constructor using `parametersOf(key)`. This makes the navigation key available to the `ViewModel`.
**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/koin)
```
package com.example.nav3recipes.passingarguments.viewmodels.koin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import org.koin.compose.KoinApplication
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.core.parameter.parametersOf
import org.koin.dsl.koinConfiguration
import org.koin.dsl.module
data object RouteA
data class RouteB(val id: String)
class KoinViewModelsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = remember { mutableStateListOf<Any>(RouteA) }
// Koin Compose Entry point
KoinApplication(
configuration = koinConfiguration {
modules(appModule)
}
) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
// In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why)
// we also need to add the default `NavEntryDecorator`s as well. These provide
// extra information to the entry's content to enable it to display correctly
// and save its state.
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<RouteA> {
ContentGreen("Welcome to Nav3") {
LazyColumn {
items(10) { i ->
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("$i"))
}) {
Text("$i")
}
}
}
}
}
entry<RouteB> { key ->
val viewModel = koinViewModel<RouteBViewModel> {
parametersOf(key)
}
ScreenB(viewModel = viewModel)
}
}
)
}
}
}
}
// Local Koin Module
private val appModule = module {
viewModelOf(::RouteBViewModel)
}
@Composable
fun ScreenB(viewModel: RouteBViewModel) {
ContentBlue("Route id: ${viewModel.navKey.id} ")
}
class RouteBViewModel(val navKey: RouteB) : ViewModel()
```

View File

@@ -0,0 +1,273 @@
# Returning a Result (Event-Based)
This recipe demonstrates how to return a result from one screen to a previous screen using an event-based approach.
## How it works
This example uses a `ResultEventBus` to facilitate communication between the screens.
1. **ResultEventBusNavEntryDecorator** : A `NavEntryDecorator` that provides a `ResultEventBus` via `LocalResultEventBus`.
2. **`ResultEventBus`** : A `ResultEventBus` is created and made available to the composables via `LocalResultEventBus`. This EventBus sends and receives the results.
3. **Sending the result** : The screen that produces the result calls `resultBus.sendResult(person)` to send the data back as a one-time event.
4. **Receiving the result** : The screen that needs the result uses a `ResultEffect` composable to listen for results of a specific type. When a result is received, the effect's lambda is triggered.
This approach is useful for results that are transient and should be handled as one-time events.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/results/event)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.common
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
class HomeViewModel : ViewModel() {
var person by mutableStateOf<Person?>(null)
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.common
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
data object Home : NavKey
@Serializable
class PersonDetailsForm : NavKey
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.common
import kotlinx.serialization.Serializable
@Serializable
data class Person(val name: String, val favoriteColor: String)
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.common
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
@Composable
fun HomeScreen(
person: Person?,
onNext: () -> Unit
) {
ContentBlue("Hello ${person?.name ?: "unknown person"}") {
if (person != null) {
Text("Your favorite color is ${person.favoriteColor}")
}
Spacer(Modifier.height(16.dp))
Button(onClick = dropUnlessResumed(block = onNext)) {
Text("Tell us about yourself")
}
}
}
@Composable
fun PersonDetailsScreen(
onSubmit: (Person) -> Unit
) {
ContentGreen("About you") {
val nameTextState = rememberTextFieldState()
OutlinedTextField(
state = nameTextState,
label = { Text("Please enter your name") }
)
val favoriteColorTextState = rememberTextFieldState()
OutlinedTextField(
state = favoriteColorTextState,
label = { Text("Please enter your favorite color") }
)
Button(
onClick = dropUnlessResumed {
val person = Person(
name = nameTextState.text.toString(),
favoriteColor = favoriteColorTextState.text.toString()
)
onSubmit(person)
},
enabled = nameTextState.text.isNotBlank() &&
favoriteColorTextState.text.isNotBlank()
) {
Text("Submit")
}
}
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.event
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.result.LocalResultEventBus
import androidx.navigation3.runtime.result.ResultEffect
import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.results.common.Home
import com.example.nav3recipes.results.common.HomeScreen
import com.example.nav3recipes.results.common.HomeViewModel
import com.example.nav3recipes.results.common.Person
import com.example.nav3recipes.results.common.PersonDetailsForm
import com.example.nav3recipes.results.common.PersonDetailsScreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
class ResultEventActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
Scaffold { paddingValues ->
val backStack = rememberNavBackStack(Home)
NavDisplay(
backStack = backStack,
modifier = Modifier.padding(paddingValues),
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberResultEventBusNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Home> {
val viewModel = viewModel<HomeViewModel>(key = Home.toString())
ResultEffect<Person> { person ->
viewModel.person = person
}
val person = viewModel.person
HomeScreen(
person = person,
onNext = { backStack.add(PersonDetailsForm()) }
)
}
entry<PersonDetailsForm> {
val resultBus = LocalResultEventBus.current
PersonDetailsScreen(
onSubmit = { person ->
resultBus.sendResult<Person>(result = person)
backStack.removeLastOrNull()
}
)
}
}
)
}
}
}
}
```

View File

@@ -0,0 +1,266 @@
# Returning a Result (State-Based)
This recipe demonstrates how to return a result from one screen to a previous screen using a state-based approach.
## How it works
This example uses a `ResultEventBus` to manage the result as state.
1. **ResultEventBusNavEntryDecorator** : A `NavEntryDecorator` that provides a `ResultEventBus` via `LocalResultEventBus`.
2. **`ResultEventBus`** : A `ResultEventBus` is created and made available to the composables via `LocalResultEventBus`. This EventBus sends and receives the results.
3. **Setting the result** : The screen that produces the result calls `resultBus.sendResult(person)` to send the data back.
4. **Observing the result** : The screen that needs the result calls `resultBus.conflateAsState<Person?>()` to get a `State` object representing the result. The UI then observes this state and recomposes whenever the result changes.
This approach is suitable when only the latest result is required. The result state does not survive configuration change or process death.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/results/state)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.common
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
class HomeViewModel : ViewModel() {
var person by mutableStateOf<Person?>(null)
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.common
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
data object Home : NavKey
@Serializable
class PersonDetailsForm : NavKey
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.common
import kotlinx.serialization.Serializable
@Serializable
data class Person(val name: String, val favoriteColor: String)
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.common
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
@Composable
fun HomeScreen(
person: Person?,
onNext: () -> Unit
) {
ContentBlue("Hello ${person?.name ?: "unknown person"}") {
if (person != null) {
Text("Your favorite color is ${person.favoriteColor}")
}
Spacer(Modifier.height(16.dp))
Button(onClick = dropUnlessResumed(block = onNext)) {
Text("Tell us about yourself")
}
}
}
@Composable
fun PersonDetailsScreen(
onSubmit: (Person) -> Unit
) {
ContentGreen("About you") {
val nameTextState = rememberTextFieldState()
OutlinedTextField(
state = nameTextState,
label = { Text("Please enter your name") }
)
val favoriteColorTextState = rememberTextFieldState()
OutlinedTextField(
state = favoriteColorTextState,
label = { Text("Please enter your favorite color") }
)
Button(
onClick = dropUnlessResumed {
val person = Person(
name = nameTextState.text.toString(),
favoriteColor = favoriteColorTextState.text.toString()
)
onSubmit(person)
},
enabled = nameTextState.text.isNotBlank() &&
favoriteColorTextState.text.isNotBlank()
) {
Text("Submit")
}
}
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.results.state
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.result.LocalResultEventBus
import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.results.common.Home
import com.example.nav3recipes.results.common.HomeScreen
import com.example.nav3recipes.results.common.Person
import com.example.nav3recipes.results.common.PersonDetailsForm
import com.example.nav3recipes.results.common.PersonDetailsScreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
class ResultStateActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
Scaffold { paddingValues ->
val backStack = rememberNavBackStack(Home)
NavDisplay(
backStack = backStack,
modifier = Modifier.padding(paddingValues),
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberResultEventBusNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Home> {
val resultState = LocalResultEventBus
.current
.conflateAsState<Person?>(null)
val person = resultState.value
HomeScreen(
person = person,
onNext = { backStack.add(PersonDetailsForm()) }
)
}
entry<PersonDetailsForm> {
val resultBus = LocalResultEventBus.current
PersonDetailsScreen(
onSubmit = { person ->
resultBus.sendResult(result = person)
backStack.removeLastOrNull()
}
)
}
}
)
}
}
}
}
```

View File

@@ -0,0 +1,435 @@
# List-Detail Scene Recipe
This example shows how to create a list-detail layout using the Scenes API.
A `ListDetailSceneStrategy` will return a `ListDetailScene` if:
- the window width is over 600dp
- A `Detail` entry is the last item in the back stack
- A `List` entry is in the back stack
The `ListDetailScene` provides a `CompositionLocal` named `LocalBackButtonVisibility` that can be used by the detail `NavEntry` to control whether it displays a back button. This is useful when the detail entry usually displays a back button but should not display it when being displayed in a `ListDetailScene`. See <https://github.com/android/nav3-recipes/issues/151> for more details on this use case.
See `ListDetailScene.kt` for more implementation details.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/scenes/listdetail)
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.scenes.listdetail
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavMetadataKey
import androidx.navigation3.runtime.contains
import androidx.navigation3.runtime.metadata
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategy
import androidx.navigation3.scene.SceneStrategyScope
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
/**
* A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
*
*/
data class ListDetailScene<T : Any>(
override val key: Any,
override val previousEntries: List<NavEntry<T>>,
val listEntry: NavEntry<T>,
val detailEntry: NavEntry<T>,
) : Scene<T> {
override val entries: List<NavEntry<T>> = listOf(listEntry, detailEntry)
override val content: @Composable (() -> Unit) = {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(0.4f)) {
listEntry.Content()
}
// Let the detail entry know not to display a back button.
CompositionLocalProvider(LocalBackButtonVisibility provides false) {
Column(modifier = Modifier.weight(0.6f)) {
AnimatedContent(
targetState = detailEntry,
contentKey = { entry -> entry.contentKey },
transitionSpec = {
slideInHorizontally(
initialOffsetX = { it }
) togetherWith
slideOutHorizontally(targetOffsetX = { -it })
}
) { entry ->
entry.Content()
}
}
}
}
}
companion object {
/**
* Helper function to add metadata to a [NavEntry] indicating it can be displayed
* in the list pane of a [ListDetailScene].
*/
fun listPane() = metadata {
put(ListKey, true)
}
/**
* Helper function to add metadata to a [NavEntry] indicating it can be displayed
* in the detail pane of a the [ListDetailScene].
*/
fun detailPane() = metadata {
put(DetailKey, true)
}
}
object ListKey : NavMetadataKey<Boolean>
object DetailKey : NavMetadataKey<Boolean>
}
/**
* This `CompositionLocal` can be used by a detail `NavEntry` to decide whether to display
* a back button. Default is `true`. It is set to `false` for a detail `NavEntry` when being
* displayed in a `ListDetailScene`.
*/
val LocalBackButtonVisibility = compositionLocalOf { true }
@Composable
fun <T : Any> rememberListDetailSceneStrategy(): ListDetailSceneStrategy<T> {
val windowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass
return remember(windowSizeClass) {
ListDetailSceneStrategy(windowSizeClass)
}
}
/**
* A [SceneStrategy] that returns a [ListDetailScene] if:
*
* - the window width is over 600dp
* - A `Detail` entry is the last item in the back stack
* - A `List` entry is in the back stack
*
* Notably, when the detail entry changes the scene's key does not change. This allows the scene,
* rather than the NavDisplay, to handle animations when the detail entry changes.
*/
class ListDetailSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
return null
}
val detailEntry =
entries.lastOrNull()?.takeIf { it.metadata.contains(ListDetailScene.DetailKey) }
?: return null
val listEntry =
entries.findLast { it.metadata.contains(ListDetailScene.ListKey) } ?: return null
// We use the list's contentKey to uniquely identify the scene.
// This allows the detail panes to be animated in and out by the scene, rather than
// having NavDisplay animate the whole scene out when the selected detail item changes.
val sceneKey = listEntry.contentKey
return ListDetailScene(
key = sceneKey,
previousEntries = entries.dropLast(1),
listEntry = listEntry,
detailEntry = detailEntry
)
}
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.scenes.listdetail
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable
/**
* This example shows how to create a list-detail layout using the Scenes API.
*
* A `ListDetailScene` will render content in two panes if:
*
* - the window width is over 600dp
* - A `Detail` entry is the last item in the back stack
* - A `List` entry is in the back stack
*
* @see `ListDetailScene`
*/
@Serializable
data object ConversationList : NavKey
@Serializable
data class ConversationDetail(
val id: Int,
val colorId: Int
) : NavKey
@Serializable
data object Profile : NavKey
class ListDetailActivity : ComponentActivity() {
@OptIn(ExperimentalSharedTransitionApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
Scaffold { paddingValues ->
val backStack = rememberNavBackStack(ConversationList)
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
SharedTransitionLayout {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(listDetailStrategy),
sharedTransitionScope = this,
modifier = Modifier.padding(paddingValues),
entryProvider = entryProvider {
entry<ConversationList>(
metadata = ListDetailScene.listPane()
) {
ConversationListScreen(
onConversationClicked = { detailRoute ->
backStack.addDetail(detailRoute)
}
)
}
entry<ConversationDetail>(
metadata = ListDetailScene.detailPane()
) { conversationDetail ->
ConversationDetailScreen(
conversationDetail = conversationDetail,
onBack = { backStack.removeLastOrNull() },
onProfileClicked = { backStack.add(Profile) }
)
}
entry<Profile> {
ProfileScreen()
}
}
)
}
}
}
}
}
private fun NavBackStack<NavKey>.addDetail(detailRoute: ConversationDetail) {
// Remove any existing detail routes before adding this detail route.
// In certain scenarios, such as when multiple detail panes can be shown at once, it may
// be desirable to keep existing detail routes on the back stack.
removeIf { it is ConversationDetail }
add(detailRoute)
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.scenes.listdetail
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.ui.theme.colors
@Composable
fun ConversationListScreen(
onConversationClicked: (ConversationDetail) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
) {
items(10) { index ->
val conversationId = index + 1
val conversationDetail = ConversationDetail(
id = conversationId,
colorId = conversationId % colors.size
)
val backgroundColor = colors[conversationDetail.colorId]
ListItem(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = dropUnlessResumed {
onConversationClicked(conversationDetail)
}),
headlineContent = {
Text(
text = "Conversation $conversationId",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
},
colors = ListItemDefaults.colors(
containerColor = backgroundColor // Set container color directly
)
)
}
}
}
@Composable
fun ConversationDetailScreen(
conversationDetail: ConversationDetail,
onBack: () -> Unit,
onProfileClicked: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(colors[conversationDetail.colorId])
.padding(16.dp)
) {
if (LocalBackButtonVisibility.current) {
IconButton(
onClick = onBack,
modifier = Modifier.align(Alignment.TopStart)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Conversation Detail Screen: ${conversationDetail.id}",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = dropUnlessResumed(block = onProfileClicked)) {
Text("View Profile")
}
}
}
}
@Composable
fun ProfileScreen() {
val profileColor = MaterialTheme.colorScheme.surfaceVariant
Column(
modifier = Modifier
.fillMaxSize()
.background(profileColor)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Profile Screen",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
```

View File

@@ -0,0 +1,244 @@
# Two-Pane Scene Recipe
This example shows how to create a two pane layout using the Scenes API.
A `TwoPaneSceneStrategy` will return a `TwoPaneScene` if:
- the window width is over 600dp
- the last two nav entries on the back stack have indicated that they support being displayed in a `TwoPaneScene` in their metadata.
See `TwoPaneScene.kt` for more implementation details.
[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/scenes/twopane)
```
package com.example.nav3recipes.scenes.twopane
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavMetadataKey
import androidx.navigation3.runtime.contains
import androidx.navigation3.runtime.metadata
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategy
import androidx.navigation3.scene.SceneStrategyScope
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
// --- TwoPaneScene ---
/**
* A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split.
*/
data class TwoPaneScene<T : Any>(
override val key: Any,
override val previousEntries: List<NavEntry<T>>,
val firstEntry: NavEntry<T>,
val secondEntry: NavEntry<T>
) : Scene<T> {
override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry)
override val content: @Composable (() -> Unit) = {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(0.5f)) {
firstEntry.Content()
}
Column(modifier = Modifier.weight(0.5f)) {
secondEntry.Content()
}
}
}
companion object {
/**
* Helper function to add metadata to a [NavEntry] indicating it can be displayed
* in a two-pane layout.
*/
fun twoPane() = metadata {
put(TwoPaneKey, true)
}
}
object TwoPaneKey : NavMetadataKey<Boolean>
}
@Composable
fun <T : Any> rememberTwoPaneSceneStrategy(): TwoPaneSceneStrategy<T> {
val windowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass
return remember(windowSizeClass) {
TwoPaneSceneStrategy(windowSizeClass)
}
}
// --- TwoPaneSceneStrategy ---
/**
* A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough
* and the top two back stack entries declare support for two-pane display.
*/
class TwoPaneSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
// Condition 1: Only return a Scene if the window is sufficiently wide to render two panes.
// We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp).
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
return null
}
val lastTwoEntries = entries.takeLast(2)
// Condition 2: Only return a Scene if there are two entries, and both have declared
// they can be displayed in a two pane scene.
return if (lastTwoEntries.size == 2
&& lastTwoEntries.all { it.metadata.contains(TwoPaneScene.TwoPaneKey) }
) {
val firstEntry = lastTwoEntries.first()
val secondEntry = lastTwoEntries.last()
// The scene key must uniquely represent the state of the scene.
// A Pair of the first and second entry keys ensures uniqueness.
val sceneKey = Pair(firstEntry.contentKey, secondEntry.contentKey)
TwoPaneScene(
key = sceneKey,
// Where we go back to is a UX decision. In this case, we only remove the top
// entry from the back stack, despite displaying two entries in this scene.
// This is because in this app we only ever add one entry to the
// back stack at a time. It would therefore be confusing to the user to add one
// when navigating forward, but remove two when navigating back.
previousEntries = entries.dropLast(1),
firstEntry = firstEntry,
secondEntry = secondEntry
)
} else {
null
}
}
}
```
```
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.nav3recipes.scenes.twopane
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBase
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentRed
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import com.example.nav3recipes.ui.theme.colors
import kotlinx.serialization.Serializable
@Serializable
private object Home : NavKey
@Serializable
private data class Product(val id: Int) : NavKey
@Serializable
private data object Profile : NavKey
class TwoPaneActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setEdgeToEdgeConfig()
super.onCreate(savedInstanceState)
setContent {
val backStack = rememberNavBackStack(Home)
val twoPaneStrategy = rememberTwoPaneSceneStrategy<NavKey>()
SharedTransitionLayout {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(twoPaneStrategy),
sharedTransitionScope = this,
entryProvider = entryProvider {
entry<Home>(
metadata = TwoPaneScene.twoPane()
) {
ContentRed("Welcome to Nav3") {
Button(onClick = { backStack.addProductRoute(1) }) {
Text("View the first product")
}
}
}
entry<Product>(
metadata = TwoPaneScene.twoPane()
) { product ->
ContentBase(
"Product ${product.id} ",
Modifier.background(colors[product.id % colors.size])
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = dropUnlessResumed {
backStack.addProductRoute(product.id + 1)
}) {
Text("View the next product")
}
Button(onClick = dropUnlessResumed {
backStack.add(Profile)
}) {
Text("View profile")
}
}
}
}
entry<Profile> {
ContentGreen("Profile (single pane only)")
}
}
)
}
}
}
}
private fun NavBackStack<NavKey>.addProductRoute(productId: Int) {
val productRoute =
Product(productId)
// Avoid adding the same product route to the back stack twice.
if (!contains(productRoute)) {
add(productRoute)
}
}
```

View File

@@ -0,0 +1,129 @@
This guide outlines the process of replacing string-based routes with
serializable Kotlin types to achieve compile-time safety and eliminate runtime
crashes caused by typos or incorrect argument types.
## Prerequisites
Before starting the migration, verify that your project meets the following
requirements:
1. **Navigation version**: Update to Jetpack Navigation 2.8.0 or higher
2. **Kotlin serialization plugin**:
3. Add the plugin to `libs.versions.toml`:
[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
- Add the dependencies to your top-level `build.gradle.kts` and module-level `build.gradle.kts`.
## Step 1: Define Your Destinations
Replace your constant route strings with `@Serializable` objects and classes.
- **For screens without arguments** : Use a `data object`
- **For screens with arguments** : Use a `data class`
**Before (string based):**
const val ROUTE_HOME = "home"
const val ROUTE_PROFILE = "profile/{userId}"
**After (type safe):**
import kotlinx.serialization.Serializable
@Serializable
object Home
@Serializable
data class Profile(val userId: String)
## Step 2: Update the NavHost Configuration
Update your `NavHost` to use the new generic types in the `composable` and
`dialog` function.
**Before:**
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(...) }
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
ProfileScreen(userId)
}
}
**After:**
NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen(...)
}
composable<Profile> { backStackEntry ->
// The library automatically handles argument extraction
val profile: Profile = backStackEntry.toRoute()
ProfileScreen(profile.userId)
}
}
## Step 3: Implement Type-Safe Navigation Calls
Replace string-interpolated navigation calls with class instances.
**Before:**
navController.navigate("profile/user123")
**After:**
navController.navigate(Profile(userId = "user123"))
## Step 4: Accessing Arguments in ViewModels
If you use a `ViewModel`, you can now extract the route object directly from the
`SavedStateHandle`.
**Implementation:**
class ProfileViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
// Automatically parses arguments into the Profile class
private val profile = savedStateHandle.toRoute<Profile>()
val userId = profile.userId
}
## Step 5: (Advanced) Handling Custom Types
If you need to pass complex data classes (not just primitives), you must define
a custom `NavType`.
1. **Create the Custom Type** : \`\`\`kotlin val SearchFilterType = object : NavType(isNullableAllowed = false) { override fun get(bundle: Bundle, key: String): SearchFilter? = Json.decodeFromString(bundle.getString(key) ?: return null)
override fun parseValue(value: String): SearchFilter =
Json.decodeFromString(Uri.decode(value))
override fun put(bundle: Bundle, key: String, value: SearchFilter) {
bundle.putString(key, Json.encodeToString(value))
}
}
2. **Register it in the Graph**:
```kotlin
composable<Search>(
typeMap = mapOf(typeOf<SearchFilter>() to SearchFilterType)
) { ... }
## Best practices and tips
- **Sealed Hierarchies**: For large apps, group your routes using a sealed interface or class to keep the navigation structure organized
- **Object Instances** : For routes without parameters, always use `object` instead of `class` to avoid unnecessary allocations
- **Nullable Types** : The new API supports nullable types (for example, `data
class Search(val query: String?)`) and provides default values automatically
- **Testing** : Use `navController.currentBackStackEntry?.hasRoute<T>()` to check the current destination in a type-safe manner during UI tests