导航进阶:嵌套导航、底部导航栏与 NavigationBar 的结合

说实话,导航这块儿,很多新手容易栽跟头。

单层导航还好说,一旦涉及到底部导航栏 + 页面内部还有子页面,很多人就开始懵了。我当年第一次做这种结构时,也折腾了好一阵子。今天咱们就把这个硬骨头啃下来。

为什么需要嵌套导航?

你想想看,一个典型的 App 长什么样?

底部四个 Tab:首页、发现、消息、我的。每个 Tab 点进去,里面还有二级页面。比如首页点进文章详情,消息列表点进聊天窗口。

如果只用一套 NavHost,所有页面都平铺在一起,那返回逻辑就乱套了。你从消息 Tab 跳到了聊天页,然后切到首页 Tab,再按返回键——应该回到哪?

嗯,这就是嵌套导航要解决的问题。

核心思路:每个底部 Tab 拥有自己独立的导航栈。Tab 之间切换时,保留各自的历史栈。返回键只影响当前可见的 Tab 内部导航。

先搭架子:底部导航栏

我个人习惯用 NavigationBar 和 NavigationBarItem 来构建底部栏。Material3 的这套组件用起来很顺手。

// 定义底部导航项
enum class BottomNavItem(
    val route: String,
    val label: String,
    val icon: ImageVector
) {
    Home("home", "首页", Icons.Default.Home),
    Discover("discover", "发现", Icons.Default.Explore),
    Messages("messages", "消息", Icons.Default.Email),
    Profile("profile", "我的", Icons.Default.Person)
}

然后写一个 Scaffold,把 NavigationBar 塞进去:

@Composable
fun MainScreen() {
    var selectedTab by remember { mutableStateOf(BottomNavItem.Home) }

    Scaffold(
        bottomBar = {
            NavigationBar {
                BottomNavItem.entries.forEach { item ->
                    NavigationBarItem(
                        selected = selectedTab == item,
                        onClick = { selectedTab = item },
                        icon = { Icon(item.icon, contentDescription = item.label) },
                        label = { Text(item.label) }
                    )
                }
            }
        }
    ) { innerPadding ->
        // 这里放内容区域
    }
}

小技巧:NavigationBarItem 的 selected 状态一定要和当前 Tab 绑定。我见过有人直接用布尔值硬编码,结果切换时动画效果全没了。

嵌套导航的核心:NavHost 套 NavHost

说白了,就是每个 Tab 内部再放一个 NavHost。但要注意,这些子 NavHost 不能共用同一个 NavController。

我建议这样组织:

  • 一个顶层的 NavHost,管理 Tab 切换
  • 每个 Tab 内部有自己的子 NavHost,管理该 Tab 下的页面跳转

代码结构大概是这样的:

