导航与TabLayout:结合TabLayout与ViewPager2、在Tab中使用Navigation、处理Tab切换时的状态
说实话,TabLayout + ViewPager2 这个组合,在 Android 开发里太常见了。但一旦和 Navigation 组件搅在一起,事情就变得有点微妙。我见过不少项目,要么直接用 ViewPager2 自己管理 Fragment,要么用 Navigation 但 Tab 切换时状态全丢了。今天我们就来聊聊,怎么把这两者优雅地结合起来。
为什么需要结合 Navigation 和 TabLayout?
你想想看,单 Activity 架构下,我们希望所有页面跳转都由 Navigation 统一管理。但 TabLayout 天生就是用来切换 Fragment 的,它自己有一套 FragmentManager 的逻辑。如果各玩各的,就会出现「导航回退时 Tab 状态混乱」或者「Tab 切换时 Navigation 不知道发生了什么」的问题。
我个人习惯的做法是:让 TabLayout 的每个 Tab 对应一个 Navigation 的 NavHostFragment。这样每个 Tab 内部都有自己的导航栈,互不干扰。
方案一:ViewPager2 + FragmentStateAdapter
这是最直接的方式。每个 Fragment 内部再嵌套一个 NavHostFragment。嗯,这里要注意:嵌套 Fragment 的导航栈需要单独处理。
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var adapter: TabPagerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
adapter = TabPagerAdapter(this)
binding.viewPager.adapter = adapter
// 将 TabLayout 与 ViewPager2 绑定
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
tab.text = when (position) {
0 -> "首页"
1 -> "发现"
2 -> "我的"
else -> ""
}
}.attach()
}
}
class TabPagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> HomeTabFragment()
1 -> DiscoverTabFragment()
2 -> ProfileTabFragment()
else -> HomeTabFragment()
}
}
}
每个 TabFragment 内部再放一个 NavHostFragment:
class HomeTabFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_home_tab, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 这里 childFragmentManager 管理的是当前 Tab 内的导航
val navHost = childFragmentManager
.findFragmentById(R.id.nav_host_fragment_home) as NavHostFragment
val navController = navHost.navController
// 后续可以用 navController 做页面跳转
}
}
方案二:不使用 ViewPager2,直接用 TabLayout + Navigation
有时候我们并不需要滑动切换,只需要点击 Tab 切换页面。这时候完全可以抛弃 ViewPager2,直接用 Navigation 来控制。
怎么做?每个 Tab 点击时,通过 Navigation 跳转到对应的目标 Fragment。但这里有个问题:Navigation 默认会创建新的 Fragment 实例,而不是复用已有的。我们需要配合 popUpTo 和 launchSingleTop 来避免重复创建。
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
val navController = findNavController(R.id.nav_host_fragment_activity)
when (tab?.position) {
0 -> navController.navigate(R.id.homeFragment)
1 -> navController.navigate(R.id.discoverFragment)
2 -> navController.navigate(R.id.profileFragment)
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) {}
override fun onTabReselected(tab: TabLayout.Tab?) {}
})
但这样写有个坑:每次点击 Tab 都会 push 一个新的 Fragment 到栈里。按返回键时,你会看到 Tab 页面一个个往回退,而不是直接退出 App。这显然不是我们想要的。
navController.navigate(R.id.homeFragment) { popUpTo(R.id.startDestination) { inclusive = true } },确保每次切换 Tab 时清空之前的导航栈。
方案三:每个 Tab 独立 NavHostFragment(推荐)
这是我在生产环境中用得最多的方案。说白了,就是给每个 Tab 分配一个独立的 NavHostFragment,用 ViewPager2 来切换显示。每个 NavHostFragment 都有自己的导航图,互不干扰。
结构图如下:
这种方案的好处很明显:
- 导航隔离:每个 Tab 的返回栈互不影响
- 状态保持:ViewPager2 默认会缓存相邻页面,切换回来时 Fragment 不会重建
- 深度链接支持:每个 Tab 内部都可以独立处理 deep link
处理 Tab 切换时的状态
这里有个细节很多人会忽略:Tab 切换时,Fragment 的生命周期会变化。比如从 Tab 0 切到 Tab 1,Tab 0 的 Fragment 会走到 onPause 甚至 onStop。如果你的页面有视频播放、倒计时之类的功能,就需要在 onResume 和 onPause 中做相应的处理。
我建议的做法是:
class HomeTabFragment : Fragment() {
override fun onResume() {
super.onResume()
// Tab 切换回来时恢复状态
// 比如重新开始播放视频、刷新数据等
}
override fun onPause() {
super.onPause()
// Tab 离开时暂停耗时操作
// 比如暂停视频、停止动画等
}
}
adapter.stateSaved = false,但要注意这样会增加内存占用。我个人习惯是让 ViewPager2 的 offscreenPageLimit 设为 2,这样所有 Tab 的 Fragment 都会被缓存。
Tab 切换时 Navigation 的状态同步
还有一个常见问题:当用户在 Tab 0 中跳转到了深层页面(比如 Fragment A → Fragment B → Fragment C),然后切换到 Tab 1 再切回来,我们希望看到的是 Fragment C,而不是 Fragment A。这个在方案三中天然支持,因为每个 Tab 的 NavHostFragment 是独立的。
但如果你用的是方案二(纯 Navigation 控制 Tab 切换),就需要手动保存和恢复状态。我一般用 SavedStateHandle 来记录每个 Tab 当前的导航位置:
class MainViewModel : ViewModel() {
private val _currentTabIndex = MutableLiveData(0)
val currentTabIndex: LiveData<Int> = _currentTabIndex
// 保存每个 Tab 的导航状态
private val tabNavStates = mutableMapOf<Int, NavState>()
fun saveTabState(tabIndex: Int, navState: NavState) {
tabNavStates[tabIndex] = navState
}
fun restoreTabState(tabIndex: Int): NavState? {
return tabNavStates[tabIndex]
}
}
嗯,说实话,这个方案比较麻烦。如果你不是有特殊需求,我建议直接用方案三,省心很多。
总结一下
今天聊了三种结合 TabLayout 和 Navigation 的方式:
| 方案 | 适用场景 | 状态管理 | 推荐度 |
|---|---|---|---|
| ViewPager2 + FragmentStateAdapter | 需要滑动切换,Tab 内部导航简单 | ViewPager2 自动管理 | ⭐⭐⭐ |
| 纯 Navigation 控制 Tab | 不需要滑动,Tab 数量少 | 需要手动管理 | ⭐⭐ |
| 每个 Tab 独立 NavHostFragment | 复杂导航,需要隔离返回栈 | 天然隔离,无需额外处理 | ⭐⭐⭐⭐⭐ |
我个人在项目中几乎都用方案三。虽然代码量稍微多一点,但后期维护起来真的很舒服。尤其是当产品经理说「这个 Tab 里再加一个二级页面」的时候,你只需要在那个 Tab 的导航图里加个 destination 就行了,完全不影响其他 Tab。
好了,今天就到这里。记住:Tab 切换的本质是 Fragment 的显示与隐藏,而 Navigation 的本质是 Fragment 的创建与销毁。把这两者的边界划清楚,你的架构就不会乱。