일반 UI 레시피

이 레시피에서는 하단 탐색 메뉴와 여러 백 스택을 사용하여 일반적인 탐색 UI 패턴을 구현하는 방법을 보여줍니다. 탐색 메뉴의 각 탭에는 자체 탐색 기록이 있습니다.

작동 방식

이 예시에는 Home, ChatList, Camera라는 세 개의 최상위 대상이 있습니다. ChatList 대상에는 ChatDetail 하위 경로도 있습니다.

TopLevelBackStack

이 레시피의 핵심은 탐색 상태를 관리하는 TopLevelBackStack 클래스입니다. 작동 방식은 다음과 같습니다.

  • 각 최상위 대상 (탭)에 대해 별도의 백 스택을 유지합니다.
  • 현재 선택된 최상위 대상을 추적합니다.
  • NavDisplay 컴포저블에서 사용할 수 있는 단일 플랫 백 스택을 제공합니다. 이 병합된 백 스택은 모든 탭의 개별 백 스택을 결합한 것입니다.

UI 구조

UI는 bottomBarNavigationBar을 사용하여 Scaffold 컴포저블을 사용하여 빌드됩니다.

  • NavigationBar에는 각 최상위 대상 위치의 항목이 표시됩니다. 항목을 클릭하면 topLevelBackStack.addTopLevel를 호출하여 해당 탭으로 전환하고 각 탭의 탐색 기록을 유지합니다.
  • NavDisplay 컴포저블은 Scaffold의 콘텐츠 영역에 배치됩니다. TopLevelBackStack에서 제공하는 평탄화된 백 스택을 기반으로 현재 화면을 표시합니다.

이 접근 방식을 사용하면 사용자가 앱의 여러 섹션 간에 전환할 수 있고 각 섹션이 자체 탐색 기록을 유지하는 일반적인 탐색 패턴이 가능합니다.

상태 보존

이 레시피에서 탐색 상태가 관리되는 방식을 확인하는 것이 중요합니다. 사용자가 최상위 수준 대상에서 벗어나면 (예: 이전 탭으로 돌아갈 때까지 뒤로 버튼을 누름) 해당 대상의 전체 탐색 기록이 삭제됩니다. 상태가 저장되지 않습니다. 사용자가 나중에 해당 탭으로 돌아오면 초기 화면부터 시작됩니다.

참고: 이 예에서 Home 경로는 ChatListCamera 경로 위로 이동할 수 있습니다. 즉, 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()
    }
}