Навигация в Android Jetpack с помощью ViewPager и TabLayout

Для нового приложения я использую библиотеку навигации Jetpack, чтобы реализовать правильную обратную навигацию. Первый уровень навигации - это панель навигации, которая отлично работает с навигацией на реактивном ранце, как описано в документации. Но есть еще один уровень навигации, реализованный с помощью ViewPager и TabLayout. Фрагменты, переключенные TabLayout, содержат дополнительную линейную иерархию навигации. Однако, похоже, нет поддержки ViewPager / TabLayout в Jetpack Navigation. Должен быть реализован FragmentPagerAdapter, и управляемый задний стек завершается при переключении вкладок. Существует разрыв между навигацией верхнего уровня и навигацией внутри каждой вкладки. Есть ли способ заставить это работать с Jetpack Navigation?




Ответы (5)


Экспериментировал с различными подходами к управлению TabLayout с помощью Jetpack Navigation. Но есть проблемы, такие как наличие полной истории переключения между вкладками несколько раз и т. Д.

Просматривая известные проблемы Google Android, прежде чем запросить демонстрацию, я обнаружил эту существующую проблему.

Его статус Закрыт помечен как Предполагаемое поведение со следующим объяснением:

Навигация сосредоточена на элементах, которые влияют на задний стек, а вкладки не влияют на задний стек - вам следует продолжать управлять вкладками с помощью ViewPager и TabLayout - см. Обучение на YouTube.

person sunadorer    schedule 18.03.2019
comment
Должны ли мы тогда следовать старому способу управления стеками фрагментов, транзакциями, анимацией? - person Sumit Shukla; 17.11.2020

То, как вы реализуете навигацию на панели приложений, меняет вашу реализацию. Если вы хотите использовать навигацию от страницы к деталям, используйте тот же fragmentManager, что и основной фрагмент NavHost. Это все равно что детализировать фрагмент / действие.

введите описание изображения здесь

Home, Dashboard и Notification имеют свои собственные графики, поэтому они могут открывать свои дочерние фрагменты, в то время как фрагмент входа принадлежит основному графу навигации, поэтому он открывает его как фрагмент детали.

Для этой реализации требуется main NavHostFragment во фрагменте или MainActivity.

Макеты

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 дает сбой при навигации по панели приложений, поэтому используйте фрагмент, если вы столкнулись с ошибкой 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>

Fragments for ViewPager2 that have NavHostFragment, only add one, others have the same layout as this one except app:navGraph="@navigation/nav_graph_home" with their own graphs.

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>

Nothing special with other fragments, skipped them, i added link for full sample and other navigation component examples if you are interested.

Графики навигации

Главный навигационный график, 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" />

And one of the nav graph for pages of ViewPager2, others are same.

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" />

Important thing with ViewPager nav graphs is to use fragment on screen instead of NavHost fragment, you need otherwise set navigation with

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

во фрагментах NavHost, когда прикреплен navHost фрагмента.

Основная деятельность

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 Функция предназначена только для наблюдения за изменением основного стека и фрагмента, она имеет только наблюдательную цель, удаляет ее, если не требуется.

Адаптер для 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 не имеют навигации на панели приложений, поскольку в этом примере это не реализовано.

MainFragment

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()

Наконец, вложенные фрагменты Пейджера

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. Чтобы решить эту проблему, вы должны использовать OnBackPressedCallback с navController?.navigateUp()
  2. Когда вы используете OnBackPressedCallback, когда находитесь в корне фрагмента ViewPager, например HomeFragment1, вы не можете вернуться, поскольку используете navController?.navigateUp(). Чтобы исправить это, вы должны проверить, что if (navController?.currentDestination?.id == navController?.graph?.startDestination) - это корень.
  3. Когда вы вызываете requireActivity().onBackPressed(), он вызывает handleOnBackPressed и создает бесконечный цикл. Итак, отключите обратный вызов раньше и снова сбросьте его.
  4. Также отключите обратный вызов в onPause (), когда ваш фрагмент не виден, чтобы предотвратить его вызов при вызове handleOnBackPressed других фрагментов

Я создал другие примеры, включая пример с вложенной навигацией для дочерних фрагментов ViewPager2, это ссылка на текущий проект. Для с изображением ниже. Это более сложно, требует использования LiveData и имеет проблемы с вращением. Также добавьте еще один пример с ViewModel, который также решает эту проблему.

введите описание изображения здесь

person Thracian    schedule 23.06.2020

Что сработало для меня до сих пор:

В navigation_graph.xml

  • сделайте ViewPagerFragment корнем вложенного графа
  • подключите вашу входящую и выходящую навигацию к вложенному графу

во вложенном графе:

  • добавить ChildFragments ViewPager во вложенный граф

Мне не нужно было менять ViewPager, и для дочерних фрагментов были созданы направления, поэтому оттуда возможна навигация.

person user2965003    schedule 10.12.2018
comment
Вы можете лучше это объяснить? Я не понимаю, что вы имеете в виду, говоря о создании ViewPagerFragment ... Что такое ViewPagerFragment? Я знаю, что такое ViewPager, и знаю, что он содержит фрагменты. Есть где-нибудь рабочий пример? - person Mitch; 08.01.2019
comment
Я думаю, что под ViewPagerFragment он имел в виду, что фрагмент, содержащий в себе viewpager - person Shivaraj Patil; 10.07.2020

Это сработало для меня. Я добавил фрагмент 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>

а затем внутри дочернего фрагмента окна просмотра:

val action = TabsFragmentDirections.actionNavTabsToNavSend()
findNavController().navigate(action)
person hushed_voice    schedule 09.06.2020
comment
если у меня было 1000 учетных записей, я буду голосовать за этот ответ от каждой учетной записи. Я застрял в этом почти на день. - person kartoos khan; 09.03.2021

Да, но вам нужно будет реализовать свое собственное назначение, реализовав класс _ 1_ и переопределение как минимум методов popBackStack() и _ 3_.

В вашем 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
}

Краткое объяснение можно найти в документации по Android и этот пример настраиваемого навигатора для FragmentDialog.

После реализации вашего ViewPagerNavigator вам нужно будет добавить его в свой NavController и настроить прослушиватели выбора представлений вкладок на вызов NavController.navigate().

Надеюсь, кто-нибудь реализует библиотеку для всех этих распространенных паттернов (ViewPager, ViewGroup, FragmentDialog), если кто-нибудь найдет, выложите в комментарии.

person Allan Veloso    schedule 05.02.2019
comment
Библиотека была бы потрясающей. Вы когда-нибудь приходили к написанию демонстрационного приложения самостоятельно? Я изучал разные идеи, но теперь нашел заявление сторонника Google, предлагающее обрабатывать вкладки как раньше и отдельно от Jetpack Navigation. - person sunadorer; 18.03.2019
comment
Не для ViewPager, я в настоящее время меняю все свои ViewPager для представлений ресайклера с помощью SnapPagerHelper. Я думаю, что навигация в большинстве случаев, в которых используется ViewPager-подобный вид и TabLayout, лучше достигается путем отправки в дополнительных элементах навигации идентификатора вкладки, которая должна быть активирована (случай, который, вероятно, имеет в виду Google). Но в некоторых случаях вам может потребоваться навигация, потому что ViewPager действует как шаговый двигатель. Для этого вам нужна правильная обработка обратного стека. Я реализовал ViewNavigator, который заменяет содержимое ViewGroup настраиваемым представлением, и я использую его для своего процесса регистрации. - person Allan Veloso; 18.03.2019