Android Compose UI Bottom Navigation Bar 與 Navigation Drawer

大多數 App 都擁有全域的導航結構,如底部的 Bottom Navigation Bar 或側邊的 Navigation Drawer。我們要學習如何將它們與 NavController 結合。

資料結構定義

首先,定義一個 Sealed Class 來管理底部導航的選項。

sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
    object Home : Screen("home", "首頁", Icons.Filled.Home)
    object Search : Screen("search", "搜尋", Icons.Filled.Search)
    object Profile : Screen("profile", "個人", Icons.Filled.Person)
}

我們需要在 ScaffoldbottomBar 中監聽當前的 Route,以決定哪個 tab 該被高亮 (Active)。

val navController = rememberNavController()

// 取得目前的 BackStackEntry (用於判斷當前頁面)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route

Scaffold(
    bottomBar = {
        NavigationBar {
            val items = listOf(Screen.Home, Screen.Search, Screen.Profile)
            
            items.forEach { screen ->
                NavigationBarItem(
                    icon = { Icon(screen.icon, contentDescription = null) },
                    label = { Text(screen.title) },
                    selected = currentRoute == screen.route, // 判斷是否選中
                    onClick = {
                        navController.navigate(screen.route) {
                            // 避免堆疊過多頁面:點擊 tab 時會清空 back stack 直到 start destination
                            popUpTo(navController.graph.findStartDestination().id) {
                                saveState = true // 保存狀態 (例如捲動位置)
                            }
                            launchSingleTop = true // 避免重複開啟同個頁面
                            restoreState = true // 恢復狀態
                        }
                    }
                )
            }
        }
    }
) { innerPadding ->
    NavHost(
        navController = navController,
        startDestination = Screen.Home.route,
        modifier = Modifier.padding(innerPadding) // 重要!避免被底部欄遮住
    ) {
        composable(Screen.Home.route) { HomeScreen() }
        composable(Screen.Search.route) { SearchScreen() }
        composable(Screen.Profile.route) { ProfileScreen() }
    }
}

ModalNavigationDrawer (側邊選單)

如果你的導航項目很多,適合使用 Drawer。

// 這是選單的「狀態」。它記錄了目前選單是 Closed(關閉)還是 Open(開啟)
// remember 確保當畫面重組時,這個狀態不會被重置
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
// 開啟或關閉選單是一個 suspend function,必須在 Coroutine 中執行
// 這個 scope 讓你在點擊按鈕時,能啟動一個 Coroutine 來執行開關動作
val scope = rememberCoroutineScope()

ModalNavigationDrawer(
    drawerState = drawerState,
    // 選單內容:這裡定義了側邊攔滑出來後長什麼樣子
    drawerContent = {
        ModalDrawerSheet {
            Text("Drawer Header", modifier = Modifier.padding(16.dp))
            Divider()
            NavigationDrawerItem(
                label = { Text("Settings") },
                selected = false,
                onClick = { /* ... */ }
            )
        }
    }
) {
    // 這邊是 App 主畫面內容
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("App") },
                navigationIcon = {
                    IconButton(onClick = {
                        // 啟動協程來開啟 Drawer 選單
                        scope.launch { drawerState.open() }
                    }) {
                        Icon(Icons.Filled.Menu, contentDescription = null)
                    }
                }
            )
        }
    ) {
        // App Content
    }
}

小結

  • 使用 currentBackStackEntryAsState 來監聽路由變化,更新 UI 高亮狀態。
  • popUpTo / saveState / restoreState 這三個參數是底部導航的黃金三角,確保了切換 tab 時狀態不流失且不會無限堆疊。