# 範例程式碼 - Single Back Stack
## 在 build.gradle(:app) 中設定 Compose 與 Navigation 依賴
```
dependencies {
implementation "androidx.navigation:navigation-compose:2.7.7"
implementation "androidx.compose.material3:material3:1.2.0"
implementation "androidx.compose.material:material-icons-extended"
}
```
## 在 `data/model` 建立 BottomNavigationItem 資料類別
```
data class BottomNavigationItem(
val title: String,
val route: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector
)
```
## 設計底部導覽欄
```
val items = listOf(
BottomNavigationItem(
title = "Home",
route = "home",
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home
),
BottomNavigationItem(
title = "Chat",
route = "chat",
selectedIcon = Icons.Filled.Email,
unselectedIcon = Icons.Outlined.Email
),
BottomNavigationItem(
title = "Settings",
route = "settings",
selectedIcon = Icons.Filled.Settings,
unselectedIcon = Icons.Outlined.Settings
)
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
MultipleBackStacksComposeTheme {
Scaffold(
bottomBar = {
NavigationBar {
items.forEach { item ->
val isSelected = item.route == currentRoute
NavigationBarItem(
selected = isSelected,
label = { Text(item.title) },
icon = {
Icon(
imageVector = if (isSelected) item.selectedIcon else item.unselectedIcon,
contentDescription = item.title
)
},
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = false
}
launchSingleTop = true
restoreState = false
}
}
)
}
}
},
modifier = Modifier.fillMaxSize()
) { innerPadding ->
}
}
}
}
}
```
## 設定 Navigation Graph 並
```
setContent {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
MultipleBackStacksComposeTheme {
Scaffold(
bottomBar = {
NavigationBar {
items.forEach { item ->
val isSelected = item.route == currentRoute
NavigationBarItem(
selected = isSelected,
label = { Text(item.title) },
icon = {
Icon(
imageVector = if (isSelected) item.selectedIcon else item.unselectedIcon,
contentDescription = item.title
)
},
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = false
}
launchSingleTop = true
restoreState = false
}
}
)
}
}
},
modifier = Modifier.fillMaxSize()
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(innerPadding)
) {
// 頂層起始頁
composable("home") {
GenericScreen("Home 1") { navController.navigate("home2") }
}
composable("chat") {
GenericScreen("Chat 1") { navController.navigate("chat2") }
}
composable("settings") {
GenericScreen("Settings 1") { navController.navigate("settings2") }
}
// 多層頁面(Home2 ~ Home10, Chat2 ~ Chat10, Settings2 ~ Settings10)
for (i in 2..10) {
composable("home$i") {
GenericScreen("Home $i") {
if (i < 10) navController.navigate("home${i + 1}")
}
}
composable("chat$i") {
GenericScreen("Chat $i") {
if (i < 10) navController.navigate("chat${i + 1}")
}
}
composable("settings$i") {
GenericScreen("Settings $i") {
if (i < 10) navController.navigate("settings${i + 1}")
}
}
}
}
}
}
}
```
## 在 `ui/screen` 建立可重複使用的 GenericScreen
```
@Composable
fun GenericScreen(
text: String,
onNextClick: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = text)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onNextClick) {
Text("Next")
}
}
}
```
# 將 Single Back Stack 重構為 Multiple Back Stacks
## 使用獨立堆疊提取 NavHost 並且BottomNavigation 保留每個 Tab 的獨立 Back Stack
```kotlin
@Composable
fun HomeNavHost() {
val homeNavController = rememberNavController()
NavHost(homeNavController, startDestination = "home1") {
for (i in 1..10) {
composable("home$i") {
GenericScreen("Home $i") {
if (i < 10) homeNavController.navigate("home${i + 1}")
}
}
}
}
}
```
---
```kotlin
@Composable
fun ChatNavHost() {
val chatNavController = rememberNavController()
NavHost(chatNavController, startDestination = "chat1") {
for (i in 1..10) {
composable("chat$i") {
GenericScreen("Chat $i") {
if (i < 10) chatNavController.navigate("chat${i + 1}")
}
}
}
}
}
```
---
```kotlin
@Composable
fun SettingsNavHost() {
val settingsNavController = rememberNavController()
NavHost(settingsNavController, startDestination = "settings1") {
for (i in 1..10) {
composable("settings$i") {
GenericScreen("Settings $i") {
if (i < 10) settingsNavController.navigate("settings${i + 1}")
}
}
}
}
}
```
---
```kotlin
NavHost(rootNavController, startDestination = "home") {
composable("home") { HomeNavHost() }
composable("chat") { ChatNavHost() }
composable("settings") { SettingsNavHost() }
}
```
---
# Compose Navigation 重構指南:從 Single Back Stack 到 Multiple Back Stacks
在使用 Jetpack Compose 開發多頁面的應用時,底部導覽(Bottom Navigation)常常扮演重要角色。最初我們通常會採用單一導航堆疊(Single Back Stack)實作,但很快就會遇到 UX 的瓶頸:使用者切換 Tab 時無法保留每個分頁的歷史狀態。
這篇文章將從開發者的角度,完整拆解如何一步步將 App 架構從 Single Back Stack 重構為 Multiple Back Stacks,提升導航體驗與程式彈性。
---
## 問題背景:Single Back Stack 的限制
當所有頁面共用一個 NavController:
* 所有 Tab 共用一條導航堆疊。
* 使用者在 Tab 中深入點擊幾層後,切換到其他 Tab,然後再切回來,畫面會重置為初始頁面。
* 無法維持使用者在各個 Tab 中的歷史狀態。
這對使用者來說體驗很差,對開發者來說也限制了彈性。
---
## 重構目標:Multiple Back Stacks
所謂 Multiple Back Stacks,是指:
* 每個底部導覽的 Tab 都有獨立的 NavController 和導航堆疊。
* 使用者在某個 Tab 中的瀏覽紀錄可以被完整保留。
* 切換 Tab 僅切換視圖,不清除對應的狀態。
---
## 重構步驟一覽
### 抽離每個 Tab 的導覽圖(Navigation Graph)
將每個主要 Tab 的頁面定義,從主導航圖中獨立出來,轉為一個個獨立的 Navigation Host(NavHost),每個都有自己的 NavController。
這樣每個 Tab 的頁面狀態就能獨立管理,互不干擾。
---
### 建立中央控制器(Root NavController)來切換 Tab
雖然每個 Tab 有獨立的導覽堆疊,但仍需要一個中央入口點(通常是主畫面或 MainActivity),用來根據當前選擇的 Tab 顯示對應的 NavHost。
這裡的 Root NavController 不負責管理子頁面,只負責控制當前顯示哪個 Tab。
---
### 使用 rememberSaveable 記錄當前 Tab
為了讓組件重組時保留目前選中的 Tab,你應該用 `rememberSaveable` 來儲存 Tab 狀態,避免畫面在重繪時跳回預設值。
---
### 整合到底部導覽(Bottom Navigation)中
在 `BottomNavigationBar` 的點擊事件中,觸發切換當前 Tab 的狀態,並讓畫面顯示對應的 NavHost。
記住,切換的是哪個 NavHost 顯示,而不是在主導航圖中切換 route。
---
### 保持每個 NavHost 的狀態不被重置
在 Compose 中,每次組件被重新組合(Recompose)時,如果 NavController 沒有適當地用 `remember` 管理,就會導致狀態丟失。
確保每個 NavController 是 `remember` 出來的,並包在記憶範圍內(通常建議不要放在可重組的函式內部直接 new)。
---
## 整體架構小結
| 組件 | 職責 |
| ------------------------ | ---------------------- |
| RootNavController | 管理目前顯示哪個 Tab |
| 個別 NavController(每個 Tab) | 管理各自的導航狀態與堆疊 |
| BottomNavigation | 操作目前 Tab,觸發切換 |
| rememberSaveable | 儲存目前選中的 Tab 狀態 |
| 每個 NavHost | 建立專屬的 Navigation Graph |
---
## 常見錯誤與注意事項
* 誤用單一 NavController:這會讓你永遠無法保留 Tab 狀態。
* 沒有使用 rememberSaveable:組件重組時 Tab 狀態會丟失。
* NavController 沒有隔離範圍:每次切換都重建 NavHost,造成 UI 閃爍或導航重置。
---
## 結語
Single Back Stack 適合簡單導覽結構,但一旦牽涉到底部導覽、多層畫面與複雜互動,採用 Multiple Back Stacks 幾乎是不可避免的選擇。
透過這個重構流程,你可以:
* 提升使用者體驗
* 增加程式可維護性
* 與原生 Android 導覽行為保持一致
---