From 57b4af6e2500f7a36f1894da8c3a984c455235a9 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Fri, 7 Nov 2025 17:59:35 +0000 Subject: [PATCH 01/13] Refactor: Introduce Navigator for multiple back stacks This commit refactors the common UI recipe to use a new `Navigator` class for managing navigation state, specifically for handling multiple back stacks. The `Navigator` class encapsulates the logic for maintaining separate back stacks for each top-level route and handles state persistence through a custom `Saver`. It exposes a Composable `entries()` function to provide the `NavEntry` list for `NavDisplay`. Key changes: - `Navigator.kt`: New class to manage navigation state, including stacks for top-level routes and navigation actions (`navigate`, `goBack`). - `CommonUiActivity.kt`: Refactored to use the `rememberNavigator` composable. The previous custom `TopLevelBackStack` implementation has been removed. - `Content.kt`: New file containing composable `entryProvider` extensions (`featureASection`, `featureBSection`, etc.) to define the content for different routes. - `README.md`: Added a README for the `commonui` recipe explaining the new implementation using the `Navigator` class. --- .../nav3recipes/commonui/CommonUiActivity.kt | 189 ++++++----------- .../example/nav3recipes/commonui/Content.kt | 107 ++++++++++ .../example/nav3recipes/commonui/Navigator.kt | 199 ++++++++++++++++++ .../example/nav3recipes/commonui/README.md | 23 ++ 4 files changed, 394 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/com/example/nav3recipes/commonui/Content.kt create mode 100644 app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt create mode 100644 app/src/main/java/com/example/nav3recipes/commonui/README.md diff --git a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt index bd3d014..73d9fdb 100644 --- a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt @@ -19,158 +19,99 @@ package com.example.nav3recipes.commonui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding 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.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.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberDecoratedNavEntries +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.content.ContentPurple -import com.example.nav3recipes.content.ContentRed import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable -/** - * Common navigation UI example. This app has three top level routes: Home, ChatList and Camera. - * ChatList has a sub-route: ChatDetail. - * - * The app back stack state is modeled in `TopLevelBackStack`. This creates a back stack for each - * top level route. It flattens those maps to create a single back stack for `NavDisplay`. This allows - * `NavDisplay` to know where to go back to. - * - * Note that 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. - */ -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 } +@Serializable +data object RouteA : Route.TopLevel() + +@Serializable +data object RouteA1 : Route() + +@Serializable +data object RouteB : Route.TopLevel() + +@Serializable +data class RouteB1(val id: String) : Route() + +@Serializable +data object RouteC : Route.TopLevel() + +@Serializable +data object RouteC1 : Route() + + +private val TOP_LEVEL_ROUTES = mapOf( + 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 +) -private val TOP_LEVEL_ROUTES : List = listOf(Home, ChatList, Camera) class CommonUiActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { - val topLevelBackStack = remember { TopLevelBackStack(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 - ) - } - ) - } + val navigator = rememberNavigator( + startRoute = RouteA, + topLevelRoutes = TOP_LEVEL_ROUTES.keys, + ) + + val entryProvider = entryProvider { + featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) + featureBSection(onDetailClick = { id -> navigator.navigate(RouteB1(id)) }) + featureCSection(onSubRouteClick = { navigator.navigate(RouteA1) }) + } + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = key == navigator.topLevelRoute + NavigationBarItem( + selected = isSelected, + onClick = { navigator.navigate(key) }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) } } - ) { _ -> + }) { paddingValues -> NavDisplay( - backStack = topLevelBackStack.backStack, - onBack = { topLevelBackStack.removeLast() }, - entryProvider = entryProvider { - entry{ - ContentRed("Home screen") - } - entry{ - ContentGreen("Chat list screen"){ - Button(onClick = { topLevelBackStack.add(ChatDetail) }) { - Text("Go to conversation") - } - } - } - entry{ - ContentBlue("Chat detail screen") - } - entry{ - ContentPurple("Camera screen") - } - }, + entries = navigator.entries(entryProvider), + onBack = { navigator.goBack() }, + modifier = Modifier.padding(paddingValues) ) } } } } -class TopLevelBackStack(startKey: T) { - - // Maintain a stack for each top level route - private var topLevelStacks : LinkedHashMap> = 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() - } -} diff --git a/app/src/main/java/com/example/nav3recipes/commonui/Content.kt b/app/src/main/java/com/example/nav3recipes/commonui/Content.kt new file mode 100644 index 0000000..60c66dd --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/commonui/Content.kt @@ -0,0 +1,107 @@ +/* + * 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 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.navigation3.runtime.EntryProviderScope +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.featureASection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + } + } + } + entry { + ContentPink("Route A1 title") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + + Button(onClick = { count++ }) { + Text("Value: $count") + } + } + } +} + +fun EntryProviderScope.featureBSection( + onDetailClick: (id: String) -> Unit, +) { + entry { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + } + } + } + entry { key -> + ContentPurple("Route B1 title. ID: ${key.id}") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + Button(onClick = { count++ }) { + Text("Value: $count") + } + } + } +} + +fun EntryProviderScope.featureCSection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Open sub route") + } + } + } + } + entry { + ContentOrange("Route C1 title") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + + Button(onClick = { count++ }) { + Text("Value: $count") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt b/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt new file mode 100644 index 0000000..1d3bfde --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt @@ -0,0 +1,199 @@ +/* + * 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.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.savedstate.SavedState +import androidx.savedstate.read +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import androidx.savedstate.write +import kotlinx.serialization.Serializable + +/** + * Create a Navigator that is saved and restored through config changes and process death. + */ +@Composable +fun rememberNavigator( + startRoute: Route, + topLevelRoutes: Set, +) : Navigator { + + return rememberSaveable(saver = Navigator.Saver) { + Navigator( + startRoute = startRoute, + topLevelRoutes = topLevelRoutes, + ) + } +} + +/** + * Navigator object that manages navigation state and provides `NavEntry`s for that state. + */ +@SuppressLint("RestrictedApi") +class Navigator( + val startRoute: Route, + val topLevelRoutes: Set +) { + + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Maintain a stack for each top level route + val topLevelStacks = topLevelRoutes.associateWith { route -> mutableStateListOf(route) }.toMutableMap() + + /** + * Navigate to the given route. + */ + fun navigate(route: Route) { + if (route is Route.TopLevel) { + topLevelRoute = route + } else { + topLevelStacks[topLevelRoute]?.add(route) + } + } + + /** + * Go back to the previous route. + */ + fun goBack() { + + val currentStack = topLevelStacks[topLevelRoute] ?: + error("Stack for $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 == topLevelRoute){ + topLevelRoute = startRoute + } else { + currentStack.removeLast() + } + } + + /** + * Get the NavEntries for the current navigation state. + */ + @Composable + fun entries( + entryProvider: (Route) -> NavEntry + ): SnapshotStateList> { + + val decoratedEntries = topLevelStacks.mapValues { (_, stack) -> + + val decorators = listOf>( + rememberSaveableStateHolderNavEntryDecorator(), + ) + + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + val stacksToUse = mutableListOf(startRoute) + if (topLevelRoute != startRoute) stacksToUse += topLevelRoute + + return stacksToUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() + } + + companion object { + private const val KEY_START_ROUTE = "start_route" + private const val KEY_TOP_LEVEL_ROUTE = "top_level_route" + private const val KEY_TOP_LEVEL_STACK_IDS = "top_level_stack_ids" + private const val KEY_TOP_LEVEL_STACK_KEY_PREFIX = "top_level_stack_key_" + private const val KEY_TOP_LEVEL_STACK_VALUES_PREFIX = "top_level_stack_values_" + + val Saver = Saver( + save = { navigator -> + val savedState = SavedState() + savedState.write { + putSavedState(KEY_START_ROUTE, encodeToSavedState(navigator.startRoute)) + putSavedState(KEY_TOP_LEVEL_ROUTE, encodeToSavedState(navigator.topLevelRoute)) + + var id = 0 + val ids = mutableListOf() + + for ((key, stackValues) in navigator.topLevelStacks) { + putSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id", encodeToSavedState(key)) + putSavedStateList( + "$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id", + stackValues.map { encodeToSavedState(it) }) + ids.add(id) + id++ + } + + putIntList(KEY_TOP_LEVEL_STACK_IDS, ids) + } + savedState + }, + restore = { savedState -> + savedState.read { + val restoredStartRoute = + decodeFromSavedState(getSavedState(KEY_START_ROUTE)) + + val topLevelRoutes = mutableSetOf() + val topLevelStacks = mutableMapOf>() + + val ids = getIntList(KEY_TOP_LEVEL_STACK_IDS) + for (id in ids) { + // get the top level key and the keys on the stack + val key: Route = + decodeFromSavedState(getSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id")) + topLevelRoutes.add(key) + topLevelStacks[key] = getSavedStateList("$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id") + .map { decodeFromSavedState(it) } + } + + val navigator = Navigator( + startRoute = restoredStartRoute, + topLevelRoutes = topLevelRoutes, + ) + + navigator.topLevelStacks.clear() + topLevelStacks.forEach { (key, value) -> + navigator.topLevelStacks[key] = value.toMutableStateList() + } + + navigator.topLevelRoute = + decodeFromSavedState(getSavedState(KEY_TOP_LEVEL_ROUTE)) + navigator + } + } + ) + } +} + +@Serializable +sealed class Route { + sealed class TopLevel : Route() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/commonui/README.md b/app/src/main/java/com/example/nav3recipes/commonui/README.md new file mode 100644 index 0000000..ae4dcaa --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/commonui/README.md @@ -0,0 +1,23 @@ +# Common navigation UI / Multiple back stacks recipe # + +This recipe demonstrates how to create top level routes with their own back stack. + +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 managed by the `Navigator` class. This maintains a back stack for each top level route and holds the logic for navigating within and between these back stacks. + +The state for each `NavEntry` is retained while its key is in the associated back stack. This means that even when the top level route changes, the state for entries in other back stacks will be retained. + +The `Navigator` class is split into two areas of responsibility: + +- **Managing navigation state**. This is done in pure Kotlin, no Composable functions. A `Saver` allows the navigation state to be saved and restored. +- **Providing UI**. The `NavEntry`s for the current navigation state are provided using the `entries` Composable function. This can be used directly with `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. From 91877ad493d9e4fca6502b694e2a71d5281f061a Mon Sep 17 00:00:00 2001 From: Don Turner Date: Fri, 7 Nov 2025 18:05:41 +0000 Subject: [PATCH 02/13] Remove unused imports This commit removes unused imports from `CommonUiActivity`. --- .../java/com/example/nav3recipes/commonui/CommonUiActivity.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt index 73d9fdb..299662d 100644 --- a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt @@ -32,8 +32,6 @@ import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberDecoratedNavEntries -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.ui.setEdgeToEdgeConfig import kotlinx.serialization.Serializable From 364d45a0e737745b20436466c0abf308c38b2dcc Mon Sep 17 00:00:00 2001 From: Don Turner Date: Fri, 7 Nov 2025 18:08:22 +0000 Subject: [PATCH 03/13] Update README for multiple back stacks recipe The "Common navigation UI" recipe has been renamed to "Multiple back stacks / Common navigation UI" to better reflect its functionality. The description has been expanded to clarify that it demonstrates creating multiple top-level routes, each with its own back stack and retained state. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc5c320..fe9dd2e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Examples showing how to use the layouts provided by the [Compose Material3 Adapt - **[Animations](app/src/main/java/com/example/nav3recipes/animations)**: Shows how to override the default animations for all destinations and a single destination. ### Common use cases -- **[Common navigation UI](app/src/main/java/com/example/nav3recipes/commonui)**: A common navigation toolbar where each item in the toolbar navigates to a top level destination. +- **[Multiple back stacks / Common navigation UI](app/src/main/java/com/example/nav3recipes/commonui)**: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death. - **[Conditional navigation](app/src/main/java/com/example/nav3recipes/conditional)**: Switch to a different navigation flow when a condition is met. For example, for authentication or first-time user onboarding. ### Architecture From f1335bb9441e978dba85f9184497842dac1d82c9 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Fri, 7 Nov 2025 18:09:39 +0000 Subject: [PATCH 04/13] Update app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../java/com/example/nav3recipes/commonui/CommonUiActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt index 299662d..c77a838 100644 --- a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt @@ -83,7 +83,7 @@ class CommonUiActivity : ComponentActivity() { featureBSection(onDetailClick = { id -> navigator.navigate(RouteB1(id)) }) featureCSection(onSubRouteClick = { navigator.navigate(RouteA1) }) } - + featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) }) Scaffold(bottomBar = { NavigationBar { TOP_LEVEL_ROUTES.forEach { (key, value) -> From f7b83c710b40e45452ec580b349ffa033284e6b9 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Fri, 7 Nov 2025 18:09:46 +0000 Subject: [PATCH 05/13] Update app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt b/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt index 1d3bfde..59c8573 100644 --- a/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt +++ b/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt @@ -107,7 +107,7 @@ class Navigator( val decoratedEntries = topLevelStacks.mapValues { (_, stack) -> - val decorators = listOf>( + val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), ) From e25b93f6bedb3aadba4e257488931201f298c9b8 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Fri, 7 Nov 2025 18:24:55 +0000 Subject: [PATCH 06/13] Refactor Navigator to be more testable and fix navigation bug The `Navigator` class has been refactored to make its state restoration logic more robust and to improve its testability. The primary constructor is now private and a new public secondary constructor has been added. This change allows for the injection of initial state (`initialTopLevelRoute` and `initialTopLevelStacks`) during instantiation, which simplifies state restoration from a `SavedState` and makes the class easier to unit test. The state restoration logic within the `Saver` has been updated to use this new constructor, streamlining the process. Additionally, a bug in `CommonUiActivity` where the "Feature C" section incorrectly navigated to `RouteA1` has been fixed to correctly navigate to `RouteC1`. --- .../nav3recipes/commonui/CommonUiActivity.kt | 4 +- .../example/nav3recipes/commonui/Navigator.kt | 39 +++++++++++-------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt index c77a838..d723cd8 100644 --- a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt @@ -81,9 +81,9 @@ class CommonUiActivity : ComponentActivity() { val entryProvider = entryProvider { featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) featureBSection(onDetailClick = { id -> navigator.navigate(RouteB1(id)) }) - featureCSection(onSubRouteClick = { navigator.navigate(RouteA1) }) - } featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) }) + } + Scaffold(bottomBar = { NavigationBar { TOP_LEVEL_ROUTES.forEach { (key, value) -> diff --git a/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt b/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt index 59c8573..f200762 100644 --- a/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt +++ b/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavEntryDecorator import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.savedstate.SavedState @@ -58,16 +57,29 @@ fun rememberNavigator( * Navigator object that manages navigation state and provides `NavEntry`s for that state. */ @SuppressLint("RestrictedApi") -class Navigator( +class Navigator private constructor( val startRoute: Route, - val topLevelRoutes: Set + val topLevelRoutes: Set, + initialTopLevelRoute: Route, + initialTopLevelStacks: Map> ) { - var topLevelRoute by mutableStateOf(startRoute) + constructor( + startRoute: Route, + topLevelRoutes: Set + ) : this( + startRoute = startRoute, + topLevelRoutes = topLevelRoutes, + initialTopLevelRoute = startRoute, + initialTopLevelStacks = topLevelRoutes.associateWith { route -> listOf(route) } + ) + + var topLevelRoute by mutableStateOf(initialTopLevelRoute) private set // Maintain a stack for each top level route - val topLevelStacks = topLevelRoutes.associateWith { route -> mutableStateListOf(route) }.toMutableMap() + val topLevelStacks : Map> = + initialTopLevelStacks.mapValues { (_, values) -> values.toMutableStateList() } /** * Navigate to the given route. @@ -108,7 +120,7 @@ class Navigator( val decoratedEntries = topLevelStacks.mapValues { (_, stack) -> val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), + rememberSaveableStateHolderNavEntryDecorator(), ) rememberDecoratedNavEntries( @@ -160,6 +172,8 @@ class Navigator( savedState.read { val restoredStartRoute = decodeFromSavedState(getSavedState(KEY_START_ROUTE)) + val restoredTopLevelRoute = + decodeFromSavedState(getSavedState(KEY_TOP_LEVEL_ROUTE)) val topLevelRoutes = mutableSetOf() val topLevelStacks = mutableMapOf>() @@ -174,19 +188,12 @@ class Navigator( .map { decodeFromSavedState(it) } } - val navigator = Navigator( + Navigator( startRoute = restoredStartRoute, topLevelRoutes = topLevelRoutes, + initialTopLevelRoute = restoredTopLevelRoute, + initialTopLevelStacks = topLevelStacks, ) - - navigator.topLevelStacks.clear() - topLevelStacks.forEach { (key, value) -> - navigator.topLevelStacks[key] = value.toMutableStateList() - } - - navigator.topLevelRoute = - decodeFromSavedState(getSavedState(KEY_TOP_LEVEL_ROUTE)) - navigator } } ) From 595de4d7e5f9f26a5e56dfda8e4c534c389038bc Mon Sep 17 00:00:00 2001 From: Don Turner Date: Sat, 8 Nov 2025 00:12:43 +0000 Subject: [PATCH 07/13] feat: Add multiple stacks recipe --- README.md | 1 + app/src/main/AndroidManifest.xml | 4 + .../nav3recipes/RecipePickerActivity.kt | 2 + .../nav3recipes/multiplestacks/Content.kt | 107 +++++++++ .../multiplestacks/MultipleStacksActivity.kt | 115 ++++++++++ .../nav3recipes/multiplestacks/Navigator.kt | 205 ++++++++++++++++++ .../nav3recipes/multiplestacks/README.md | 23 ++ 7 files changed, 457 insertions(+) create mode 100644 app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt create mode 100644 app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt create mode 100644 app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt create mode 100644 app/src/main/java/com/example/nav3recipes/multiplestacks/README.md diff --git a/README.md b/README.md index fe9dd2e..51d4e72 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Examples showing how to use the layouts provided by the [Compose Material3 Adapt ### Common use cases - **[Multiple back stacks / Common navigation UI](app/src/main/java/com/example/nav3recipes/commonui)**: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death. +- **[Multiple back stacks](app/src/main/java/com/example/nav3recipes/multiplestacks)**: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death. - **[Conditional navigation](app/src/main/java/com/example/nav3recipes/conditional)**: Switch to a different navigation flow when a condition is met. For example, for authentication or first-time user onboarding. ### Architecture diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8f45afb..a0dfbb7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -149,6 +149,10 @@ android:name=".scenes.listdetail.ListDetailActivity" android:exported="true" android:theme="@style/Theme.Nav3Recipes"/> + diff --git a/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt b/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt index 81dc297..644a2bb 100644 --- a/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt @@ -52,6 +52,7 @@ import com.example.nav3recipes.dialog.DialogActivity import com.example.nav3recipes.material.listdetail.MaterialListDetailActivity import com.example.nav3recipes.material.supportingpane.MaterialSupportingPaneActivity import com.example.nav3recipes.modular.hilt.ModularActivity +import com.example.nav3recipes.multiplestacks.MultipleStacksActivity import com.example.nav3recipes.passingarguments.viewmodels.basic.BasicViewModelsActivity import com.example.nav3recipes.passingarguments.viewmodels.hilt.HiltViewModelsActivity import com.example.nav3recipes.passingarguments.viewmodels.koin.KoinViewModelsActivity @@ -92,6 +93,7 @@ private val recipes = listOf( Heading("Common use cases"), Recipe("Common UI", CommonUiActivity::class.java), + Recipe("Multiple Stacks", MultipleStacksActivity::class.java), Recipe("Conditional navigation", ConditionalActivity::class.java), Heading("Architecture"), diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt new file mode 100644 index 0000000..a5e45e6 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt @@ -0,0 +1,107 @@ +/* + * 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.navigation3.runtime.EntryProviderScope +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.featureASection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentRed("Route A title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + } + } + } + entry { + ContentPink("Route A1 title") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + + Button(onClick = { count++ }) { + Text("Value: $count") + } + } + } +} + +fun EntryProviderScope.featureBSection( + onDetailClick: (id: String) -> Unit, +) { + entry { + ContentGreen("Route B title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onDetailClick("ABC") }) { + Text("Go to B1") + } + } + } + } + entry { key -> + ContentPurple("Route B1 title. ID: ${key.id}") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + Button(onClick = { count++ }) { + Text("Value: $count") + } + } + } +} + +fun EntryProviderScope.featureCSection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentMauve("Route C title") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Open sub route") + } + } + } + } + entry { + ContentOrange("Route C1 title") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + + Button(onClick = { count++ }) { + Text("Value: $count") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt new file mode 100644 index 0000000..6fce788 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt @@ -0,0 +1,115 @@ +/* + * 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.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +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.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + + +@Serializable +data object RouteA : Route.TopLevel() + +@Serializable +data object RouteA1 : Route() + +@Serializable +data object RouteB : Route.TopLevel() + +@Serializable +data class RouteB1(val id: String) : Route() + +@Serializable +data object RouteC : Route.TopLevel() + +@Serializable +data object RouteC1 : Route() + + +private val TOP_LEVEL_ROUTES = mapOf( + 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() { + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val navigator = rememberNavigator( + startRoute = RouteA, + topLevelRoutes = TOP_LEVEL_ROUTES.keys, + ) + + val entryProvider = entryProvider { + featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) + featureBSection(onDetailClick = { id -> navigator.navigate(RouteB1(id)) }) + featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) }) + } + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = key == navigator.topLevelRoute + NavigationBarItem( + selected = isSelected, + onClick = { navigator.navigate(key) }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) { paddingValues -> + NavDisplay( + entries = navigator.entries(entryProvider), + onBack = { navigator.goBack() }, + modifier = Modifier.padding(paddingValues) + ) + } + } + } +} + + diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt new file mode 100644 index 0000000..3a6ef41 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt @@ -0,0 +1,205 @@ +/* + * 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 androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.savedstate.SavedState +import androidx.savedstate.read +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import androidx.savedstate.write +import kotlinx.serialization.Serializable + +/** + * Create a Navigator that is saved and restored through config changes and process death. + */ +@Composable +fun rememberNavigator( + startRoute: Route, + topLevelRoutes: Set, +) : Navigator { + + return rememberSaveable(saver = Navigator.Saver) { + Navigator( + startRoute = startRoute, + topLevelRoutes = topLevelRoutes, + ) + } +} + +/** + * Navigator object that manages navigation state and provides `NavEntry`s for that state. + */ +@SuppressLint("RestrictedApi") +class Navigator private constructor( + val startRoute: Route, + val topLevelRoutes: Set, + initialTopLevelRoute: Route, + initialTopLevelStacks: Map> +) { + + constructor( + startRoute: Route, + topLevelRoutes: Set + ) : this( + startRoute = startRoute, + topLevelRoutes = topLevelRoutes, + initialTopLevelRoute = startRoute, + initialTopLevelStacks = topLevelRoutes.associateWith { route -> listOf(route) } + ) + + var topLevelRoute by mutableStateOf(initialTopLevelRoute) + private set + + // Maintain a stack for each top level route + val topLevelStacks : Map> = + initialTopLevelStacks.mapValues { (_, values) -> values.toMutableStateList() } + + /** + * Navigate to the given route. + */ + fun navigate(route: Route) { + if (route is Route.TopLevel) { + topLevelRoute = route + } else { + topLevelStacks[topLevelRoute]?.add(route) + } + } + + /** + * Go back to the previous route. + */ + fun goBack() { + + val currentStack = topLevelStacks[topLevelRoute] ?: + error("Stack for $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 == topLevelRoute){ + topLevelRoute = startRoute + } else { + currentStack.removeLast() + } + } + + /** + * Get the NavEntries for the current navigation state. + */ + @Composable + fun entries( + entryProvider: (Route) -> NavEntry + ): SnapshotStateList> { + + val decoratedEntries = topLevelStacks.mapValues { (_, stack) -> + + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + ) + + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + val stacksToUse = mutableListOf(startRoute) + if (topLevelRoute != startRoute) stacksToUse += topLevelRoute + + return stacksToUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() + } + + companion object { + private const val KEY_START_ROUTE = "start_route" + private const val KEY_TOP_LEVEL_ROUTE = "top_level_route" + private const val KEY_TOP_LEVEL_STACK_IDS = "top_level_stack_ids" + private const val KEY_TOP_LEVEL_STACK_KEY_PREFIX = "top_level_stack_key_" + private const val KEY_TOP_LEVEL_STACK_VALUES_PREFIX = "top_level_stack_values_" + + val Saver = Saver( + save = { navigator -> + val savedState = SavedState() + savedState.write { + putSavedState(KEY_START_ROUTE, encodeToSavedState(navigator.startRoute)) + putSavedState(KEY_TOP_LEVEL_ROUTE, encodeToSavedState(navigator.topLevelRoute)) + + var id = 0 + val ids = mutableListOf() + + for ((key, stackValues) in navigator.topLevelStacks) { + putSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id", encodeToSavedState(key)) + putSavedStateList( + "$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id", + stackValues.map { encodeToSavedState(it) }) + ids.add(id) + id++ + } + + putIntList(KEY_TOP_LEVEL_STACK_IDS, ids) + } + savedState + }, + restore = { savedState -> + savedState.read { + val restoredStartRoute = + decodeFromSavedState(getSavedState(KEY_START_ROUTE)) + val restoredTopLevelRoute = + decodeFromSavedState(getSavedState(KEY_TOP_LEVEL_ROUTE)) + + val topLevelRoutes = mutableSetOf() + val topLevelStacks = mutableMapOf>() + + val ids = getIntList(KEY_TOP_LEVEL_STACK_IDS) + for (id in ids) { + // get the top level key and the keys on the stack + val key: Route = + decodeFromSavedState(getSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id")) + topLevelRoutes.add(key) + topLevelStacks[key] = getSavedStateList("$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id") + .map { decodeFromSavedState(it) } + } + + Navigator( + startRoute = restoredStartRoute, + topLevelRoutes = topLevelRoutes, + initialTopLevelRoute = restoredTopLevelRoute, + initialTopLevelStacks = topLevelStacks, + ) + } + } + ) + } +} + +@Serializable +sealed class Route { + sealed class TopLevel : Route() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/README.md b/app/src/main/java/com/example/nav3recipes/multiplestacks/README.md new file mode 100644 index 0000000..ae4dcaa --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/README.md @@ -0,0 +1,23 @@ +# Common navigation UI / Multiple back stacks recipe # + +This recipe demonstrates how to create top level routes with their own back stack. + +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 managed by the `Navigator` class. This maintains a back stack for each top level route and holds the logic for navigating within and between these back stacks. + +The state for each `NavEntry` is retained while its key is in the associated back stack. This means that even when the top level route changes, the state for entries in other back stacks will be retained. + +The `Navigator` class is split into two areas of responsibility: + +- **Managing navigation state**. This is done in pure Kotlin, no Composable functions. A `Saver` allows the navigation state to be saved and restored. +- **Providing UI**. The `NavEntry`s for the current navigation state are provided using the `entries` Composable function. This can be used directly with `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. From 0d776d40fb4bf0166ece4719454ab8b2294a09c5 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Sat, 8 Nov 2025 00:12:43 +0000 Subject: [PATCH 08/13] feat: Add multiple stacks recipe --- .../nav3recipes/commonui/CommonUiActivity.kt | 187 ++++++++++------ .../example/nav3recipes/commonui/Content.kt | 107 --------- .../example/nav3recipes/commonui/Navigator.kt | 206 ------------------ .../example/nav3recipes/commonui/README.md | 23 -- 4 files changed, 124 insertions(+), 399 deletions(-) delete mode 100644 app/src/main/java/com/example/nav3recipes/commonui/Content.kt delete mode 100644 app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt delete mode 100644 app/src/main/java/com/example/nav3recipes/commonui/README.md diff --git a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt index d723cd8..bd3d014 100644 --- a/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/commonui/CommonUiActivity.kt @@ -19,97 +19,158 @@ package com.example.nav3recipes.commonui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.padding 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.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.ui.Modifier +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.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 -import kotlinx.serialization.Serializable +/** + * Common navigation UI example. This app has three top level routes: Home, ChatList and Camera. + * ChatList has a sub-route: ChatDetail. + * + * The app back stack state is modeled in `TopLevelBackStack`. This creates a back stack for each + * top level route. It flattens those maps to create a single back stack for `NavDisplay`. This allows + * `NavDisplay` to know where to go back to. + * + * Note that 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. + */ -@Serializable -data object RouteA : Route.TopLevel() - -@Serializable -data object RouteA1 : Route() - -@Serializable -data object RouteB : Route.TopLevel() - -@Serializable -data class RouteB1(val id: String) : Route() - -@Serializable -data object RouteC : Route.TopLevel() - -@Serializable -data object RouteC1 : Route() - - -private val TOP_LEVEL_ROUTES = mapOf( - 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 -) +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 = listOf(Home, ChatList, Camera) class CommonUiActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { - val navigator = rememberNavigator( - startRoute = RouteA, - topLevelRoutes = TOP_LEVEL_ROUTES.keys, - ) - - val entryProvider = entryProvider { - featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) - featureBSection(onDetailClick = { id -> navigator.navigate(RouteB1(id)) }) - featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) }) - } - - Scaffold(bottomBar = { - NavigationBar { - TOP_LEVEL_ROUTES.forEach { (key, value) -> - val isSelected = key == navigator.topLevelRoute - NavigationBarItem( - selected = isSelected, - onClick = { navigator.navigate(key) }, - icon = { - Icon( - imageVector = value.icon, - contentDescription = value.description - ) - }, - label = { Text(value.description) } - ) + val topLevelBackStack = remember { TopLevelBackStack(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 + ) + } + ) + } } } - }) { paddingValues -> + ) { _ -> NavDisplay( - entries = navigator.entries(entryProvider), - onBack = { navigator.goBack() }, - modifier = Modifier.padding(paddingValues) + backStack = topLevelBackStack.backStack, + onBack = { topLevelBackStack.removeLast() }, + entryProvider = entryProvider { + entry{ + ContentRed("Home screen") + } + entry{ + ContentGreen("Chat list screen"){ + Button(onClick = { topLevelBackStack.add(ChatDetail) }) { + Text("Go to conversation") + } + } + } + entry{ + ContentBlue("Chat detail screen") + } + entry{ + ContentPurple("Camera screen") + } + }, ) } } } } +class TopLevelBackStack(startKey: T) { + + // Maintain a stack for each top level route + private var topLevelStacks : LinkedHashMap> = 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() + } +} diff --git a/app/src/main/java/com/example/nav3recipes/commonui/Content.kt b/app/src/main/java/com/example/nav3recipes/commonui/Content.kt deleted file mode 100644 index 60c66dd..0000000 --- a/app/src/main/java/com/example/nav3recipes/commonui/Content.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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 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.navigation3.runtime.EntryProviderScope -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.featureASection( - onSubRouteClick: () -> Unit, -) { - entry { - ContentRed("Route A title") { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = onSubRouteClick) { - Text("Go to A1") - } - } - } - } - entry { - ContentPink("Route A1 title") { - var count by rememberSaveable { - mutableIntStateOf(0) - } - - Button(onClick = { count++ }) { - Text("Value: $count") - } - } - } -} - -fun EntryProviderScope.featureBSection( - onDetailClick: (id: String) -> Unit, -) { - entry { - ContentGreen("Route B title") { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { onDetailClick("ABC") }) { - Text("Go to B1") - } - } - } - } - entry { key -> - ContentPurple("Route B1 title. ID: ${key.id}") { - var count by rememberSaveable { - mutableIntStateOf(0) - } - Button(onClick = { count++ }) { - Text("Value: $count") - } - } - } -} - -fun EntryProviderScope.featureCSection( - onSubRouteClick: () -> Unit, -) { - entry { - ContentMauve("Route C title") { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = onSubRouteClick) { - Text("Open sub route") - } - } - } - } - entry { - ContentOrange("Route C1 title") { - var count by rememberSaveable { - mutableIntStateOf(0) - } - - Button(onClick = { count++ }) { - Text("Value: $count") - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt b/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt deleted file mode 100644 index f200762..0000000 --- a/app/src/main/java/com/example/nav3recipes/commonui/Navigator.kt +++ /dev/null @@ -1,206 +0,0 @@ -/* - * 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.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.toMutableStateList -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberDecoratedNavEntries -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.savedstate.SavedState -import androidx.savedstate.read -import androidx.savedstate.serialization.decodeFromSavedState -import androidx.savedstate.serialization.encodeToSavedState -import androidx.savedstate.write -import kotlinx.serialization.Serializable - -/** - * Create a Navigator that is saved and restored through config changes and process death. - */ -@Composable -fun rememberNavigator( - startRoute: Route, - topLevelRoutes: Set, -) : Navigator { - - return rememberSaveable(saver = Navigator.Saver) { - Navigator( - startRoute = startRoute, - topLevelRoutes = topLevelRoutes, - ) - } -} - -/** - * Navigator object that manages navigation state and provides `NavEntry`s for that state. - */ -@SuppressLint("RestrictedApi") -class Navigator private constructor( - val startRoute: Route, - val topLevelRoutes: Set, - initialTopLevelRoute: Route, - initialTopLevelStacks: Map> -) { - - constructor( - startRoute: Route, - topLevelRoutes: Set - ) : this( - startRoute = startRoute, - topLevelRoutes = topLevelRoutes, - initialTopLevelRoute = startRoute, - initialTopLevelStacks = topLevelRoutes.associateWith { route -> listOf(route) } - ) - - var topLevelRoute by mutableStateOf(initialTopLevelRoute) - private set - - // Maintain a stack for each top level route - val topLevelStacks : Map> = - initialTopLevelStacks.mapValues { (_, values) -> values.toMutableStateList() } - - /** - * Navigate to the given route. - */ - fun navigate(route: Route) { - if (route is Route.TopLevel) { - topLevelRoute = route - } else { - topLevelStacks[topLevelRoute]?.add(route) - } - } - - /** - * Go back to the previous route. - */ - fun goBack() { - - val currentStack = topLevelStacks[topLevelRoute] ?: - error("Stack for $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 == topLevelRoute){ - topLevelRoute = startRoute - } else { - currentStack.removeLast() - } - } - - /** - * Get the NavEntries for the current navigation state. - */ - @Composable - fun entries( - entryProvider: (Route) -> NavEntry - ): SnapshotStateList> { - - val decoratedEntries = topLevelStacks.mapValues { (_, stack) -> - - val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - ) - - rememberDecoratedNavEntries( - backStack = stack, - entryDecorators = decorators, - entryProvider = entryProvider - ) - } - - val stacksToUse = mutableListOf(startRoute) - if (topLevelRoute != startRoute) stacksToUse += topLevelRoute - - return stacksToUse - .flatMap { decoratedEntries[it] ?: emptyList() } - .toMutableStateList() - } - - companion object { - private const val KEY_START_ROUTE = "start_route" - private const val KEY_TOP_LEVEL_ROUTE = "top_level_route" - private const val KEY_TOP_LEVEL_STACK_IDS = "top_level_stack_ids" - private const val KEY_TOP_LEVEL_STACK_KEY_PREFIX = "top_level_stack_key_" - private const val KEY_TOP_LEVEL_STACK_VALUES_PREFIX = "top_level_stack_values_" - - val Saver = Saver( - save = { navigator -> - val savedState = SavedState() - savedState.write { - putSavedState(KEY_START_ROUTE, encodeToSavedState(navigator.startRoute)) - putSavedState(KEY_TOP_LEVEL_ROUTE, encodeToSavedState(navigator.topLevelRoute)) - - var id = 0 - val ids = mutableListOf() - - for ((key, stackValues) in navigator.topLevelStacks) { - putSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id", encodeToSavedState(key)) - putSavedStateList( - "$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id", - stackValues.map { encodeToSavedState(it) }) - ids.add(id) - id++ - } - - putIntList(KEY_TOP_LEVEL_STACK_IDS, ids) - } - savedState - }, - restore = { savedState -> - savedState.read { - val restoredStartRoute = - decodeFromSavedState(getSavedState(KEY_START_ROUTE)) - val restoredTopLevelRoute = - decodeFromSavedState(getSavedState(KEY_TOP_LEVEL_ROUTE)) - - val topLevelRoutes = mutableSetOf() - val topLevelStacks = mutableMapOf>() - - val ids = getIntList(KEY_TOP_LEVEL_STACK_IDS) - for (id in ids) { - // get the top level key and the keys on the stack - val key: Route = - decodeFromSavedState(getSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id")) - topLevelRoutes.add(key) - topLevelStacks[key] = getSavedStateList("$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id") - .map { decodeFromSavedState(it) } - } - - Navigator( - startRoute = restoredStartRoute, - topLevelRoutes = topLevelRoutes, - initialTopLevelRoute = restoredTopLevelRoute, - initialTopLevelStacks = topLevelStacks, - ) - } - } - ) - } -} - -@Serializable -sealed class Route { - sealed class TopLevel : Route() -} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/commonui/README.md b/app/src/main/java/com/example/nav3recipes/commonui/README.md deleted file mode 100644 index ae4dcaa..0000000 --- a/app/src/main/java/com/example/nav3recipes/commonui/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Common navigation UI / Multiple back stacks recipe # - -This recipe demonstrates how to create top level routes with their own back stack. - -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 managed by the `Navigator` class. This maintains a back stack for each top level route and holds the logic for navigating within and between these back stacks. - -The state for each `NavEntry` is retained while its key is in the associated back stack. This means that even when the top level route changes, the state for entries in other back stacks will be retained. - -The `Navigator` class is split into two areas of responsibility: - -- **Managing navigation state**. This is done in pure Kotlin, no Composable functions. A `Saver` allows the navigation state to be saved and restored. -- **Providing UI**. The `NavEntry`s for the current navigation state are provided using the `entries` Composable function. This can be used directly with `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. From db11414cf6d0974e8eca722d16f7f88877551ced Mon Sep 17 00:00:00 2001 From: Don Turner Date: Sat, 8 Nov 2025 06:41:52 +0000 Subject: [PATCH 09/13] Refactor Navigator to remove `topLevelRoutes` The `topLevelRoutes` property is removed from the `Navigator` constructor. This information is now derived directly from the keys of the `initialTopLevelStacks` map. --- .../java/com/example/nav3recipes/multiplestacks/Navigator.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt index 3a6ef41..b71bd58 100644 --- a/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt @@ -58,7 +58,6 @@ fun rememberNavigator( @SuppressLint("RestrictedApi") class Navigator private constructor( val startRoute: Route, - val topLevelRoutes: Set, initialTopLevelRoute: Route, initialTopLevelStacks: Map> ) { @@ -68,7 +67,6 @@ class Navigator private constructor( topLevelRoutes: Set ) : this( startRoute = startRoute, - topLevelRoutes = topLevelRoutes, initialTopLevelRoute = startRoute, initialTopLevelStacks = topLevelRoutes.associateWith { route -> listOf(route) } ) @@ -189,7 +187,6 @@ class Navigator private constructor( Navigator( startRoute = restoredStartRoute, - topLevelRoutes = topLevelRoutes, initialTopLevelRoute = restoredTopLevelRoute, initialTopLevelStacks = topLevelStacks, ) From e46ff3ac4921fe74e9fd26e70cea222eddf827c5 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Sat, 8 Nov 2025 08:33:28 +0000 Subject: [PATCH 10/13] Refactor MultipleStacks to use built-in state management This commit refactors the multiple back stacks recipe to leverage the new state management APIs from `alpha11`. Key changes: * The custom `Navigator` class, which previously managed its own state and persistence logic via a `Saver`, has been removed. * The `NavigationState` class is introduced to hold the navigation state, created using `rememberNavigationState`. * State persistence is now handled by `rememberSerializable` for the current top-level route and `rememberNavBackStack` for each of the back stacks. * The custom `Route` sealed class has been replaced with the standard `NavKey` interface. * The `Navigator` class is now a simpler, stateless handler for navigation events, which operates on the `NavigationState`. * The README has been updated to reflect these architectural changes. --- .../nav3recipes/multiplestacks/Content.kt | 7 +- .../multiplestacks/MultipleStacksActivity.kt | 139 ++++++++++-- .../nav3recipes/multiplestacks/Navigator.kt | 202 ------------------ .../nav3recipes/multiplestacks/README.md | 13 +- 4 files changed, 135 insertions(+), 226 deletions(-) delete mode 100644 app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt index a5e45e6..f7fd342 100644 --- a/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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 @@ -32,7 +33,7 @@ import com.example.nav3recipes.content.ContentPink import com.example.nav3recipes.content.ContentPurple import com.example.nav3recipes.content.ContentRed -fun EntryProviderScope.featureASection( +fun EntryProviderScope.featureASection( onSubRouteClick: () -> Unit, ) { entry { @@ -57,7 +58,7 @@ fun EntryProviderScope.featureASection( } } -fun EntryProviderScope.featureBSection( +fun EntryProviderScope.featureBSection( onDetailClick: (id: String) -> Unit, ) { entry { @@ -81,7 +82,7 @@ fun EntryProviderScope.featureBSection( } } -fun EntryProviderScope.featureCSection( +fun EntryProviderScope.featureCSection( onSubRouteClick: () -> Unit, ) { entry { diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt index 6fce788..f3b0aa9 100644 --- a/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt @@ -29,34 +29,51 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +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.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer import com.example.nav3recipes.ui.setEdgeToEdgeConfig import kotlinx.serialization.Serializable +import kotlin.collections.last @Serializable -data object RouteA : Route.TopLevel() +data object RouteA : NavKey @Serializable -data object RouteA1 : Route() +data object RouteA1 : NavKey @Serializable -data object RouteB : Route.TopLevel() +data object RouteB : NavKey @Serializable -data class RouteB1(val id: String) : Route() +data class RouteB1(val id: String) : NavKey @Serializable -data object RouteC : Route.TopLevel() +data object RouteC : NavKey @Serializable -data object RouteC1 : Route() +data object RouteC1 : NavKey - -private val TOP_LEVEL_ROUTES = mapOf( +private val TOP_LEVEL_ROUTES = mapOf( 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"), @@ -67,17 +84,18 @@ data class NavBarItem( val description: String ) - class MultipleStacksActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { - val navigator = rememberNavigator( + val navigationState = rememberNavigationState( startRoute = RouteA, - topLevelRoutes = TOP_LEVEL_ROUTES.keys, + topLevelRoutes = TOP_LEVEL_ROUTES.keys ) + val navigator = remember { Navigator(navigationState) } + val entryProvider = entryProvider { featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) featureBSection(onDetailClick = { id -> navigator.navigate(RouteB1(id)) }) @@ -87,7 +105,7 @@ class MultipleStacksActivity : ComponentActivity() { Scaffold(bottomBar = { NavigationBar { TOP_LEVEL_ROUTES.forEach { (key, value) -> - val isSelected = key == navigator.topLevelRoute + val isSelected = key == navigationState.topLevelRoute NavigationBarItem( selected = isSelected, onClick = { navigator.navigate(key) }, @@ -103,7 +121,7 @@ class MultipleStacksActivity : ComponentActivity() { } }) { paddingValues -> NavDisplay( - entries = navigator.entries(entryProvider), + entries = navigationState.toEntries(entryProvider), onBack = { navigator.goBack() }, modifier = Modifier.padding(paddingValues) ) @@ -112,4 +130,99 @@ class MultipleStacksActivity : ComponentActivity() { } } +/** + * Convert NavigationState into NavEntries. + */ +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry +): SnapshotStateList> { + + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + return stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() +} + +/** + * 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.removeLast() + } + } +} + +/** + * Create a navigation state that persists config changes and process death. + */ +@Composable +fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set +) : NavigationState { + + val topLevelRoute = rememberSerializable( + serializer = MutableStateSerializer(NavKeySerializer()) + ){ + mutableStateOf(startRoute) + } + + return NavigationState( + topLevelRoute = topLevelRoute, + backStacks = topLevelRoutes.associateWith { key -> + rememberNavBackStack(key) + } + ) +} + +/** + * State holder for navigation state. + * + * @param topLevelRoute - the current top level route + * @param backStacks - the back stacks for each top level route + */ +class NavigationState( + topLevelRoute: MutableState, + val backStacks: Map> +) { + val startRoute = topLevelRoute.value + var topLevelRoute : NavKey by topLevelRoute + val stacksInUse : List + get(){ + val stacksInUse = mutableListOf(startRoute) + if (this@NavigationState.topLevelRoute != startRoute) stacksInUse += this@NavigationState.topLevelRoute + return stacksInUse + } +} + diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt deleted file mode 100644 index b71bd58..0000000 --- a/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt +++ /dev/null @@ -1,202 +0,0 @@ -/* - * 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 androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.toMutableStateList -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberDecoratedNavEntries -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.savedstate.SavedState -import androidx.savedstate.read -import androidx.savedstate.serialization.decodeFromSavedState -import androidx.savedstate.serialization.encodeToSavedState -import androidx.savedstate.write -import kotlinx.serialization.Serializable - -/** - * Create a Navigator that is saved and restored through config changes and process death. - */ -@Composable -fun rememberNavigator( - startRoute: Route, - topLevelRoutes: Set, -) : Navigator { - - return rememberSaveable(saver = Navigator.Saver) { - Navigator( - startRoute = startRoute, - topLevelRoutes = topLevelRoutes, - ) - } -} - -/** - * Navigator object that manages navigation state and provides `NavEntry`s for that state. - */ -@SuppressLint("RestrictedApi") -class Navigator private constructor( - val startRoute: Route, - initialTopLevelRoute: Route, - initialTopLevelStacks: Map> -) { - - constructor( - startRoute: Route, - topLevelRoutes: Set - ) : this( - startRoute = startRoute, - initialTopLevelRoute = startRoute, - initialTopLevelStacks = topLevelRoutes.associateWith { route -> listOf(route) } - ) - - var topLevelRoute by mutableStateOf(initialTopLevelRoute) - private set - - // Maintain a stack for each top level route - val topLevelStacks : Map> = - initialTopLevelStacks.mapValues { (_, values) -> values.toMutableStateList() } - - /** - * Navigate to the given route. - */ - fun navigate(route: Route) { - if (route is Route.TopLevel) { - topLevelRoute = route - } else { - topLevelStacks[topLevelRoute]?.add(route) - } - } - - /** - * Go back to the previous route. - */ - fun goBack() { - - val currentStack = topLevelStacks[topLevelRoute] ?: - error("Stack for $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 == topLevelRoute){ - topLevelRoute = startRoute - } else { - currentStack.removeLast() - } - } - - /** - * Get the NavEntries for the current navigation state. - */ - @Composable - fun entries( - entryProvider: (Route) -> NavEntry - ): SnapshotStateList> { - - val decoratedEntries = topLevelStacks.mapValues { (_, stack) -> - - val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - ) - - rememberDecoratedNavEntries( - backStack = stack, - entryDecorators = decorators, - entryProvider = entryProvider - ) - } - - val stacksToUse = mutableListOf(startRoute) - if (topLevelRoute != startRoute) stacksToUse += topLevelRoute - - return stacksToUse - .flatMap { decoratedEntries[it] ?: emptyList() } - .toMutableStateList() - } - - companion object { - private const val KEY_START_ROUTE = "start_route" - private const val KEY_TOP_LEVEL_ROUTE = "top_level_route" - private const val KEY_TOP_LEVEL_STACK_IDS = "top_level_stack_ids" - private const val KEY_TOP_LEVEL_STACK_KEY_PREFIX = "top_level_stack_key_" - private const val KEY_TOP_LEVEL_STACK_VALUES_PREFIX = "top_level_stack_values_" - - val Saver = Saver( - save = { navigator -> - val savedState = SavedState() - savedState.write { - putSavedState(KEY_START_ROUTE, encodeToSavedState(navigator.startRoute)) - putSavedState(KEY_TOP_LEVEL_ROUTE, encodeToSavedState(navigator.topLevelRoute)) - - var id = 0 - val ids = mutableListOf() - - for ((key, stackValues) in navigator.topLevelStacks) { - putSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id", encodeToSavedState(key)) - putSavedStateList( - "$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id", - stackValues.map { encodeToSavedState(it) }) - ids.add(id) - id++ - } - - putIntList(KEY_TOP_LEVEL_STACK_IDS, ids) - } - savedState - }, - restore = { savedState -> - savedState.read { - val restoredStartRoute = - decodeFromSavedState(getSavedState(KEY_START_ROUTE)) - val restoredTopLevelRoute = - decodeFromSavedState(getSavedState(KEY_TOP_LEVEL_ROUTE)) - - val topLevelRoutes = mutableSetOf() - val topLevelStacks = mutableMapOf>() - - val ids = getIntList(KEY_TOP_LEVEL_STACK_IDS) - for (id in ids) { - // get the top level key and the keys on the stack - val key: Route = - decodeFromSavedState(getSavedState("$KEY_TOP_LEVEL_STACK_KEY_PREFIX$id")) - topLevelRoutes.add(key) - topLevelStacks[key] = getSavedStateList("$KEY_TOP_LEVEL_STACK_VALUES_PREFIX$id") - .map { decodeFromSavedState(it) } - } - - Navigator( - startRoute = restoredStartRoute, - initialTopLevelRoute = restoredTopLevelRoute, - initialTopLevelStacks = topLevelStacks, - ) - } - } - ) - } -} - -@Serializable -sealed class Route { - sealed class TopLevel : Route() -} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/README.md b/app/src/main/java/com/example/nav3recipes/multiplestacks/README.md index ae4dcaa..68f67a2 100644 --- a/app/src/main/java/com/example/nav3recipes/multiplestacks/README.md +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/README.md @@ -1,17 +1,14 @@ -# Common navigation UI / Multiple back stacks recipe # +# Multiple back stacks recipe # -This recipe demonstrates how to create top level routes with their own back stack. +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 managed by the `Navigator` class. This maintains a back stack for each top level route and holds the logic for navigating within and between these back stacks. +The app's navigation state is held in the `NavigationState` class. The state itself is created using `rememberNavigationState`. -The state for each `NavEntry` is retained while its key is in the associated back stack. This means that even when the top level route changes, the state for entries in other back stacks will be retained. +Navigation events are handled by the `Navigator`. It updates the navigation state. -The `Navigator` class is split into two areas of responsibility: - -- **Managing navigation state**. This is done in pure Kotlin, no Composable functions. A `Saver` allows the navigation state to be saved and restored. -- **Providing UI**. The `NavEntry`s for the current navigation state are provided using the `entries` Composable function. This can be used directly with `NavDisplay`. +The navigation state is converted into `NavEntry`s with `NavigationState.toEntries`. These entries are then displayed by `NavDisplay`. Key behaviors: From 5536aba4788fc2d08e233d914f4dc13d5bdb022e Mon Sep 17 00:00:00 2001 From: Don Turner Date: Sat, 8 Nov 2025 08:35:36 +0000 Subject: [PATCH 11/13] Update README.md Rename "Multiple back stacks / Common navigation UI" recipe to "Common navigation UI". --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51d4e72..249fe1e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Examples showing how to use the layouts provided by the [Compose Material3 Adapt - **[Animations](app/src/main/java/com/example/nav3recipes/animations)**: Shows how to override the default animations for all destinations and a single destination. ### Common use cases -- **[Multiple back stacks / Common navigation UI](app/src/main/java/com/example/nav3recipes/commonui)**: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death. +- **[Common navigation UI](app/src/main/java/com/example/nav3recipes/commonui)**: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death. - **[Multiple back stacks](app/src/main/java/com/example/nav3recipes/multiplestacks)**: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death. - **[Conditional navigation](app/src/main/java/com/example/nav3recipes/conditional)**: Switch to a different navigation flow when a condition is met. For example, for authentication or first-time user onboarding. From 8cc344f60557d39194252de30a4933372de72f4e Mon Sep 17 00:00:00 2001 From: Don Turner Date: Sat, 8 Nov 2025 08:36:16 +0000 Subject: [PATCH 12/13] Fix: Correct description for Common UI recipe The description for the Common UI recipe was incorrectly copied from the Multiple back stacks recipe. This commit updates the README to accurately describe the Common UI recipe. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 249fe1e..034fc7d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Examples showing how to use the layouts provided by the [Compose Material3 Adapt - **[Animations](app/src/main/java/com/example/nav3recipes/animations)**: Shows how to override the default animations for all destinations and a single destination. ### Common use cases -- **[Common navigation UI](app/src/main/java/com/example/nav3recipes/commonui)**: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death. +- **[Common navigation UI](app/src/main/java/com/example/nav3recipes/commonui)**: A common navigation toolbar where each item in the toolbar navigates to a top level destination. - **[Multiple back stacks](app/src/main/java/com/example/nav3recipes/multiplestacks)**: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death. - **[Conditional navigation](app/src/main/java/com/example/nav3recipes/conditional)**: Switch to a different navigation flow when a condition is met. For example, for authentication or first-time user onboarding. From 5fbc0ba8ba4bf1fdc99b277d3172082ef355b7df Mon Sep 17 00:00:00 2001 From: Don Turner Date: Mon, 10 Nov 2025 20:23:13 +0000 Subject: [PATCH 13/13] Refactor multiple backstacks implementation This commit refactors the multiple backstacks recipe by extracting core navigation logic into separate `Navigator` and `NavigationState` classes. - `NavigationState` is now a dedicated state holder for the current top-level route and the back stacks for each tab. - `Navigator` encapsulates the navigation logic for handling forward and back navigation events. - The `MultipleStacksActivity` is simplified to use these new classes. - The `RouteB1` destination has been simplified from a data class to an object, and click handlers have been updated accordingly. --- .../nav3recipes/multiplestacks/Content.kt | 18 +-- .../multiplestacks/MultipleStacksActivity.kt | 119 +----------------- .../multiplestacks/NavigationState.kt | 73 +++++++++++ .../nav3recipes/multiplestacks/Navigator.kt | 78 ++++++++++++ 4 files changed, 163 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/com/example/nav3recipes/multiplestacks/NavigationState.kt create mode 100644 app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt index f7fd342..be49f4c 100644 --- a/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt @@ -37,7 +37,7 @@ fun EntryProviderScope.featureASection( onSubRouteClick: () -> Unit, ) { entry { - ContentRed("Route A title") { + ContentRed("Route A") { Column(horizontalAlignment = Alignment.CenterHorizontally) { Button(onClick = onSubRouteClick) { Text("Go to A1") @@ -46,7 +46,7 @@ fun EntryProviderScope.featureASection( } } entry { - ContentPink("Route A1 title") { + ContentPink("Route A1") { var count by rememberSaveable { mutableIntStateOf(0) } @@ -59,19 +59,19 @@ fun EntryProviderScope.featureASection( } fun EntryProviderScope.featureBSection( - onDetailClick: (id: String) -> Unit, + onSubRouteClick: (id: String) -> Unit, ) { entry { - ContentGreen("Route B title") { + ContentGreen("Route B") { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { onDetailClick("ABC") }) { + Button(onClick = { onSubRouteClick("ABC") }) { Text("Go to B1") } } } } - entry { key -> - ContentPurple("Route B1 title. ID: ${key.id}") { + entry { + ContentPurple("Route B1") { var count by rememberSaveable { mutableIntStateOf(0) } @@ -86,7 +86,7 @@ fun EntryProviderScope.featureCSection( onSubRouteClick: () -> Unit, ) { entry { - ContentMauve("Route C title") { + ContentMauve("Route C") { Column(horizontalAlignment = Alignment.CenterHorizontally) { Button(onClick = onSubRouteClick) { Text("Open sub route") @@ -95,7 +95,7 @@ fun EntryProviderScope.featureCSection( } } entry { - ContentOrange("Route C1 title") { + ContentOrange("Route C1") { var count by rememberSaveable { mutableIntStateOf(0) } diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt index f3b0aa9..80dea9c 100644 --- a/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/MultipleStacksActivity.kt @@ -29,30 +29,14 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -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.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberDecoratedNavEntries -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay -import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer import com.example.nav3recipes.ui.setEdgeToEdgeConfig import kotlinx.serialization.Serializable -import kotlin.collections.last @Serializable @@ -65,7 +49,7 @@ data object RouteA1 : NavKey data object RouteB : NavKey @Serializable -data class RouteB1(val id: String) : NavKey +data object RouteB1 : NavKey @Serializable data object RouteC : NavKey @@ -98,7 +82,7 @@ class MultipleStacksActivity : ComponentActivity() { val entryProvider = entryProvider { featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) - featureBSection(onDetailClick = { id -> navigator.navigate(RouteB1(id)) }) + featureBSection(onSubRouteClick = { id -> navigator.navigate(RouteB1) }) featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) }) } @@ -128,101 +112,4 @@ class MultipleStacksActivity : ComponentActivity() { } } } -} - -/** - * Convert NavigationState into NavEntries. - */ -@Composable -fun NavigationState.toEntries( - entryProvider: (NavKey) -> NavEntry -): SnapshotStateList> { - - val decoratedEntries = backStacks.mapValues { (_, stack) -> - val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - ) - rememberDecoratedNavEntries( - backStack = stack, - entryDecorators = decorators, - entryProvider = entryProvider - ) - } - - return stacksInUse - .flatMap { decoratedEntries[it] ?: emptyList() } - .toMutableStateList() -} - -/** - * 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.removeLast() - } - } -} - -/** - * Create a navigation state that persists config changes and process death. - */ -@Composable -fun rememberNavigationState( - startRoute: NavKey, - topLevelRoutes: Set -) : NavigationState { - - val topLevelRoute = rememberSerializable( - serializer = MutableStateSerializer(NavKeySerializer()) - ){ - mutableStateOf(startRoute) - } - - return NavigationState( - topLevelRoute = topLevelRoute, - backStacks = topLevelRoutes.associateWith { key -> - rememberNavBackStack(key) - } - ) -} - -/** - * State holder for navigation state. - * - * @param topLevelRoute - the current top level route - * @param backStacks - the back stacks for each top level route - */ -class NavigationState( - topLevelRoute: MutableState, - val backStacks: Map> -) { - val startRoute = topLevelRoute.value - var topLevelRoute : NavKey by topLevelRoute - val stacksInUse : List - get(){ - val stacksInUse = mutableListOf(startRoute) - if (this@NavigationState.topLevelRoute != startRoute) stacksInUse += this@NavigationState.topLevelRoute - return stacksInUse - } -} - - +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/NavigationState.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/NavigationState.kt new file mode 100644 index 0000000..c8b8d6b --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/NavigationState.kt @@ -0,0 +1,73 @@ +/* + * 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.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.rememberSaveableStateHolderNavEntryDecorator + +/** + * State holder for navigation state. + * + * @param topLevelRoute - the current top level route + * @param backStacks - the back stacks for each top level route + */ +class NavigationState( + topLevelRoute: MutableState, + val backStacks: Map> +) { + val startRoute = topLevelRoute.value + var topLevelRoute : NavKey by topLevelRoute + val stacksInUse : List + get(){ + val stacksInUse = mutableListOf(startRoute) + if (this@NavigationState.topLevelRoute != startRoute) stacksInUse += this@NavigationState.topLevelRoute + return stacksInUse + } +} + +/** + * Convert NavigationState into NavEntries. + */ +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry +): SnapshotStateList> { + + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + return stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt b/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt new file mode 100644 index 0000000..51ffe67 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt @@ -0,0 +1,78 @@ +/* + * 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.mutableStateOf +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +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 +) : NavigationState { + + val topLevelRoute = rememberSerializable( + serializer = MutableStateSerializer(NavKeySerializer()) + ){ + mutableStateOf(startRoute) + } + + return NavigationState( + topLevelRoute = topLevelRoute, + backStacks = topLevelRoutes.associateWith { key -> + rememberNavBackStack(key) + } + ) +} + +/** + * 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.removeLast() + } + } +} + +