Android 使用 ViewPager 和 TabLayout 的 Jetpack 导航

Android Jetpack Navigation with ViewPager and TabLayout

对于新应用程序,我使用 Jetpack 导航库来实现正确的后退导航。第一级导航是一个导航抽屉,它可以很好地与文档中描述的喷气背包导航一起工作。但是还有另一个层次的导航是用 ViewPager 和 TabLayout 实现的。 TabLayout 切换的片段包含额外的线性导航层次结构。但是,Jetpack Navigation 中似乎不支持 ViewPager/TabLayout。必须实现 FragmentPagerAdapter,并且在切换选项卡时管理的返回堆栈结束。顶级导航和每个选项卡内的导航之间存在脱节。有什么方法可以使它与 Jetpack Navigation 一起使用吗?

到目前为止对我有用的是:

在navigation_graph.xml

  • 使您的 ViewPagerFragment 成为嵌套图的根
  • 将您的进出导航连接到嵌套图表

在嵌套图中:

  • 将 ViewPager 的 ChildFragments 添加到嵌套图中

我不需要更改 ViewPager,并且为子片段创建了方向,因此可以从那里进行导航。

,但您必须通过实施 class Navigator and overriding at least the methods popBackStack() and navigate().

来实施您自己的自定义目的地

在您的 navigate 中,您必须调用 ViewPager.setCurrentTab() 并将其添加到您的后台堆栈。类似于:

lateinit var viewPager: ViewPager? = null // you have to provide this in the constructor

private val backstack: Deque<Pair<Int, View>> = ArrayDeque

override fun navigate(destination: Destination, args: Bundle?,
                      navOptions: NavOptions?, navigatorExtras: Extras?
): NavDestination? {

    viewPager.currentItem = destination.id
    backstack.remove(destination.id) // remove so the stack has never two of the same
    backstack.addLast(destination.id)

    return destination
}

在您的 popBackStack 中,您将不得不退回最后选择的项目。类似于:

override fun popBackStack(): Boolean {
    if(backstack.size() <= 1) return false

    viewPager.currentItem = backstack.peekLast()
    backstack.removeLast()

    return true
}

您可以在 FragmentDialog 的自定义导航器 Android docs and this example 上找到简要说明。

实施 ViewPagerNavigator 后,您必须将其添加到 NavController 并将选项卡视图选择的侦听器设置为调用 NavController.navigate().

我希望有人会为所有这些常见模式(ViewPager、ViewGroup、FragmentDialog)实现一个库,如果有人找到它,请把它放在评论中。

试验了使用 Jetpack Navigation 处理 TabLayout 的不同方法。但是遇到了一些问题,比如拥有多次在选项卡之间切换的完整历史记录等。

浏览已知 Google Android 提出演示请求之前的问题,我发现了这个 existing issue

其状态为已关闭,标记为预期行为,解释如下:

Navigation focuses on elements that affect the back stack and tabs do not affect the back stack - you should continue to manage tabs with a ViewPager and TabLayout - Referring to Youtube training.

这对我有用。我将 viewPagerTabs 片段添加到嵌套图中,如下所示:

<navigation
        android:id="@+id/nav_nested_graph"
        app:startDestination="@id/nav_viewpager_tab">
        <fragment
            android:id="@+id/nav_pager_tab"
            android:name="com.android.ui.tabs.TabsFragment"
            android:label="@string/tag_tabs"
            tools:layout="@layout/tabs_fragment">
            <action
                android:id="@+id/action_nav_tabs_to_nav_send"
                app:destination="@id/nav_send_graph">
        </fragment>
</navigation>

然后在 viewpager 的子片段中:

val action = TabsFragmentDirections.actionNavTabsToNavSend()
findNavController().navigate(action)

应用栏导航的实现方式会改变您的实现方式。如果您希望使用从页面到细节的导航,它使用与主要 NavHost 片段使用的相同的 fragmentManager。好像要详细fragment/activity.

Home、Dashboard 和 Notification 有自己的图表,因此它们可以打开它们的子片段,而 Login 片段属于主导航图,因此它打开它的片段作为详细片段。

此实现需要片段中的 main NavHostFragmentMainActivity.

布局

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">


        <com.google.android.material.appbar.AppBarLayout
                android:id="@+id/appbar"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

            <androidx.appcompat.widget.Toolbar
                    android:id="@+id/toolbar"
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize"
                    app:popupTheme="@style/ThemeOverlay.AppCompat.ActionBar" />

        </com.google.android.material.appbar.AppBarLayout>

        <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:layout_behavior="@string/appbar_scrolling_view_behavior">

            <fragment
                    android:id="@+id/nav_host_fragment"
                    android:name="androidx.navigation.fragment.NavHostFragment"
                    android:layout_width="0dp"
                    android:layout_height="0dp"
                    app:layout_constraintLeft_toLeftOf="parent"
                    app:layout_constraintRight_toRightOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintBottom_toBottomOf="parent"

                    app:defaultNavHost="true"
                    app:navGraph="@navigation/nav_graph"/>

        </androidx.constraintlayout.widget.ConstraintLayout>

    </androidx.coordinatorlayout.widget.CoordinatorLayout>

