Giao diện người dùng điều hướng với công thức Scene Decorators
Công thức này minh hoạ cách thêm các phần tử trên giao diện người dùng (chẳng hạn như thanh ứng dụng trên cùng và thanh điều hướng hoặc dải điều hướng) mà bạn muốn thêm vào cảnh, thay vì ở cấp mục nhập điều hướng. Để làm việc này, ứng dụng sẽ dùng API trình trang trí cảnh.
Cách hoạt động
Lớp NavigationScene
Lớp NavigationScene là cốt lõi của công thức này. Hàm này nhận một Scene, lớp kích thước cửa sổ hiện tại, một SharedTransitionScope và các thành phần kết hợp cho thanh điều hướng và thanh điều hướng bên. Nếu lớp kích thước chiều rộng cửa sổ là trung bình trở lên, thì lớp này sẽ hiển thị thanh điều hướng ở cạnh bắt đầu của màn hình cùng với nội dung ở cạnh kết thúc. Nếu không, hàm này sẽ hiển thị thanh điều hướng ở cạnh dưới của màn hình cùng với nội dung ở trên.
Chỉ kết xuất các phần tử giao diện người dùng dùng chung một lần
Trong quá trình chuyển cảnh, cả hai cảnh đều được tạo và kết xuất cùng một lúc. Đối với những phần tử được chia sẻ giữa các cảnh, chẳng hạn như thanh điều hướng hoặc thanh bên, bạn có thể không muốn các phần tử này được tạo thành trong cả hai cảnh.
Ví dụ: các thành phần kết hợp thanh điều hướng và thành phần kết hợp thanh bên trong công thức này có một số trạng thái nội bộ không thể được chuyển lên (chẳng hạn như trạng thái cho các ảnh động sau khi một mục được chọn). Do đó, bạn nên chỉ gọi thành phần kết hợp đã cho từ một cảnh tại một thời điểm bất kỳ.
Để đạt được hành vi mong muốn, công thức này kết hợp API nội dung có thể di chuyển và phần tử dùng chung của Compose:
- Bằng cách sử dụng
movableContentOf, thành phần kết hợp có thể giữ lại trạng thái của chính nó khi được di chuyển giữa các nhánh khác nhau của thành phần tương ứng với từng cảnh. - Bằng cách sử dụng các API phần tử dùng chung, ứng dụng có thể giữ nguyên thanh điều hướng/thanh điều hướng dọc trong khi tạo ảnh động cho nội dung của các cảnh đã được trang trí. Việc này được thực hiện bằng cách sử dụng đối tượng sửa đổi
sharedElementcũng như đối tượng sửa đổi tuỳ chỉnhcacheSize. Đối tượng này duy trì một phần giữ chỗ có kích thước phù hợp trong cảnh mà không gọi thành phần kết hợp nội dung có thể di chuyển.
Lớp NavigationSceneDecoratorStrategy
Lớp NavigationSceneDecoratorStrategy chịu trách nhiệm bao bọc cảnh đầu vào trong một NavigationScene. Hàm rememberNavigationSceneDecoratorStrategy đơn giản hoá quy trình tạo NavigationSceneDecoratorStrategy bằng cách xử lý việc tạo các thành phần kết hợp movableContentOf. Nhìn chung, NavigationSceneDecoratorStrategy phải là một trong những (nếu không phải là) mục cuối cùng trong tham số sceneDecoratorStrategies để có thể chứa tất cả nội dung khác của ứng dụng.
/* * Copyright 2026 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.navscenedecorator import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.remember import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.scenes.listdetail.rememberListDetailSceneStrategy import com.example.nav3recipes.ui.setEdgeToEdgeConfig class ResponsiveNavigationSceneDecoratorActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setEdgeToEdgeConfig() setContent { SharedTransitionLayout { val navigationState = rememberNavigationState( startRoute = RouteA, topLevelRoutes = setOf(RouteA, RouteB, RouteC) ) val navigator = remember(navigationState) { Navigator(navigationState) } val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>() val responsiveNavigationSceneDecoratorStrategy = rememberResponsiveNavigationSceneDecoratorStrategy<NavKey>( navBar = { NavBar(NAV_ITEMS, navigator) }, navRail = { NavRail(NAV_ITEMS, navigator) }, sharedTransitionScope = this ) val entryProvider = entryProvider { featureASection { id -> navigator.navigate(RouteA1(id)) } featureBSection { navigator.navigate(RouteB1) } featureCSection { navigator.navigate(RouteC1) } } NavDisplay( entries = navigationState.toEntries(entryProvider), sceneDecoratorStrategies = listOf(responsiveNavigationSceneDecoratorStrategy), sceneStrategies = listOf(listDetailStrategy), sharedTransitionScope = this, onBack = navigator::goBack ) } } } }
/* * Copyright 2026 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.navscenedecorator import androidx.compose.animation.EnterExitState import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.navigation3.scene.Scene import androidx.navigation3.scene.SceneDecoratorStrategy import androidx.navigation3.scene.SceneDecoratorStrategyScope import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.window.core.layout.WindowSizeClass data class ResponsiveNavigationScene<T : Any>( private val scene: Scene<T>, private val sharedTransitionScope: SharedTransitionScope, private val windowSizeClass: WindowSizeClass, private val navBarContent: @Composable (() -> Unit), private val navRailContent: @Composable (() -> Unit), ) : Scene<T> by scene { override val key = scene::class to scene.key override val content = @Composable { val animatedContentScope = LocalNavAnimatedContentScope.current val isMovableContentCaller = animatedContentScope.transition.targetState == EnterExitState.Visible with(sharedTransitionScope) { if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)) { Row(Modifier.fillMaxSize()) { Box( modifier = Modifier .cacheSize(!isMovableContentCaller) .sharedElement( rememberSharedContentState("nav-rail"), animatedContentScope ) ) { if (isMovableContentCaller) { navRailContent() } } Box(modifier = Modifier.weight(1f)) { scene.content() } } } else { Column(Modifier.fillMaxSize()) { Box(modifier = Modifier.weight(1f)) { scene.content() } Box( modifier = Modifier .cacheSize(!isMovableContentCaller) .sharedElement( rememberSharedContentState("nav-bar"), animatedContentScope ) ) { if (isMovableContentCaller) { navBarContent() } } } } } } } @Composable fun <T : Any> rememberResponsiveNavigationSceneDecoratorStrategy( navBar: @Composable () -> Unit, navRail: @Composable () -> Unit, sharedTransitionScope: SharedTransitionScope, windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass ): ResponsiveNavigationSceneDecoratorStrategy<T> { val currentNavBar by rememberUpdatedState(navBar) val currentNavRail by rememberUpdatedState(navRail) val movableNavBar = remember { movableContentOf { currentNavBar() } } val movableNavRail = remember { movableContentOf { currentNavRail() } } return remember(windowSizeClass, sharedTransitionScope) { ResponsiveNavigationSceneDecoratorStrategy( windowSizeClass, sharedTransitionScope, movableNavBar, movableNavRail ) } } class ResponsiveNavigationSceneDecoratorStrategy<T : Any>( private val windowSizeClass: WindowSizeClass, private val sharedTransitionScope: SharedTransitionScope, private val navBarContent: @Composable () -> Unit, private val navRailContent: @Composable () -> Unit, ) : SceneDecoratorStrategy<T> { override fun SceneDecoratorStrategyScope<T>.decorateScene(scene: Scene<T>): Scene<T> { return ResponsiveNavigationScene( scene, sharedTransitionScope, windowSizeClass, navBarContent, navRailContent ) } }
/* * Copyright 2026 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.navscenedecorator import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape 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.Button import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.example.nav3recipes.content.ContentBase import com.example.nav3recipes.content.ContentGreen import com.example.nav3recipes.content.ContentMauve import com.example.nav3recipes.content.ContentOrange import com.example.nav3recipes.content.ContentPurple import com.example.nav3recipes.scenes.listdetail.ListDetailScene import com.example.nav3recipes.ui.theme.colors import kotlinx.serialization.Serializable @Serializable data object RouteA : NavKey @Serializable data class RouteA1(val id: Int) : NavKey @Serializable data object RouteB : NavKey @Serializable data object RouteB1 : NavKey @Serializable data object RouteC : NavKey @Serializable data object RouteC1 : NavKey const val ITEM_COUNT = 20 fun EntryProviderScope<NavKey>.featureASection( onSubRouteClick: (Int) -> Unit, ) { entry<RouteA>( metadata = ListDetailScene.listPane() ) { Surface(modifier = Modifier.fillMaxHeight()) { var contentPadding by remember { mutableStateOf(PaddingValues()) } LazyColumn( modifier = Modifier .fillMaxSize() .onWindowInsetsOverlapChanged(WindowInsets.safeDrawing) { contentPadding = it }, contentPadding = contentPadding ) { items(List(ITEM_COUNT) { it + 1 }) { id -> ListItem( headlineContent = { Text("Item $id") }, leadingContent = { Box( modifier = Modifier .size(24.dp) .clip(RoundedCornerShape(4.dp)) .background(colors[id % colors.size]) ) }, modifier = Modifier .fillMaxWidth() .clickable(onClick = dropUnlessResumed { onSubRouteClick(id) }), ) } } } } entry<RouteA1>( metadata = ListDetailScene.detailPane() ) { key -> ContentBase( "Item ${key.id}", modifier = Modifier.background(colors[key.id % colors.size]) ) { var count by rememberSaveable { mutableIntStateOf(0) } Button(onClick = { count++ }) { Text("Value: $count") } } } } fun EntryProviderScope<NavKey>.featureBSection( onSubRouteClick: () -> Unit, ) { entry<RouteB> { ContentGreen("Route B") { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Button(onClick = dropUnlessResumed { onSubRouteClick() }) { Text("Go to B1") } } } } entry<RouteB1> { ContentPurple("Route B1") { var count by rememberSaveable { mutableIntStateOf(0) } Button(onClick = { count++ }) { Text("Value: $count") } } } } fun EntryProviderScope<NavKey>.featureCSection( onSubRouteClick: () -> Unit, ) { entry<RouteC> { ContentMauve("Route C") { Column(horizontalAlignment = Alignment.CenterHorizontally) { Button(onClick = dropUnlessResumed { onSubRouteClick() }) { Text("Go to C1") } } } } entry<RouteC1> { ContentOrange("Route C1") { var count by rememberSaveable { mutableIntStateOf(0) } Button(onClick = { count++ }) { Text("Value: $count") } } } } data class NavBarItem( val navKey: NavKey, val icon: ImageVector, val description: String ) val NAV_ITEMS = listOf( NavBarItem(RouteA, Icons.Default.Home, "Route A"), NavBarItem(RouteB, Icons.Default.Face, "Route B"), NavBarItem(RouteC, Icons.Default.Camera, "Route C"), ) @Composable fun NavBar(navBarItems: List<NavBarItem>, navigator: Navigator) { NavBar( navBarItems = navBarItems, topLevelRoute = navigator.state.topLevelRoute, onNavItemClick = { navigator.navigate(it) } ) } @Composable fun NavBar(navBarItems: List<NavBarItem>, topLevelRoute: NavKey, onNavItemClick: (NavKey) -> Unit) { NavigationBar(Modifier.consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom))) { navBarItems.forEach { item -> NavigationBarItem( selected = item.navKey == topLevelRoute, onClick = { onNavItemClick(item.navKey) }, icon = { Icon( imageVector = item.icon, contentDescription = item.description ) }, label = { Text(item.description) }) } } } @Composable fun NavRail(navRailItems: List<NavBarItem>, navigator: Navigator) { NavRail( navRailItems = navRailItems, topLevelRoute = navigator.state.topLevelRoute, onNavItemClick = { navigator.navigate(it) } ) } @Composable fun NavRail( navRailItems: List<NavBarItem>, topLevelRoute: NavKey, onNavItemClick: (NavKey) -> Unit ) { NavigationRail { navRailItems.forEach { item -> NavigationRailItem( selected = item.navKey == topLevelRoute, onClick = { onNavItemClick(item.navKey) }, icon = { Icon( imageVector = item.icon, contentDescription = item.description ) }, label = { Text(item.description) }) } } }
/* * Copyright 2026 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.navscenedecorator import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.node.GlobalPositionAwareModifierNode import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.invalidateMeasurement import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection /** * A modifier that caches the measured size of the component and optionally reuses it. For example, * this can be used to maintain the size of an element that contains moveable content. * * @param useCachedSize If true, the modifier will use the previously measured and cached size * (if available) instead of the current size. If false, it measures normally and caches the new size. */ fun Modifier.cacheSize(useCachedSize: Boolean): Modifier = this.then(CacheSizeElement(useCachedSize)) private data class CacheSizeElement( val useCachedSize: Boolean ) : ModifierNodeElement<CacheSizeNode>() { override fun create() = CacheSizeNode(useCachedSize) override fun update(node: CacheSizeNode) { node.useCachedSize = useCachedSize } override fun InspectorInfo.inspectableProperties() { name = "cacheSize" properties["useCachedSize"] = useCachedSize } } private class CacheSizeNode( useCachedSize: Boolean ) : Modifier.Node(), LayoutModifierNode { var useCachedSize: Boolean = useCachedSize set(value) { if (field != value) { field = value invalidateMeasurement() } } private var isSizeCached = false private var cachedSize: IntSize = IntSize.Zero override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints ): MeasureResult { val placeable = measurable.measure(constraints) val currentSize = IntSize(placeable.width, placeable.height) val size = if (useCachedSize && isSizeCached) { cachedSize } else { currentSize } cachedSize = size isSizeCached = true return layout(size.width, size.height) { placeable.placeRelative(0, 0) } } } /** * A modifier that calculates how much the component overlaps with the given [WindowInsets] * and reports the overlap as [PaddingValues]. * * This can be used to dynamically adjust the layout of content that is partially obscured * by system bars or other window insets, such as by using it as the `contentPadding` parameter * for a [androidx.compose.foundation.lazy.LazyColumn] or [androidx.compose.foundation.lazy.LazyRow] * * @param insets The [WindowInsets] to calculate the overlap against. * @param onOverlapChanged A callback invoked whenever the overlap changes, providing the overlap as [PaddingValues]. */ @Composable fun Modifier.onWindowInsetsOverlapChanged( insets: WindowInsets, onOverlapChanged: (PaddingValues) -> Unit ): Modifier { val density = LocalDensity.current val windowInfo = LocalWindowInfo.current val layoutDirection = LocalLayoutDirection.current return this.then( WindowInsetsOverlapElement( insets = insets, density = density, windowHeight = windowInfo.containerSize.height.toFloat(), windowWidth = windowInfo.containerSize.width.toFloat(), layoutDirection = layoutDirection, onOverlapChanged = onOverlapChanged ) ) } private data class WindowInsetsOverlapElement( val insets: WindowInsets, val density: Density, val windowHeight: Float, val windowWidth: Float, val layoutDirection: LayoutDirection, val onOverlapChanged: (PaddingValues) -> Unit ) : ModifierNodeElement<WindowInsetsOverlapNode>() { override fun create() = WindowInsetsOverlapNode( insets, density, windowHeight, windowWidth, layoutDirection, onOverlapChanged ) override fun update(node: WindowInsetsOverlapNode) { node.insets = insets node.density = density node.windowHeight = windowHeight node.windowWidth = windowWidth node.layoutDirection = layoutDirection node.onOverlapChanged = onOverlapChanged // Recalculate padding when modifier properties (like insets) change, // even if the component hasn't moved or changed size (no layout pass). node.calculatePadding() } override fun InspectorInfo.inspectableProperties() { name = "onWindowInsetsOverlapChanged" properties["insets"] = insets } } private class WindowInsetsOverlapNode( var insets: WindowInsets, var density: Density, var windowHeight: Float, var windowWidth: Float, var layoutDirection: LayoutDirection, var onOverlapChanged: (PaddingValues) -> Unit ) : Modifier.Node(), GlobalPositionAwareModifierNode { // Cache the layout coordinates so padding can be recalculated // when insets change without triggering a new global positioning pass. private var lastCoordinates: LayoutCoordinates? = null override fun onGloballyPositioned(coordinates: LayoutCoordinates) { lastCoordinates = coordinates calculatePadding() } fun calculatePadding() { val coordinates = lastCoordinates ?: return val screenRect = coordinates.boundsInWindow() val topOverlap = (insets.getTop(density) - screenRect.top).coerceAtLeast(0f) val bottomOverlap = (screenRect.bottom - (windowHeight - insets.getBottom(density))).coerceAtLeast(0f) val leftOverlap = (insets.getLeft(density, layoutDirection) - screenRect.left).coerceAtLeast(0f) val rightOverlap = (screenRect.right - (windowWidth - insets.getRight(density, layoutDirection))).coerceAtLeast(0f) with(density) { onOverlapChanged( PaddingValues.Absolute( left = leftOverlap.toDp(), top = topOverlap.toDp(), right = rightOverlap.toDp(), bottom = bottomOverlap.toDp() ) ) } } }
/* * Copyright 2026 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.navscenedecorator import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer import com.example.nav3recipes.conditional.rememberNavBackStack /** * Create a navigation state that persists config changes and process death. */ @Composable fun rememberNavigationState( startRoute: NavKey, topLevelRoutes: Set<NavKey> ): NavigationState { val topLevelRoute = rememberSerializable( startRoute, topLevelRoutes, serializer = MutableStateSerializer(NavKeySerializer()) ) { mutableStateOf(startRoute) } val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } return remember(startRoute, topLevelRoutes) { NavigationState( startRoute = startRoute, topLevelRoute = topLevelRoute, backStacks = backStacks ) } } /** * State holder for navigation state. * * @param startRoute - the start route. The user will exit the app through this route. * @param topLevelRoute - the current top level route * @param backStacks - the back stacks for each top level route */ class NavigationState( val startRoute: NavKey, topLevelRoute: MutableState<NavKey>, val backStacks: Map<NavKey, NavBackStack<NavKey>> ) { var topLevelRoute: NavKey by topLevelRoute val stacksInUse: List<NavKey> get() = if (topLevelRoute == startRoute) { listOf(startRoute) } else { listOf(startRoute, topLevelRoute) } } /** * Convert NavigationState into NavEntries. */ @Composable fun NavigationState.toEntries( entryProvider: (NavKey) -> NavEntry<NavKey> ): SnapshotStateList<NavEntry<NavKey>> { val decoratedEntries = backStacks.mapValues { (_, stack) -> val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator<NavKey>(), ) rememberDecoratedNavEntries( backStack = stack, entryDecorators = decorators, entryProvider = entryProvider ) } return stacksInUse.flatMap { decoratedEntries[it] ?: emptyList() }.toMutableStateList() }
/* * Copyright 2026 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.navscenedecorator import androidx.navigation3.runtime.NavKey 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 { if (route is RouteA1) { state.backStacks[state.topLevelRoute]?.removeAll { it is RouteA1 } } state.backStacks[state.topLevelRoute]?.add(route) } } fun goBack() { val currentStack = state.backStacks[state.topLevelRoute] ?: error("Stack for ${state.topLevelRoute} not found") val currentRoute = currentStack.last() // If we're at the base of the current route, go back to the start route stack. if (currentRoute == state.topLevelRoute) { state.topLevelRoute = state.startRoute } else { currentStack.removeLastOrNull() } } }