일반 UI 레시피
이 레시피에서는 하단 탐색 메뉴와 여러 백 스택을 사용하여 일반적인 탐색 UI 패턴을 구현하는 방법을 보여줍니다. 탐색 메뉴의 각 탭에는 자체 탐색 기록이 있습니다.
작동 방식
이 예시에는 Home, ChatList, Camera라는 세 개의 최상위 대상이 있습니다. ChatList 대상에는 ChatDetail 하위 경로도 있습니다.
TopLevelBackStack
이 레시피의 핵심은 탐색 상태를 관리하는 TopLevelBackStack 클래스입니다. 작동 방식은 다음과 같습니다.
- 각 최상위 대상 (탭)에 대해 별도의 백 스택을 유지합니다.
- 현재 선택된 최상위 대상을 추적합니다.
NavDisplay컴포저블에서 사용할 수 있는 단일 플랫 백 스택을 제공합니다. 이 병합된 백 스택은 모든 탭의 개별 백 스택을 결합한 것입니다.
UI 구조
UI는 bottomBar로 NavigationBar을 사용하여 Scaffold 컴포저블을 사용하여 빌드됩니다.
NavigationBar에는 각 최상위 대상 위치의 항목이 표시됩니다. 항목을 클릭하면topLevelBackStack.addTopLevel를 호출하여 해당 탭으로 전환하고 각 탭의 탐색 기록을 유지합니다.NavDisplay컴포저블은Scaffold의 콘텐츠 영역에 배치됩니다.TopLevelBackStack에서 제공하는 평탄화된 백 스택을 기반으로 현재 화면을 표시합니다.
이 접근 방식을 사용하면 사용자가 앱의 여러 섹션 간에 전환할 수 있고 각 섹션이 자체 탐색 기록을 유지하는 일반적인 탐색 패턴이 가능합니다.
상태 보존
이 레시피에서 탐색 상태가 관리되는 방식을 확인하는 것이 중요합니다. 사용자가 최상위 수준 대상에서 벗어나면 (예: 이전 탭으로 돌아갈 때까지 뒤로 버튼을 누름) 해당 대상의 전체 탐색 기록이 삭제됩니다. 상태가 저장되지 않습니다. 사용자가 나중에 해당 탭으로 돌아오면 초기 화면부터 시작됩니다.
참고: 이 예에서 Home 경로는 ChatList 및 Camera 경로 위로 이동할 수 있습니다. 즉, Home에서 뒤로 탐색해도 앱이 종료되지 않을 수 있습니다. 사용자가 백 스택에 남아 있는 단일 최상위 경로에서 뒤로 이동하면 앱이 종료됩니다.
/* * Copyright 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.commonui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Face import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.content.ContentBlue import com.example.nav3recipes.content.ContentGreen import com.example.nav3recipes.content.ContentPurple import com.example.nav3recipes.content.ContentRed import com.example.nav3recipes.ui.setEdgeToEdgeConfig private sealed interface TopLevelRoute { val icon: ImageVector } private data object Home : TopLevelRoute { override val icon = Icons.Default.Home } private data object ChatList : TopLevelRoute { override val icon = Icons.Default.Face } private data object ChatDetail private data object Camera : TopLevelRoute { override val icon = Icons.Default.PlayArrow } private val TOP_LEVEL_ROUTES : List<TopLevelRoute> = listOf(Home, ChatList, Camera) class CommonUiActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { val topLevelBackStack = remember { TopLevelBackStack<Any>(Home) } Scaffold( bottomBar = { NavigationBar { TOP_LEVEL_ROUTES.forEach { topLevelRoute -> val isSelected = topLevelRoute == topLevelBackStack.topLevelKey NavigationBarItem( selected = isSelected, onClick = { topLevelBackStack.addTopLevel(topLevelRoute) }, icon = { Icon( imageVector = topLevelRoute.icon, contentDescription = null ) } ) } } } ) { _ -> NavDisplay( backStack = topLevelBackStack.backStack, onBack = { topLevelBackStack.removeLast() }, entryProvider = entryProvider { entry<Home>{ ContentRed("Home screen") } entry<ChatList>{ ContentGreen("Chat list screen"){ Button(onClick = dropUnlessResumed { topLevelBackStack.add(ChatDetail) }) { Text("Go to conversation") } } } entry<ChatDetail>{ ContentBlue("Chat detail screen") } entry<Camera>{ ContentPurple("Camera screen") } }, ) } } } } class TopLevelBackStack<T: Any>(startKey: T) { // Maintain a stack for each top level route private var topLevelStacks : LinkedHashMap<T, SnapshotStateList<T>> = linkedMapOf( startKey to mutableStateListOf(startKey) ) // Expose the current top level route for consumers var topLevelKey by mutableStateOf(startKey) private set // Expose the back stack so it can be rendered by the NavDisplay val backStack = mutableStateListOf(startKey) private fun updateBackStack() = backStack.apply { clear() addAll(topLevelStacks.flatMap { it.value }) } fun addTopLevel(key: T){ // If the top level doesn't exist, add it if (topLevelStacks[key] == null){ topLevelStacks.put(key, mutableStateListOf(key)) } else { // Otherwise just move it to the end of the stacks topLevelStacks.apply { remove(key)?.let { put(key, it) } } } topLevelKey = key updateBackStack() } fun add(key: T){ topLevelStacks[topLevelKey]?.add(key) updateBackStack() } fun removeLast(){ val removedKey = topLevelStacks[topLevelKey]?.removeLastOrNull() // If the removed key was a top level key, remove the associated top level stack topLevelStacks.remove(removedKey) topLevelKey = topLevelStacks.keys.last() updateBackStack() } }