</layout>

截至目前 androidx.fragment.app.FragmentContainerView appbar 导航崩溃,所以使用 fragment如果遇到navController未找到错误

fragment_main.xml

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:background="@color/colorPrimary"
            app:tabTextColor="#fff"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:tabMode="scrollable" />

    <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tabLayout" />

</androidx.constraintlayout.widget.ConstraintLayout>

带有 NavHostFragment 的 ViewPager2 的片段,只添加一个,其他的布局与此相同,除了 app:navGraph="@navigation/nav_graph_home" 有自己的图表。

fragment_nav_host_home.xml

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nested_nav_host_fragment_home"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"

            app:defaultNavHost="false"
            app:navGraph="@navigation/nav_graph_home" />

</androidx.constraintlayout.widget.ConstraintLayout>

其他片段没有什么特别之处,跳过它们,如果您有兴趣,我添加了 link 完整示例和其他导航组件示例。

导航图

主导航图,nav_graph.xml

<!-- MainFragment-->
<fragment
        android:id="@+id/main_dest"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.MainFragment"
        android:label="MainFragment"
        tools:layout="@layout/fragment_main">

    <!-- Login -->
    <action
            android:id="@+id/action_main_dest_to_loginFragment2"
            app:destination="@id/loginFragment2" />
</fragment>


<!-- Global Action Start -->
<action
        android:id="@+id/action_global_start"
        app:destination="@id/main_dest"
        app:popUpTo="@id/main_dest"
        app:popUpToInclusive="true" />

<!-- Login -->
<fragment
        android:id="@+id/loginFragment2"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.LoginFragment2"
        android:label="LoginFragment2" />

和ViewPager2的pages nav graph之一,其他相同

nav_graph_home.xml

<fragment
        android:id="@+id/home_dest"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.navhost.HomeNavHostFragment"
        android:label="HomeHost"
        tools:layout="@layout/fragment_navhost_home" />

<fragment
        android:id="@+id/homeFragment1"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.HomeFragment1"
        android:label="HomeFragment1"
        tools:layout="@layout/fragment_home1">
    <action
            android:id="@+id/action_homeFragment1_to_homeFragment2"
            app:destination="@id/homeFragment2" />
</fragment>

<fragment
        android:id="@+id/homeFragment2"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.HomeFragment2"
        android:label="HomeFragment2"
        tools:layout="@layout/fragment_home2">
    <action
            android:id="@+id/action_homeFragment2_to_homeFragment3"
            app:destination="@id/homeFragment3" />
</fragment>

<fragment
        android:id="@+id/homeFragment3"
        android:name="com.smarttoolfactory.tutorial6_2navigationui_viewpager2_nestednavhost.blankfragment.HomeFragment3"
        android:label="HomeFragment3"
        tools:layout="@layout/fragment_home3" />

ViewPager 导航图的重要一点是在屏幕上使用片段而不是 NavHost 片段,否则您需要使用

设置导航
  if (navController!!.currentDestination == null || navController!!.currentDestination!!.id == navController!!.graph.startDestination) {
        navController?.navigate(R.id.homeFragment1)
    }

在附加片段的 navHost 时在 NavHost 片段中。

主要Activity

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        listenBackStackChange()

    }

    private fun listenBackStackChange() {
        // Get NavHostFragment
        val navHostFragment =
            supportFragmentManager.findFragmentById(R.id.main_nav_host_fragment)

        // ChildFragmentManager of NavHostFragment
        val navHostChildFragmentManager = navHostFragment?.childFragmentManager

        navHostChildFragmentManager?.addOnBackStackChangedListener {

            val backStackEntryCount = navHostChildFragmentManager.backStackEntryCount
            val fragments = navHostChildFragmentManager.fragments


            Toast.makeText(
                this,
                "Main graph backStackEntryCount: $backStackEntryCount, fragments: $fragments",
                Toast.LENGTH_SHORT
            ).show()
        }
    }
}

listenBackStackChange功能只是为了观察main fragment栈和fragment如何变化,仅供观察,不需要就去掉

ViewPager2 的适配器

class ChildFragmentStateAdapter(private val fragment: Fragment) :
    FragmentStateAdapter(fragment) {

    override fun getItemCount(): Int = 4

    override fun createFragment(position: Int): Fragment {


        return when (position) {
            0 -> HomeNavHostFragment()
            1 -> DashBoardNavHostFragment()
            2 -> NotificationHostFragment()
            else -> LoginFragment1()
        }
    }

}

具有 HostFragment 的片段没有应用栏导航,因为它未在此示例中实现。

主要片段

