company_banner

Navigation Component-дзюцу, vol. 2 – вложенные графы навигации


    Каждое большое приложение содержит множество способов навигации между экранами. А хорошая библиотека навигации должна помогать разработчику их реализовывать. Именно с такой мыслью я подошёл к исследованию кейсов со вложенными графами навигации.


    Это вторая из трёх статей про реализацию кейсов навигации при помощи Navigation Component-а.


    Первая статья про BottomNavigationView, третья — про многомодульность, диплинки и другие кейсы.


    Где на схеме приложения кейсы со вложенными графами?



    В этом кейсе мы говорим про следующую часть общей схемы навигации:



    Представим такую ситуацию: у нас есть 4 экрана — A, B, C и D. Пусть с экранов A и B вы можете перейти на экран C, с экрана C в экран D, а после D — вернуться на тот экран, который начал флоу C->D.


    А можно нагляднее?

    В тестовом приложении, которое я приготовил для разбора Navigation Component-а, есть две вкладки BottomNavigationView (на схеме это Search и Responses – но пусть они будут экранами A и B):



    С обеих этих вкладок мы можем перейти на некоторый вложенный флоу, который состоит из двух экранов (C и D):



    Если мы перейдём на экран C с вкладки Search (экрана A), то после экрана D мы должны вернуться на вкладку Search:



    А если мы стартуем экран C со вкладки Responses, то после завершения внутреннего флоу C->D мы должны вернуться на вкладку Responses:



    Этот кейс описывает старт последовательности экранов из разных мест приложения, а после её завершения возврат на тот экран, который начал эту последовательность.


    Как это реализовать? Для начала вы объявляете вашу «вложенную»‎ последовательность экранов в отдельном XML-файле навигации, чтобы можно было вкладывать её в другие графы:


    Объявление графа вложенной навигации
    <!-- company_flow__nav_graph.xml -->
    <navigation
        android:id="@+id/company_flow__nav_graph"
        app:startDestination="@id/CompanyFragment">
    
        <fragment
            android:id="@+id/CompanyFragment"
            android:name="ui.company.CompanyFragment">
            <action
                android:id="@+id/action__CompanyFragment__to__CompanyDetailsFragment"
                app:destination="@id/CompanyDetailsFragment" />
        </fragment>
    
        <fragment
            android:id="@+id/CompanyDetailsFragment"
            android:name="ui.company.CompanyDetailsFragment"/>
    
    </navigation>

    Затем следует вложить созданный граф навигации в уже существующий граф и использовать идентификатор вложенного графа для описания action-ов:


    Добавление графа навигации в другой граф
    <navigation
        android:id="@+id/menu__search"
        app:startDestination="@id/SearchContainerFragment">
    
        <fragment
            android:id="@+id/SearchContainerFragment"
            android:name="ui.tabs.search.SearchContainerFragment">
    
            <action
                android:id="@+id/action__SearchContainerFragment__to__CompanyFlow"
                app:destination="@id/company_flow__nav_graph" />
    
        </fragment>
    
        <include app:graph="@navigation/company_flow__nav_graph" />
    
    </navigation>

    Итак, вы описали навигацию из двух разных мест приложения во вложенный флоу. Но что делать с возвратом?


    Проблема в том, что Navigation Component не позволяет нормально описывать навигацию НАЗАД, только навигацию ВПЕРЁД. Но при этом даёт возможность описывать удаление экранов из back stack-а при помощи атрибутов popBackUp и popBackUpInclusive в XML, а также при помощи функции popBackStack в NavController-е.


    Пока размышлял над этим, заметил интересную вещь: я подключился дебаггером перед переходом с экрана Splash на экран с нижней навигацией и обнаружил, что поле mBackStack внутри NavController-а Splash-экрана содержит два объекта NavBackStackEntry.


    А можно на картинке?


    Честно говоря, я не ожидал увидеть там два объекта, поскольку в back stack-е фрагментов точно был только один SplashFragment. Откуда взялась вторая сущность? Оказалось, что первый объект представляет собой NavGraph, который запустился в моей корневой Activity, а второй объект – мой SplashFragment, который представлен классом FragmentNavigator.Destination.


    И тут у меня появилась идея – а что если вызвать на NavController-е функцию popBackStack и передать туда идентификатор графа? Коль скоро граф находится в back stack-е NavController-а, это должно удалить все экраны, которые были добавлены в рамках этого графа.


    И эта идея сработала.


    Возврат из flow при помощи popBackStack
    class CompanyDetailsFragment : Fragment(R.layout.fragment_company_details) {
    
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
    
            finish_flow_button.setOnClickListener {
                findNavController().popBackStack(R.id.company_flow__nav_graph, true)
            }
        }
    
    }

    Минус такого подхода к определению обратной навигации очевиден: эта навигация не отобразится в визуальном редакторе. Конечно, можно определить action в XML-е вот таким образом:


    Определение action-а для закрытия графа навигации
    <fragment
      android:id="@+id/CompanyDetailsFragment"
      android:name="ui.company.CompanyDetailsFragment"
      android:label="@string/fragment_company_details__title"
      tools:layout="@layout/fragment_company_details">
    
      <action
          android:id="@+id/action__finishCompanyFlow"
          app:popUpTo="@id/company_flow__nav_graph"
          app:popUpToInclusive="true" />
    
    </fragment>

    В таком случае мы сможем использовать для обратной навигации NavController:


    findNavController().navigate(R.id.action__finishCompanyFlow)

    Но есть в этом что-то семантически неправильное: странно использовать слово navigate для закрытия экранов и обратной навигации.


    Возврат результата из вложенного флоу


    Что ж, мы получили некоторое подобие обратной навигации. Но возникает ещё один вопрос: есть ли способ вернуть из вложенного флоу какой-нибудь результат?


    Да, есть. В Navigation Component 2.3 Google представил нам специальное key-value хранилище для проброса результатов с других экранов – SavedStateHandle. К этому хранилищу можно получить доступ через свойства NavController-а – previousBackStackEntry и currentBackStackEntry. Но в своих примерах Google почему-то считает, что ваш вложенный флоу всегда состоит только из одного экрана.


    Типичный пример работы с SavedStateHandle
    // Flow screen
    findNavController().previousBackStackEntry
        ?.savedStateHandle
        ?.set("some_key", "value")
    
    // Screen that waits result
    val result = findNavController().currentBackStackEntry
        ?.savedStateHandle
        ?.remove<String>("some_key")

    Что делать, если вложенный флоу состоит из нескольких экранов? Вы не можете использовать previousBackStackEntry для доступа к SavedStateHandle, потому что в этом случае вы положите данные в один из экранов вашего вложенного флоу. Для решения этой проблемы можно воспользоваться следующим фиксом:


    Посмотреть на фикс
    fragment_company_details__button.setOnClickListener {
        // Here we are inside nested navigation flow
        findNavController().popBackStack(R.id.company_flow__nav_graph, true)
    
        // At this line, "findNavController().currentBackStackEntry" means
        // screen that STARTED current nested flow.
        // So we can send the result!
        findNavController().currentBackStackEntry
          ?.savedStateHandle
          ?.set(COMPANY_FLOW_RESULT_FLAG, true)
    }

    Суть в следующем: до вызова findNavController().popBackStack вы находитесь ВНУТРИ вашего флоу экранов, а вот сразу после вызова popBackStack – уже на экране, который НАЧАЛ ваш флоу! И это означает, что вы можете использовать для доступа к SavedStateHandle свойство currentBackStackEntry. Этот entry будет означать ваш стартовый экран, которому нужен результат из флоу.


    В свою очередь, на на экране, который начал вложенный флоу, вы тоже используете currentBackStackEntry для доступа к SavedStateHandle. И, следовательно, читаете правильные данные:


    Читаем данные из SavedStateHandle
    // Read result from nested navigation flow
    val companyFlowResult = findNavController().currentBackStackEntry
        ?.savedStateHandle
        ?.remove<Boolean>(CompanyDetailsFragment.COMPANY_FLOW_RESULT_FLAG)
    
    text__company_flow_result.text = "${companyFlowResult}"

    Выводы по работе с вложенным флоу


    • Для обратной навигации из вложенного флоу, состоящего из нескольких экранов, можно использовать функцию NavController.popBackStack, передав туда идентификатор графа навигации вашего флоу.
    • Для проброса какого-либо результата из вложенного флоу можно использовать SavedStateHandle.


    Сейчас будет немного терминологии, чтобы синхронизировать наше понимание по поводу графов навигации.


    Пусть у нас два графа навигации – граф A и граф B. Я буду называть граф B вложенным в граф A, если мы вкладываем его через include. И, наоборот, я буду называть граф A внешним по отношению к графу B, если граф А включает в себя граф B.


    Ещё немного картинок

    Граф B – вложенный в граф A:



    Граф А – внешний по отношению к графу B:



    А теперь давайте разберём кейс навигации из вложенного графа во внешний граф.



    Допустим, у нас есть вкладка BottomNavigationView, с которой мы хотим открыть последовательность из двух экранов. После второго экрана мы должны вернуться на вкладку, которая начала эту последовательность…


    Что? В смысле, «это тот самый первый кейс, который ты уже разобрал»? Разве вы не заметили, что у этой последовательности НЕТ нижней навигации?


    Приблизить картинку

    Смотрите, вот экран с нижней навигацией:



    А вот последовательность экранов без неё:



    Мы хотим открыть последовательность экранов поверх имеющегося. Если мы просто вложим Auth-граф в граф навигации, относящийся к вкладке нижней навигации, то мы получим не тот результат, который хотим: экраны Auth-графа будут иметь нижнюю навигацию.


    Неправильный подход к такой навигации

    Пусть мы вставили граф auth flow-навигации в наш граф вкладки нижней навигации и добавили action для перехода в него:


    <navigation xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/menu__profile"
        app:startDestination="@id/ProfileContainerFragment">
    
        <fragment
            android:id="@+id/ProfileContainerFragment"
            android:name="ui.tabs.profile.ProfileContainerFragment">
    
            <action
                android:id="@+id/action__ProfileContainerFragment__to__AuthFlow"
                app:destination="@id/auth__nav_graph" />
    
        </fragment>
    
        <include app:graph="@navigation/auth__nav_graph" />
    
    </navigation>

    В этом случае первый экран auth-флоу появится в контейнере с нижней навигацией, а мы этого не хотели:



    При этом не хочется переносить логику работы с нижней навигацией в Activity, писать какие-то методы по скрытию / демонстрации этого BottomNavigationView. Не зря же я адаптировал фикс из NavigationAdvancedSample для фрагмента, в конце концов.


    В каком случае мы получим желаемый результат? Если каким-то образом осуществим навигацию из контейнера с BottomNavigationView (не из самой вкладки, а из контейнера, который является Host-ом для всех этих вкладок), то Auth-граф откроется без нижней навигации.


    А на картинке можно?

    Если у нас получится сделать навигацию по зелёной стрелочке, то мы сможем перейти от контейнера с нижней навигацией в контейнер без неё.



    Давайте введём action для навигации между MainFragment-ом и флоу авторизации:


    Описание навигации
    <!— app_nav_graph.xml —>
    <fragment
      android:id="@+id/SplashFragment"
      android:name="com.aaglobal.jnc_playground.ui.splash.SplashFragment"/>
    
    <fragment
      android:id="@+id/MainFragment"
      android:name="com.aaglobal.jnc_playground.ui.main.MainFragment">
      <action
          android:id="@+id/action__MainFragment__to__AuthFlow"
          app:destination="@id/auth__nav_graph" />
    </fragment>
    
    <include app:graph="@navigation/auth__nav_graph" />

    Но проблема в том, что если мы попытаемся использовать этот action прямо из вкладки нижней навигации, вот так:


    fragment_profile_container__button__open_auth_flow.setOnClickListener {
        findNavController().navigate(R.id.action__MainFragment__to__AuthFlow)
    }

    … то приложение упадёт с IllegalArgumentException, потому что NavController текущей вкладки ничего не знает о навигации вне своего Host-а навигации.


    Ищем «правильный» NavController


    Оказалось, что проблему можно решить, если найти «правильный» NavController, знающий про описанный вами action и привязанный к «внешнему» (с точки зрения фрагмента вкладки) для нас хосту. Если мы запустим action через него, навигация пройдёт ровно так, как нам нужно.


    В Navigation Component есть специальная утилитная функция для поиска NavController-а, который привязан к нужному вам контейнеру, – Navigation.findNavController:


    Открываем флоу авторизации правильно
    fragment_profile_container__button__open_auth_flow.setOnClickListener {
      Navigation.findNavController(
        requireActivity(),
        R.id.activity_root__fragment__nav_host
      ).navigate(R.id.action__MainFragment__to__AuthFlow)
    }


    Проблемы с навигацией по кнопке Back


    Итак, мы смогли открыть флоу авторизации поверх открытого фрагмента с нижней навигацией. Но появилась новая проблема: если пользователь нажмёт кнопку «Back», находясь на первом экране графа авторизации, приложение упадёт. Снова с IllegalArgumentException – на этот раз NavController не может найти контейнер, с которого мы только что пришли, как будто мы используем неправильный NavController для обратной навигации.


    Покажи гифку


    Исключение, которое мы получаем:


    java.lang.IllegalArgumentException: No view found for id 0x7f08009a (com.aaglobal.jnc_playground:id/fragment_main__nav_host_container) for fragment NavHostFragment{5150965} (e58fc3a2-b046-4c80-9def-9ca40957502d) id=0x7f08009a bottomNavigation#0}

    Эту проблему можно решить, переопределив поведение кнопки «Back». В одной из новых версий AndroidX появился удобный OnBackPressedCallback. Раз мы используем неправильный NavController по умолчанию, значит, мы можем подменить его на правильный:


    Переопределяем back-навигацию для первого экрана auth-графа
    class StartAuthFragment : Fragment(R.layout.fragment_start_auth) {
        private var callback: OnBackPressedCallback? = null
    
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
    
            callback = object : OnBackPressedCallback(true) {
                override fun handleOnBackPressed() {
                    Navigation.findNavController(
                        requireActivity(),
                        R.id.activity_root__fragment__nav_host
                    ).popBackStack()
                }
            }.also {
                requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, it)
            }
        }
    }

    В нашем callback-е мы делаем ровно то же, что и в прошлый раз, когда пытались построить навигацию от вкладки нижней навигации в auth-граф: находим «правильный» NavController, который привёл нас на первый экран внешнего графа навигации, чтобы именно с его помощью вернуться назад.


    И это работает! Но есть одно «но»: чтобы это продолжало работать на протяжении всего auth-флоу, нам надо добавить точно такой же OnBackPressedCallback в каждый экран этого флоу =(


    И, конечно же, придётся поправить закрытие всего auth-флоу – там мы тоже должны добавить получение «правильного» NavController-а:


    Как это выглядит?
    class FinishAuthFragment : Fragment(R.layout.fragment_finish_auth) {
    
      override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
          super.onViewCreated(view, savedInstanceState)
    
          fragment_finish_auth__button.setOnClickListener {
              Navigation.findNavController(
                  requireActivity(),
                  R.id.activity_root__fragment__nav_host
              ).popBackStack(R.id.auth__nav_graph, true)
    
              findNavController().currentBackStackEntry
                ?.savedStateHandle
                ?.set(AUTH_FLOW_RESULT_KEY, true)
          }
      }
    
    }

    Подведём итоги


    • Если хотите осуществить навигацию вне текущего контейнера навигации, вы можете это сделать, получив «правильный» NavController.
    • Помните, что это вызовет проблемы с обратной навигацией.


    Допустим, на старте приложения мы показываем пользователю экран Splash-а. На нём мы выполняем действия, связанные с инициализацией приложения. Потом, если пользователь не авторизован, мы хотим перевести его во флоу авторизации, в противном случае – сразу покажем экран с нижней навигацией. При этом, когда пользователь завершит флоу авторизации (неважно, как именно), мы должны показать ему главный экран с нижней навигацией.


    Покажи на картинке


    У этого кейса есть пара особенностей, которые отличают его от первого рассмотренного случая.


    • Когда пользователь нажмёт на кнопку «Back» на первом экране флоу авторизации, мы хотим не «вернуться назад» (потому что зачем нам второй раз показывать Splash), а закрыть приложение.
    • После завершения флоу авторизации мы не просто закрываем открытый нами граф, но и двигаемся вперёд.

    С первым пунктом проблем быть не должно, мы можем просто пробросить флажок в флоу авторизации, который будет говорить в OnBackPressedCallback, когда надо закрывать приложение, а когда просто двигаться назад:


    Покажи код

    Определяем флажок для StartAuthFragment:


    <fragment
      android:id="@+id/StartAuthFragment"
      android:name="com.aaglobal.jnc_playground.ui.auth.StartAuthFragment"
      android:label="Start auth"
      tools:layout="@layout/fragment_start_auth">
    
      <argument
          android:name="isFromSplashScreen"
          android:defaultValue="false"
          app:argType="boolean"
          app:nullable="false" />
    
      <action
          android:id="@+id/action__StartAuthFragment__to__FinishAuthFragment"
          app:destination="@id/FinishAuthFragment" />
    
    </fragment>

    А теперь используем этот флажок в OnBackPressedCallback:


    class StartAuthFragment : Fragment(R.layout.fragment_start_auth) {
        private val args: StartAuthFragmentArgs by navArgs()
        private var callback: OnBackPressedCallback? = null
    
        private fun getOnBackPressedCallback(): OnBackPressedCallback {
          return object : OnBackPressedCallback(true) {
              override fun handleOnBackPressed() {
                  if (args.isFromSplashScreen) {
                      requireActivity().finish()
                  } else {
                      Navigation.findNavController(
                        requireActivity(),
                        R.id.activity_root__fragment__nav_host
                      ).popBackStack()
                  }
              }
          }
        }
    }

    Поскольку у нас Single Activity, requireActivity().finish() будет достаточно, чтобы закрыть наше приложение.


    Со вторым пунктом чуть интереснее. Я вижу два способа реализовать такую «пост-навигацию».


    • Первый способ: Navigation Component позволяет в runtime-е менять граф навигации, мы могли бы где-нибудь сохранить @id будущего destination-а и добавить немного логики при завершении авторизации.
    • Второй способ – закрывать флоу авторизации как и раньше, а логику движения вперёд дописать в экран, который стартовал экраны авторизации, то есть в Splash.

    Первый способ мне не нравится тем, что если появятся дополнительные destination-ы, которые надо открывать после экранов авторизации, появится и много лишней логики внутри флоу авторизации. Да и модифицировать граф навигации в runtime-е — то ещё удовольствие.


    Второй способ тоже не ахти – потребуется сохранить предыдущий экран в back stack-е, чтобы, вернувшись на него и прочитав результат после авторизации, мы могли двигаться дальше. Но это всё равно приемлемый вариант: вложенный флоу будет отвечать только за свою собственную логику, а экран, который начинает подобную «условную» навигацию (выбор между main и auth на Splash-е, например), и так знает, как двигаться вперёд.


    И реализовать это просто – мы знаем, как закрыть auth-флоу, знаем, как прокинуть из него результат на экран, который стартовал экраны авторизации. Останется только поймать результат на SplashFragment-е.


    Покажи код

    Пробрасываем результат из auth-флоу:


    // FinishAuthFragment.kt
    
    fragment_finish_auth__button.setOnClickListener {
        // Save hasAuthData flag in prefs
        GlobalDI.getAuthRepository().putHasAuthDataFlag(true)
    
        // Navigate back from auth flow
        Navigation.findNavController(
            requireActivity(),
            R.id.activity_root__fragment__nav_host
        ).popBackStack(R.id.auth__nav_graph, true)
    
        // Send signal about finishing flow
        findNavController().currentBackStackEntry
          ?.savedStateHandle
          ?.set(AUTH_FLOW_RESULT_KEY, true)
    }

    И ловим его на стороне SplashFragment-а:


    // SplashFragment.kt
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
    
        val authResult = findNavController().currentBackStackEntry
            ?.savedStateHandle
            ?.remove<Boolean>(FinishAuthFragment.AUTH_FLOW_RESULT_KEY) == true
    
        if (authResult) {
            navigateToMainScreen()
            return
        }
    }

    Выводы по кейсам вложенной навигации


    • У подобных кейсов куча подводных камней – тестировать нужно внимательно.
    • Вы должны быть морально готовы искать решения для ваших случаев, ведь я столкнулся далеко не со всеми возможными проблемами.

    Через неделю, в заключительной статье о Navigation Component, я расскажу, как можно организовать навигацию в многомодульных приложениях и как работается с дип линками, а также покажу кейсы со встраиваемыми фрагментами и диалогами.

    HeadHunter
    HR Digital

    Комментарии 5

      +1

      Спасибо за подробные статьи, особенно за наличие всех картинок.
      Есть вопрос по передаче результатов от вложенных графов.
      Не кажется ли, что в целом передача/получение результатов через бэкстек/навигацию является костылём? Не органичнее ли для этого использовать какие-то общие "бизнес" сущности/компоненты? Например, AuthFlow в результате будет менять наличие юзера в неком UserRepository, информацию из которого и будут получать экраны из других графов навигации.

        0
        Привет!

        Я согласен с тем, что для реального проекта передавать результаты зачастую проще через какие-то общие шины данных. А ещё это позволит описать бизнес-логику не во View-части =)

        В статье мне хотелось показать то, как нам предлагают работать с Navigation Component и его сущностями. Я бы точно не рекомендовал делать так, как показано в статье, потому что это больно и костыльно.
          0
          Раз вы ужж затащили mvi в hh, то почему не решились навигировать ваши экраны через фичу и cicerone(чисто что бы фрагмент переключить)?

          Разве управлять самим состоянием экрана не удобней, чем доверять его Navigation Component-у? ) Там и миграция на композ будет потом легкой .)
            0
            Привет!

            По поводу MVI и навигации — мы в hh так и делаем. У нас в основном проекте нет никакого Navigation Component, и навигация построена на Cicerone. То, что описано в статье — это большой тестовый пример, который был сделан в рамках ресёрча.

            Не очень понял про состояние экрана, Navigation Component же не связан с ним (разве что в очень широком смысле). Navigation Component осуществляет навигацию, переключение между экранами, у которых могут быть свои состояния.
              0
              Да имел ввиду в широком смысле (какой экран в текущий момент активный).

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое