λ:

2021.9.9更新: 之前版本把页面切换直接放进了Activity, 跳转其他页是靠启动新Activity。但实践中都是单Activity模式,所以重新整理一下。

# 仓库地址: https://github.com/lzyprime/android_demos
# branch: bottom_navigation

git clone -b bottom_navigation https://github.com/lzyprime/android_demos

底部导航配合多页面切换是常见逻辑,微信,qq,抖音,淘宝等等,常见app里几乎都有这种设计。

底部导航栏:BottomNavgationView + menu, 涉及到图标和标题在点击时的变化(颜色,大小,选中与未选中图标变化)

页面切换有两个方案:

  1. Navigation组件

  2. ViewPager2

参考 使用 NavigationUI 更新界面组件。 会发现用navigation组件实现页面切换可以一行代码搞定。但是单Activity模式下,会有NavHost嵌套,需要处理层级问题。并且默认情况下,页面是不保存状态的,切页面每次都重建,2.4.0会加saveState处理。

ViewPager2,底层是RecyclerView, 相比Navgation会有页面缓存,支持滑动切页手势,也不会有NavHost嵌套问题。

Activity

Activity
|- MainNavHost
  |- MainNavFragment(home)
  | |- HomePageNavHost
  | | |- Item1Fragment(home)
  | | |- Item2Fragment
  | | |- Item3Fragment
  | | 
  | |- BottomNavgationView
  |  
  |  
  |- SecondaryFragment
    |- ViewPager
    | |- Item3Fragment(home)
    | |- Item2Fragment
    | |- Item1Fragment
    |- BottomNavgationView

项目总体层次图。问题主要出在MainNavHost,HomePageNavHost嵌套问题上。

<!-- activity_main.xml -->
<androidx.coordinatorlayout.widget.CoordinatorLayout ...>
    <androidx.fragment.app.FragmentContainerView
        ...
        android:id="@+id/mainNavHost"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:defaultNavHost="true"
        app:navGraph="@navigation/main_graph" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

Activity中只放一个NavHostFragment组织全局导航。 defaultNavHost=true拦截系统返回按钮, 执行navigateUp

main_graph:

main_graph

<!-- main_nav_fragment.xml -->
<LinearLayout ...>
    <androidx.fragment.app.FragmentContainerView
        ...
        android:id="@+id/homePageNavHost"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/main_nav_graph" />
    
    <com.google.android.material.bottomnavigation.BottomNavigationView
        ...
        android:id="@+id/mainBtmNavView"
        app:menu="@menu/main_nav_menu" />
</LinearLayout>

main_nav_graph:

main_nav_graph

main_nav_menu:

main_nav_menu

class MainNavFragment : Fragment(R.layout.main_nav_fragment) {
    private val binding by viewBinding<MainNavFragmentBinding>()
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val navController =
            (childFragmentManager.findFragmentById(R.id.homePageNavHost) as NavHostFragment).navController
        binding.mainBtmNavView.setupWithNavController(navController)
    }
}

通过setupWithNavController将NavHost与BottomNavgationView绑定。内部就是给BottomNavgationView设置Listener, 监听onNavigationItemSelected(MenuItem item)事件。然后通过item.itemId导航到同id目的地。

所以,menu 中id要与 导航图 中一致

但是如果想在子页面Item1, Item2, Item3操作全局导航,就要想办法拿到MainNavHost的navController:

class Item1Fragment : Fragment(R.layout.item1_fragment) {
    private val binding: Item1FragmentBinding by viewBinding()
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        val globalNavHostController = requireActivity().findNavController(R.id.mainNavHost)
        ...
    }
}

按照层级关系,在子页面调用findNavController()会返回 HomePageNavHost 的 controller。所以要额外操作,去拿Activity中MainNavHost。 凡是HomePageNavHost嵌套图中的View都会有这种问题。

ViewPager2

ViewPager2就不会有这些问题,因为在同一个NavHost下。

<!-- secondary_nav_fragment.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout ...>
    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/secondaryViewPager"
        ... />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/secondaryBtmNavView"
        app:menu="@menu/secondary_nav_menu" 
        ... />
</LinearLayout>
class SecondaryNavFragment : Fragment(R.layout.secondary_nav_fragment) {
    private val binding by viewBinding<SecondaryNavFragmentBinding>()

    private val fragments = arrayOf(Item3Fragment(), Item2Fragment(), Item1Fragment())
    private val itemIds = arrayOf(R.id.item3, R.id.item2, R.id.item1)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.secondaryViewPager.adapter = object : FragmentStateAdapter(this) {
            override fun getItemCount(): Int = fragments.size
            override fun createFragment(position: Int): Fragment = fragments[position]
        }

        binding.secondaryViewPager.offscreenPageLimit = fragments.size

        //binding.secondaryViewPager.isUserInputEnabled = false // 禁用滑动

        binding.secondaryViewPager.registerOnPageChangeCallback(object :
            ViewPager2.OnPageChangeCallback() {
            override fun onPageSelected(position: Int) {
                binding.secondaryBtmNavView.selectedItemId = itemIds[position]
            }
        })

        binding.secondaryBtmNavView.setOnItemSelectedListener {
            binding.secondaryViewPager.currentItem = itemIds.indexOf(it.itemId)
            true
        }
    }

}

ViewPager2设置:

  • 设置 FragmentStateAdapter。由于ViewPager2底层是RecyclerView, 所以这肯定是个RecyclerView.Adapter子类。
  • offscreenPageLimit, 缓存页面数
  • 注册 OnPageChangeCallback,监听页面滑动,页面切换完成时,设置底部导航栏选中项。
  • 如果不想滑动切换页面, isUserInputEnabled = false

BottomNavigationView设置:

设置点击事件监听,ViewPager2 跳转对应页面。