diff --git a/README.md b/README.md index cc5c320..034fc7d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ 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. +- **[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. ### 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..be49f4c --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/Content.kt @@ -0,0 +1,108 @@ +/* + * 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 androidx.navigation3.runtime.NavKey +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentOrange +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed + +fun EntryProviderScope.featureASection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentRed("Route A") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Go to A1") + } + } + } + } + entry { + ContentPink("Route A1") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + + Button(onClick = { count++ }) { + Text("Value: $count") + } + } + } +} + +fun EntryProviderScope.featureBSection( + onSubRouteClick: (id: String) -> Unit, +) { + entry { + ContentGreen("Route B") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onSubRouteClick("ABC") }) { + Text("Go to B1") + } + } + } + } + entry { + ContentPurple("Route B1") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + Button(onClick = { count++ }) { + Text("Value: $count") + } + } + } +} + +fun EntryProviderScope.featureCSection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentMauve("Route C") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = onSubRouteClick) { + Text("Open sub route") + } + } + } + } + entry { + ContentOrange("Route C1") { + 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..80dea9c --- /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.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + + +@Serializable +data object RouteA : NavKey + +@Serializable +data object RouteA1 : NavKey + +@Serializable +data object RouteB : NavKey + +@Serializable +data object RouteB1 : NavKey + +@Serializable +data object RouteC : NavKey + +@Serializable +data object RouteC1 : NavKey + +private val TOP_LEVEL_ROUTES = mapOf( + 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 navigationState = rememberNavigationState( + startRoute = RouteA, + topLevelRoutes = TOP_LEVEL_ROUTES.keys + ) + + val navigator = remember { Navigator(navigationState) } + + val entryProvider = entryProvider { + featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) + featureBSection(onSubRouteClick = { id -> navigator.navigate(RouteB1) }) + featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) }) + } + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = key == navigationState.topLevelRoute + NavigationBarItem( + selected = isSelected, + onClick = { navigator.navigate(key) }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) { paddingValues -> + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.goBack() }, + modifier = Modifier.padding(paddingValues) + ) + } + } + } +} \ 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() + } + } +} + + 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..68f67a2 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/multiplestacks/README.md @@ -0,0 +1,20 @@ +# Multiple back stacks recipe # + +This recipe demonstrates how to create multiple back stacks. + +The app has three top level routes: `RouteA`, `RouteB` and `RouteC`. These routes have sub routes `RouteA1`, `RouteB1` and `RouteC1` respectively. The content for the sub routes is a counter that can be used to verify state retention through configuration changes and process death. + +The app's navigation state is held in the `NavigationState` class. The state itself is created using `rememberNavigationState`. + +Navigation events are handled by the `Navigator`. It updates the navigation state. + +The navigation state is converted into `NavEntry`s with `NavigationState.toEntries`. These entries are then displayed by `NavDisplay`. + +Key behaviors: + +- This app follows the "exit through home" pattern where the user always exits through the starting back stack. This means that `RouteA`'s entries are _always_ in the list of entries. +- Navigating to a top level route that is not the starting route _replaces_ the other entries. For example, navigating A->B->C would result in entries for A+C, B's entries are removed. + +Important implementation details: + +- Each top level route has its own `SaveableStateHolderNavEntryDecorator`. This is the object responsible for managing the state for the entries in its back stack.