导航进阶:嵌套导航、底部导航栏与 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 画一张结构图
光说可能不够直观,我画了一张图帮你理清关系:
从图上可以清楚看到:每个 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 来兜底。