@Composable
fun MainScreen() {
    val rootNavController = rememberNavController()
    var selectedTab by remember { mutableStateOf(BottomNavItem.Home) }

    Scaffold(
        bottomBar = {
            NavigationBar {
                BottomNavItem.entries.forEach { item ->
                    NavigationBarItem(
                        selected = selectedTab == item,
                        onClick = {
                            selectedTab = item
                            rootNavController.navigate(item.route) {
                                popUpTo(rootNavController.graph.startDestinationId) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = { Icon(item.icon, contentDescription = item.label) },
                        label = { Text(item.label) }
                    )
                }
            }
        }
    ) { innerPadding ->
        NavHost(
            navController = rootNavController,
            startDestination = BottomNavItem.Home.route,
            modifier = Modifier.padding(innerPadding)
        ) {
            composable(BottomNavItem.Home.route) {
                HomeNavGraph() // 首页的子导航
            }
            composable(BottomNavItem.Discover.route) {
                DiscoverNavGraph()
            }
            // ... 其他 Tab
        }
    }
}

注意:popUpTo 里的 saveState = true 和 restoreState = true 是关键。没有这两句,你切到其他 Tab 再回来,之前打开的二级页面就丢了。我曾经踩过这个坑,用户反馈说「我明明打开了文章,切出去再回来怎么就回到列表了?」

子导航图怎么写?

每个子导航图就是一个独立的 NavHost,有自己的 NavController。

@Composable
fun HomeNavGraph() {
    val homeNavController = rememberNavController()

    NavHost(
        navController = homeNavController,
        startDestination = "home_list"
    ) {
        composable("home_list") {
            HomeListScreen(
                onArticleClick = { articleId ->
                    homeNavController.navigate("article/$articleId")
                }
            )
        }
        composable(
            route = "article/{articleId}",
            arguments = listOf(navArgument("articleId") { type = NavType.StringType })
        ) { backStackEntry ->
            val articleId = backStackEntry.arguments?.getString("articleId") ?: ""
            ArticleDetailScreen(articleId = articleId)
        }
    }
}

你看,首页列表和文章详情页都在 HomeNavGraph 里管理。返回键只会从详情页回到列表,不会跳到其他 Tab。

用 SVG 画一张结构图

光说可能不够直观,我画了一张图帮你理清关系:

嵌套导航结构图 顶层 NavHost(管理 Tab 切换) Tab 1: 首页 HomeNavGraph 首页列表 文章详情 返回 Tab 2: 发现 DiscoverNavGraph 发现列表 详情页 首页 发现 消息

从图上可以清楚看到:每个 Tab 内部是一个独立的导航图,它们之间互不干扰。顶层 NavHost 只负责切换 Tab,不关心 Tab 内部怎么跳转。

避坑指南:返回键的处理

我曾经遇到一个很隐蔽的问题:用户在 Tab A 的二级页面,按返回键时,直接退出了 App。

原因是什么?因为顶层 NavHost 的返回键默认是退出整个导航。我们需要拦截返回事件,先交给当前 Tab 的子 NavController 处理。

@Composable
fun HandleBackPressForChildNav(childNavController: NavHostController) {
    val backPressDispatcher = LocalBackPressedDispatcherOwner.current?.onBackPressedDispatcher
    val currentBackStackEntry by childNavController.currentBackStackEntryAsState()
    
    BackHandler(enabled = currentBackStackEntry != null) {
        if (childNavController.currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED) {
            childNavController.popBackStack()
        }
    }
}

把这个 Composable 放在每个子导航图里,就能保证返回键先处理子导航的返回,子导航栈空了再交给顶层。

我的习惯:在子导航图的入口 composable 里调用 HandleBackPressForChildNav。这样每个子图各自负责自己的返回逻辑,清晰又干净。

状态保存与恢复

还有一个细节:Tab 切换时,子导航的状态怎么保存?

其实上面代码里已经用到了——popUpTo 里的 saveState 和 restoreState。但要注意,这只保存了导航栈的路径,不会保存页面内的滚动位置、输入框内容等。

如果需要保存更细粒度的状态,我建议用 ViewModel + SavedStateHandle。每个 Tab 的 ViewModel 独立作用域,切换 Tab 时 ViewModel 不会被销毁,数据自然就保留了。

class HomeViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val scrollPosition = savedStateHandle.getStateFlow("scrollPosition", 0)
    
    fun saveScrollPosition(position: Int) {
        savedStateHandle["scrollPosition"] = position
    }
}

嗯,这样用户切到其他 Tab 再回来,列表还是停留在之前的位置。体验会好很多。

总结一下

嵌套导航 + 底部导航栏,说白了就是「分层管理」四个字。

  • 顶层 NavHost 管 Tab 切换
  • 子 NavHost 管 Tab 内部页面跳转
  • 返回键优先交给子导航处理
  • 状态保存靠 saveState/restoreState + ViewModel

这套模式我在好几个项目里都用过,稳定可靠。你照着这个思路写,基本不会出大问题。

核心口诀:一个 Tab 一个图,返回先找子路由。切换保存状态,ViewModel 来兜底。

公众号:蓝海资料掘金营,微信deep3321