class MainFragment : BaseDataBindingFragment() {

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    // TabLayout
    val tabLayout = dataBinding.tabLayout
    // ViewPager2
    val viewPager = dataBinding.viewPager

    /*
         Set Adapter for ViewPager inside this fragment using this Fragment,
        more specifically childFragmentManager as param
     */
    viewPager.adapter = ChildFragmentStateAdapter(this)

    // Bind tabs and viewpager
    TabLayoutMediator(tabLayout, viewPager) { tab, position ->
       when(position) {
           0->  tab.text = "Home"
           1->  tab.text = "Notification"
           2->  tab.text = "Dashboard"
           3->  tab.text = "Login"
       }
    }.attach()

}

override fun getLayoutRes(): Int = R.layout.fragment_main

}

MainFragment 设置选项卡,BaseDataBindingFragment 仅通过 getLayoutRes()

使用数据绑定

最后是 Pager 的嵌套片段

class HomeNavHostFragment : BaseDataBindingFragment<FragmentNavhostHomeBinding>() {
   
    override fun getLayoutRes(): Int = R.layout.fragment_navhost_home

    var navController: NavController? = null

    private val nestedNavHostFragmentId = R.id.nested_nav_host_fragment_home
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        /*
             This is navController we get from findNavController not the one required
            for navigating nested fragments
         */
        val mainNavController =
            Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)

        val nestedNavHostFragment =
            childFragmentManager.findFragmentById(nestedNavHostFragmentId) as? NavHostFragment
        navController = nestedNavHostFragment?.navController
        
        /*
             Alternative 1
            Navigate to HomeFragment1 if there is no current destination and current destination
            is start destination. Set start destination as this fragment so it needs to
            navigate next destination.

            If start destination is NavHostFragment it's required to navigate to first
         */
//        if (navController!!.currentDestination == null || navController!!.currentDestination!!.id == navController!!.graph.startDestination) {
//            navController?.navigate(R.id.homeFragment1)
//        }

        /*
             Alternative 2 Reset graph to default status every time this fragment's view is created
            ❌ This does not work if initial destination if this fragment because it repeats
            creating this fragment in an infinite loop since graph is created every time
         */
//        val navInflater = navController!!.navInflater
//        nestedNavHostFragment!!.navController.graph = graph
//        val graph = navController!!.navInflater.inflate(navGraphId)
//        nestedNavHostFragment!!.navController.graph = graph



        // Listen on back press
        listenOnBackPressed()

    }



    private fun listenOnBackPressed() {
        requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
    }

    override fun onResume() {
        super.onResume()
        callback.isEnabled = true
    }

    override fun onPause() {
        super.onPause()
        callback.isEnabled = false
    }
    
    // This should be false, true causes problems on rotation
    val callback = object : OnBackPressedCallback(false) {

        override fun handleOnBackPressed() {

            // Get NavHostFragment
            val navHostFragment =
                childFragmentManager.findFragmentById(nestedNavHostFragmentId)
            // ChildFragmentManager of the current NavHostFragment
            val navHostChildFragmentManager = navHostFragment?.childFragmentManager

            val currentDestination = navController?.currentDestination
            val backStackEntryCount = navHostChildFragmentManager!!.backStackEntryCount

            val isAtStartDestination =
                (navController?.currentDestination?.id == navController?.graph?.startDestination)

      

            // Check if it's the root of nested fragments in this navhost
            if (navController?.currentDestination?.id == navController?.graph?.startDestination) {

                /*
                 Disable this callback because calls OnBackPressedDispatcher
                  gets invoked  calls this callback  gets stuck in a loop
                */
                isEnabled = false
                requireActivity().onBackPressed()
                isEnabled = true
            } else {
                navController?.navigateUp()
            }
        }
    }

}

这里重要的是正确使用onBackPressedDispatcher。 ViewPager2 中的嵌套片段后退导航存在一些问题。

  1. 由于当您按下后退按钮时片段不会添加到主后退堆栈,Activity 会完全跳过 ViewPager 后退堆栈。要解决此问题,您应该使用 OnBackPressedCallbacknavController?.navigateUp()
  2. 如果您在 ViewPager 片段的根目录下使用 OnBackPressedCallback,例如 HomeFragment1,您将无法返回,因为您正在使用 navController?.navigateUp()。要修复它,您应该检查 if (navController?.currentDestination?.id == navController?.graph?.startDestination) 是根。
  3. 当您调用 requireActivity().onBackPressed() 时,它会调用 handleOnBackPressed 并创建一个无限循环。所以,之前禁用回调并重新设置它。
  4. 同时在您的片段不可见时禁用 onPause() 中的回调,以防止在调用其他片段的 handleOnBackPressed 时调用它

我创建了其他示例,包括为 ViewPager2 的子片段嵌套导航的示例,这是 link for current project. For the one with image below。更棘手的是需要使用 LiveData 并且存在旋转问题。还可以添加另一个 ViewModel 示例来解决此问题。