Русский
Русский
English
Статистика
Реклама

Navigation component

Navigation Component-дзюцу, vol. 3 Corner-кейсы

23.09.2020 10:08:04 | Автор: admin


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


Это третья и заключительная статья в цикле про различные кейсы навигации с Navigation Component-ом. Вы также можете ознакомиться с первой и второй частями



Если вы работаете с большим приложением, вероятно, вы уже разбили его на модули. Неважно, как именно. Может быть, вы создаёте отдельные модули для логики и UI, а может храните всю логику фичи (от взаимодействия с API до логики presentation-слоя) в одном модуле. Главное у вас могут быть кейсы, когда требуется осуществить навигацию между двумя независимыми модулями.


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


На картинке мы видим, что у нас есть как минимум два модуля: модуль :vacancy с одним экраном и модуль :company с двумя экранами вложенного flow. В рамках моего примера я построил навигацию из модуля :vacancy в модуль :company, которые не связаны друг с другом.


Существует три способа как это сделать, разберём их один за другим.


App-модуль + интерфейсы


Первый способ использовать ваш application-модуль в качестве хранилища всего графа навигации и определить в feature-модулях специальные интерфейсы для роутинга.


Структура вашего приложения в этом способе


Структура приложения будет стандартной: есть app-модуль, который знает обо всех feature-модулях, есть feature-модули, которые не знают друг о друге. В этом способе ваши feature-модули пребывают в священном неведении о Navigation Component, и для навигации они будут определять интерфейсы примерно вот такого вида:


// ::vacancy moduleinterface VacancyRouterSource {    fun openNextVacancy(vacancyId: String)    // For navigation to another module    fun openCompanyFlow()}

А ваш app-модуль будет реализовывать эти интерфейсы, потому что он знает обо всех action-ах и навигации:


fun initVacancyDI(navController: NavController) {  VacancyDI.vacancyRouterSource = object : VacancyRouterSource {      override fun openNextVacancy(vacancyId: String) {          navController.navigate(              VacancyFragmentDirections                .actionVacancyFragmentToVacancyFragment(vacancyId = vacancyId)          )      }      override fun openCompanyFlow() {          initCompanyDI(navController)          navController.navigate(R.id.action__VacancyFragment__to__CompanyFlow)      }  }}

Плюс этого способа отсутствие дополнительных модулей, которые могут повлиять на скорость сборки приложения. Однако в минусах вы получаете:


  • дополнительную работу в виде определения интерфейсов, реализаций, организации DI для проброса этих интерфейсов в ваши feature-модули;
  • отсутствие возможности использовать использовать Safe Args плагин, делегат navArgs, сгенерированные Directions, и другие фишки Navigation Component-а в feature-модулях, потому что эти модули ничего не знают про библиотеку.

Сомнительный, в общем, способ.


Графы навигации в feature-модулях + диплинки


Второй способ вынести отдельные графы навигации в feature-модули и использовать поддержку навигации по диплинкам (она же навигация по URI, которую добавили в Navigation Component 2.1).


Структура вашего приложения в этом способе


Структура приложения будет ровно такой же, как и в первом способе: есть app-модуль, который зависит от всех feature-модулей, и есть feature-модули, которые друг о друге ничего не знают.


Но теперь ваш app-модуль не обязан содержать весь граф навигации приложения, он может содержать только его часть. А остальные кусочки будут содержать именно feature-модули.


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/company_flow__nav_graph"    app:startDestination="@id/CompanyFragment">    <fragment        android:id="@+id/CompanyFragment"        android:name="company.CompanyFragment">        <deepLink app:uri="companyflow://company" />        <!-- Or with arguments -->        <argument android:name="company_id" app:argType="long" />        <deepLink app:uri="companyflow://company" />        <action            android:id="@+id/action__CompanyFragment__to__CompanyDetailsFragment"            app:destination="@id/CompanyDetailsFragment" />    </fragment>    <fragment        android:id="@+id/CompanyDetailsFragment"        android:name="company.CompanyDetailsFragment" /></navigation>

Feature-модули будут определять свои собственные графы навигации для роутинга между экранами, о которых они знают. А ещё они будут объявлять диплинки для тех экранов, на которые можно попасть из других модулей. В примере выше мы добавили тэг deepLink, чтобы на экран CompanyFragment можно было попасть из другого модуля.


После этого мы можем использовать этот диплинк для открытия экрана CompanyFragment из модуля :vacancy :


// ::vacancy modulefragment_vacancy__button__open_company_flow.setOnClickListener {  // Navigation through deep link  val companyFlowUri = "companyflow://company".toUri()  findNavController().navigate(companyFlowUri)}

Плюс этого метода в том, что это самый простой способ навигации между двумя независимыми модулями. А минус что вы не сможете использовать Safe Args, или сложные типы аргументов (Enum, Serializable, Parcelable) при навигации между фичами.


P.S. Есть, конечно, вариант сериализовать ваши сложные структуры в JSON и передавать их в качестве String-аргументов в диплинк, но это как-то Странно.


Общий модуль со всем графом навигации


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


Структура вашего приложения в этом способе


У нас по-прежнему есть app-модуль, но теперь его задача просто подсоединить к себе все feature-модули; он больше не хранит в себе граф навигации. Весь граф навигации теперь располагается в специальном модуле, который ничего не знает о feature-модулях. Зато каждый feature-модуль знает про common navigation.


В чём соль? Несмотря на то, что common-модуль не знает о реализациях ваших destination-ов (фрагментах, диалогах, activity), он всё равно способен объявить граф навигации в XML-файлах! Да, Android Studio начинает сходить с ума: все имена классов в XML-е горят красным, но, несмотря на это, все нужные классы генерируются, Safe Args плагин работает как нужно. И так как ваши feature-модули подключают к себе common-модуль, они могут свободно использовать все сгенерированные классы и пользоваться любыми action-ами вашего графа навигации.


Плюс этого способа наконец-то можно пользоваться всеми возможностями Navigation Component-а в любом feature-модуле. Из минусов:


  • добавился ещё один модуль в critical path каждого feature-модуля, которому потребовалась навигация;
  • отсутствует автоматический рефакторинг имён: если вы поменяете имя класса какого-нибудь destination-а, вам нужно будет не забыть, что надо поправить его в common-модуле.

Выводы по навигации в многомодульных приложениях


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

Работа с диплинками


Практически каждое большое приложение должно уметь поддерживать диплинки. И практически каждый Android-разработчик мечтал о простом способе работы с этими глубокими ссылками. Окей, я мечтал. И казалось, что Navigation Component ровно то, что нужно.


По моей задумке кейсы с диплинками должны были занимать довольно большую часть переходов тестового приложения.


Какую именно часть?


У меня было три кейса с диплинками, которые я хотел реализовать с помощью Navigation Component.


  • Открытие определённой вкладки нижней навигации допустим, я хочу через диплинк открыть вторую вкладку на главном экране после Splash-экрана

Посмотреть на картинке

Допустим, я хочу через диплинк открыть вкладку Favorites нижней навигации на главном экране после Splash-экрана:



  • Открытие определённого экрана ViewPager-а внутри конкретной вкладки нижней навигации

Посмотреть на картинке

Пусть я хочу открыть определённую вкладку ViewPager-а внутри вкладки Responses:



  • Открытие экрана, который требует авторизации пользователя пусть теперь для корректной работы вкладки нижней навигации требуется авторизация. И с помощью диплинка я хочу сначала показать Splash-экран, затем открыть флоу авторизации, а когда пользователь его пройдёт, открыть нужную вкладку

Посмотреть на картинке

Пусть вкладка Profile теперь требует авторизации. И с помощью диплинка я хочу сначала показать Splash-экран, затем открыть флоу авторизации, а когда пользователь его пройдёт открыть вкладку Profile.



Сразу скажу, что у меня не получилось нормально реализовать ни один из этих кейсов. И работа с диплинками в Navigation Component стала для меня самым большим разочарованием.



Прежде чем делать диплинк для второй вкладки, я решил настроить ссылку хотя бы для первой. Я решил, что для этого будет достаточно просто добавить диплинк на экран контейнера нижней навигации (не самой вкладки, а контейнера):


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/app_nav_graph"    app:startDestination="@id/SplashFragment">    <fragment        android:id="@+id/SplashFragment"        android:name="ui.splash.SplashFragment" />    <fragment        android:id="@+id/MainFragment"        android:name="ui.main.MainFragment">        <deepLink app:uri="www.example.com/main" />    </fragment></navigation>

Затем я, следуя документации, добавил граф навигации с диплинком в Android Manifest:


<manifest xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    package="com.aaglobal.jnc_playground">    <application android:name=".App">        <activity android:name=".ui.root.RootActivity">            <nav-graph android:value="@navigation/app_nav_graph"/>            <intent-filter>                <action android:name="android.intent.action.MAIN" />                <category android:name="android.intent.category.LAUNCHER" />            </intent-filter>        </activity>    </application></manifest>

А потом решил проверить, работает ли то, что я настроил при помощи простой adb-команды:


adb shell am start \  -a android.intent.action.VIEW \  -d "https://www.example.com/main" com.aaglobal.jnc_playground

И-и-и нет. Ничего не завелось. Я получил краш приложения с уже знакомым исключением IllegalStateException: FragmentManager is already executing transactions. Дебаггер указывал на код, связанный с настройкой нижней навигации, поэтому я решил просто обернуть эту настройку в очередной Handler.post:


// MainFragment.kt  fragment with BottomNavigationViewoverride fun onViewCreated(view: View, savedInstanceState: Bundle?) {    super.onViewCreated(view, savedInstanceState)    if (savedInstanceState == null) {        safeSetupBottomNavigationBar()    }}private fun safeSetupBottomNavigationBar() {    Handler().post {        setupBottomNavigationBar()    }}

Это исправило краш, но приложение всё равно работало неправильно: запустив диплинк, мы пропустили Splash-экран, он просто не запускался. А это означает, что не отрабатывал код, который отвечал за инициализацию моего приложения.


Это произошло, потому что в нашем случае путь диплинка был таким: мы запустили приложение, запустилась его единственная Activity. В вёрстке этой activity мы инициализировали первый граф навигации. В этом графе оказался элемент, который удовлетворял URI, мы отправили его через adb-команду вуаля, он сразу и открылся, проигнорировав указанный в графе startDestination.


Тогда я решил перенести диплинк в другой граф внутрь вкладки нижней навигации.


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/menu__search"    app:startDestination="@id/SearchContainerFragment">    <fragment        android:id="@+id/SearchContainerFragment"        android:name="tabs.search.SearchContainerFragment">        <deepLink app:uri="www.example.com/main" />        <action            android:id="@+id/action__SearchContainerFragment__to__CompanyFlow"            app:destination="@id/company_flow__nav_graph" />        <action            android:id="@+id/action__SearchContainerFragment__to__VacancyFragment"            app:destination="@id/vacancy_nav_graph" />    </fragment></navigation>

И, запустив приложение, я получил ЭТО:


Посмотреть на ЭТО


На гифке видно, как приложение запустилось, и мы увидели Splash-экран. После этого на мгновение показался экран с нижней навигацией, а затем приложение словно запустилось заново! Мы снова увидели Splash-экран, и только после его повторного прохождения появилась нужная вкладка нижней навигации.


И что самое неприятное во всей этой истории это не баг, а фича.


Если почитать внимательно документацию про работу с диплинками в Navigation Component, можно найти следующий кусочек:


When a user opens your app via an explicit deep link, the task back stack is cleared and replaced with the deep link destination.

То есть наш back stack специально очищается, чтобы Navigation Component-у было удобнее работать с диплинками. Говорят, что когда-то давно, в бета-версии библиотеки всё работало адекватнее.


Мы можем это исправить. Корень проблемы в методе handleDeepLink NavController-а:


Кусочек handleDeepLink
public void handleDeepLink(@Nullable Intent intent) {    // ...    if ((flags & Intent.FLAG_ACTIVITY_NEW_TASK) != 0) {        // Start with a cleared task starting at our root when we're on our own task        if (!mBackStack.isEmpty()) {            popBackStackInternal(mGraph.getId(), true);        }        int index = 0;        while (index < deepLink.length) {            int destinationId = deepLink[index++];            NavDestination node = findDestination(destinationId);            if (node == null) {                final String dest = NavDestination.getDisplayName(mContext, destinationId);                throw new IllegalStateException("Deep Linking failed:"                        + " destination " + dest                        + " cannot be found from the current destination "                        + getCurrentDestination());            }            navigate(node, bundle,                    new NavOptions.Builder().setEnterAnim(0).setExitAnim(0).build(), null);        }        return true;    }}

Чтобы переопределить это поведение, нам потребуется:


  • почти полностью скопировать к себе исходный код Navigation Component;
  • добавить свой собственный NavController с исправленной логикой (добавление исходного кода библиотеки необходимо, так как от NavController-а зависят практически все элементы библиотеки) назовём его FixedNavController;
  • заменить все использования исходного NavController-а на FixedNavController.

Несложно, правда? Но кажется, что это уже слишком. Подобные исправления заставляют нас в будущем тратить всё больше и больше времени на поддержку нашего кастомного решения, ведь нужно будет обновлять свой фикс при каждом обновлении библиотеки. Около года назад коллеги из Яндекса столкнулись с похожей проблемой, и им пришлось делать примерно то же самое. Сочувствую.


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


Покажи гифку


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


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



Если у вас будет свой собственный NavController, корректно обрабатывающий диплинки, реализовать этот кейс будет просто.


В NavController-е есть специальный булевский флажок isDeepLinkHandled, который говорит нам, что текущий NavController успешно обработал диплинк. Вы могли бы добавить диплинк, ведущий на фрагмент, который содержит в себе ViewPager, затем написать примерно вот такой код, чтобы перейти на нужную вкладку:


if (findMyNavController().isDeepLinkHandled && requireActivity().intent.data != null) {    val uriString = requireActivity().intent.data?.toString()    val selectedPosition = when {        uriString == null -> 0        uriString.endsWith("favorites") -> 0        uriString.endsWith("subscribes") -> 1        else -> 2    }    fragment_favorites_container__view_pager.setCurrentItem(selectedPosition, true)}

Но, опять же, это будет доступно только в случае, если вы уже добавили к себе в кодовую базу свою реализацию NavController-а, ведь флаг isDeepLinkHandled является private-полем. Ок, можно достучаться до него через механизм reflection-а, но это уже другая история.



Navigation Component не поддерживает диплинки с условием из коробки. Если вы хотите поддержать такое поведение, Google предлагает действовать следующим образом:


  • через диплинк открыть экран, который требует авторизацию;
  • на этом экране проверить, авторизован ли пользователь, если нет открыть флоу авторизации поверх нужного экрана;
  • пройти auth flow, вернуть результат из вложенного графа и т.д., и т.п.

Возможности глобально решить мою задачу средствами Navigation Component-а я не нашёл.


Выводы по работе с диплинками в Navigation Component


  • Работать с ними больно, если требуется добавлять дополнительные действия или условия.
  • Объявлять диплинки ближе к месту их назначения классная идея, в разы удобнее AndroidManifest-а со списком поддерживаемых ссылок.

Бонус-секция кейсы БЕЗ проблем


Чтобы разбавить негатив от предыдущей секции, я покажу пару кейсов, с которыми вообще не было проблем. А я их ждал. Но их не было.



Допустим, у вас есть экран вакансий, с которого вы можете перейти на другую вакансию.


Где на схеме приложения этот кейс?


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


<fragment  android:id="@+id/VacancyFragment"  android:name="com.aaglobal.jnc_playground.ui.vacancy.VacancyFragment"  android:label="Fragment vacancy"  tools:layout="@layout/fragment_vacancy">  <argument      android:name="vacancyId"      app:argType="string"      app:nullable="false" />  <action      android:id="@+id/action__VacancyFragment__to__VacancyFragment"      app:destination="@id/VacancyFragment" /></fragment>

И этого оказалось достаточно новый экран открывался поверх старого, при нажатии на кнопку Back навигация была корректной. Если бы я захотел, чтобы каждый новый экран открывался вместо текущего, было бы достаточно добавить атрибут popUpTo к моему action-у.



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


Где на схеме приложения этот кейс?


Я добавил контейнер для будущего фрагмента со списком в вёрстку вкладки нижней навигации:


<LinearLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="match_parent"    android:orientation="vertical">    <TextView        android:id="@+id/fragment_favorites_container__text__title"        style="@style/LargeTitle"        android:text="Favorites container" />    <androidx.fragment.app.FragmentContainerView        android:id="@+id/fragment_favorites_container__container__recommend_vacancies"        android:layout_width="match_parent"        android:layout_height="match_parent" /></LinearLayout>

А затем в runtime-е добавил нужный мне фрагмент в этот контейнер:


class FavoritesContainerFragment : Fragment(R.layout.fragment_favorites_container) {    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        super.onViewCreated(view, savedInstanceState)        childFragmentManager.attachFragmentInto(          containerId = R.id.fragment_container_view,          fragment = createVacancyListFragment()        )    }}

Метод attachFragmentInfo на childFragmentManager это extension-метод, который просто оборачивает всю работу с транзакциями, не более того.


А вот как я создал фрагмент:


class FavoritesContainerFragment : Fragment(R.layout.fragment_favorites_container) {    // ...    private fun createVacancyListFragment(): Fragment {        return VacancyListFragment.newInstance(          vacancyType = "favorites_container",          vacancyListRouterSource = object : VacancyListRouterSource {              override fun navigateToVacancyScreen(item: VacancyItem) {                  findNavController().navigate(                      R.id.action__FavoritesContainerFragment__to__VacancyFragment,                      VacancyFragmentArgs(vacancyId = "${item.name}|${item.id}").toBundle()                  )              }        }     }}

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



Пусть у меня есть несколько BottomSheetDialog-ов, между которыми я хочу перемещаться с помощью Navigation Component.


Где на схеме приложения этот кейс?


Год назад с таким кейсом были какие-то проблемы, но сейчас всё работает как надо. Можно легко объявить какой-то dialog в качестве destination-а в вашем графе навигации, можно добавить action для открытия диалога из другого диалога.


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/menu__favorites"    app:startDestination="@id/FavoritesContainerFragment">   <dialog        android:id="@+id/ABottomSheet"        android:name="ui.dialogs.dialog_a.ABottomSheetDialog">        <action            android:id="@+id/action__ABottomSheet__to__BBottomSheet"            app:destination="@id/BBottomSheet"            app:popUpTo="@id/ABottomSheet"            app:popUpToInclusive="true" />    </dialog>    <dialog        android:id="@+id/BBottomSheet"        android:name="ui.dialogs.dialog_b.BBottomSheetDialog">        <action            android:id="@+id/action__BBottomSheet__to__ABottomSheet"            app:destination="@id/ABottomSheet"            app:popUpTo="@id/BBottomSheet"            app:popUpToInclusive="true" />    </dialog></navigation>

Диалоги создавались как нужно, закрывались вовремя, навигация по кнопке Back отрабатывала как ожидалось.


Выводы по бонус-секции


Кейсы без проблем существуют.


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


На данный момент нет никакой причины переводить большое приложение на Navigation Component. Слишком много проблем, слишком много костылей, постоянно нужно выдумывать что-то для осуществления не самых сложных кейсов навигации. Сам факт, что я ухитрился написать так много текста про проблемы с Navigation Component-ом, что-то да говорит.


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


Пример приложения на Github-е лежит здесь.


Полезные ссылки по теме


Подробнее..

Safe Argsверный помощник Navigation Component

28.01.2021 14:21:54 | Автор: admin

В этой статье вы узнаете, кто такой этот Safe Args, как он упрощает жизнь и что является продуктом его работы, в том числе и за кулисами.

Вы сейчас во второй части большого материала про Navigation Component в многомодульном проекте. Если вы впервые слышите про Navigation Component, то рекомендую сначала почитать, что вообще такое Navigation Component. Если уже знакомы с азами, то можно переходить к самому интересному:

Safe Argsэто плагин, идущий отдельно от Navigation Component, но созданный специально для того, чтобы с библиотекой работалось легче. С ним нет необходимости указывать id destination-а и передавать параметры через Bundleплагин генерирует для этого отдельные классы и имеет набор extension-ов для работы с ними. Давайте разбираться, как это всё работает.

Во-первых, вместе с плагином появился новый тег в xml: <argument>. Он применим и к action, и к destinationтак можно передавать и принимать параметры в более удобном виде. Во-вторых, на основе экранов и переходов, указанных в графе, генерируются специальные классы, которые можно указывать в NavController-е вместо id action.

Show me thecode!

<navigation        xmlns:android=http://schemas.android.com/apk/res/android"        xmlns:app=http://schemas.android.com/apk/res-auto"        xmlns:tools=http://schemas.android.com/tools"        android:id=@+id/graphuserflow        app:startDestination=@id/fragmentUserList>        <fragment                android:id=@+id/fragmentUserList                android:label=FragmentUserList                android:name=com.example.usersList.UserListFragment                tools:layout=@layout/fragmentuserlist>                    <action                        android:id=@+id/actiontouserdetails                        pp:destination=@id/fragmentUserList >                        <argument                                android:name=userId                                app:argType=integer                                app:nullable=false />                 </action>        </fragment>        <fragment                android:id=@+id/fragmentUserDetails                android:label=FragmentUserDetails                android:name=com.example.usersList.UserDetails                tools:layout=@layout/fragmentuser_details/></navigation>

Здесь мы взяли: достаточно простой граф, где есть фрагмент, вложенный граф и переход от одного к другому. Единственная особенностьпресловутый <argument>, с помощью которого мы передаем в users details фрагмент параметр userId. Пересоберём проект и посмотрим, что получилось.

class UserListFragmentDirections private constructor() {    private data class ActionUserFromListToDetails(    val userId: Int    ) : NavDirections {        override fun getActionId(): Int = R.id.actionToUserDetails                override fun getArguments(): Bundle {            val result = Bundle()            result.putInt(userId, this.userId)            return result        }    }    companion object {        fun actionToUserDetails(userId: Int): NavDirections =        ActionToUserDetails(userId)    }}

В generated-папке модуля, где лежит граф, находим вот такой классобертку, которая включает в себя все переходы, указанные в графе с уже включенными туда параметрами. В нашем случае это переход на экран деталей пользователя и передача туда аргумента userId.

Теперь вызов перехода будет выглядеть так:

navController.navigate(    UserListFragmentDirections.actionToUserDetails(userId))

А параметры в целевом destination-е можно получить через extension, который теперь у нас имеется.

private val args by navArgs<UserDetailsFragmentArgs>()private val userId by lazy { args.userId }

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

В итоге

Safe Argsприятное дополнение к Navigation Component, благодаря которому мы облегчили себе работу с id переходов и обработки получения/отправки их аргументов. Использовать его или нетдело ваше, но дальнейшее повествование основано на использовании это плагина. Спойлер: он принесет немало проблем, но в конце все будут счастливы:)

А теперь к самому интересному. Взглянем, как можно организовать работу с Navigation Component в многомодульном проекте совместно с SafeArgs и iOS-like multistack-навигацию.

Подробнее..

Navigation Component и multi backstack navigation

28.01.2021 14:21:54 | Автор: admin

Вы сейчас в четвертой части большого материала про Navigation Component в многомодульном проекте. Если вы уже знаете:

То добро пожаловать в заключительную часть истории о моем опыте с этой прекрасной библиотекойпро решение для iOS-like multistack-навигации.

Если не знаете, то выйдите и зайдите нормально прочитайте сначала три статьи выше.

В дополнение к библиотеке Navigation Component Google выпустили несколько интерфейсных дополнений под названием NavigationUI, которые помогут вам подключить навигацию к BottomBar, Menu и прочим стандартным компонентам. Но часто поступают требования, чтобы на каждой вкладке был свой стек и текущие состояния сохранялись при переходе между ними. К сожалению, из коробки Navigation Component и NavigationUI так не умеют.

Поддержку такого подхода представили сами Google в своем architecture-components-samples репозитории на GitHub (https://github.com/android/architecture-components-samples/tree/master/NavigationAdvancedSample). Суть его проста:

  1. Добавляем FragmentContainer.

  2. Создаем NavHostFragment и граф под каждую вкладку.

  3. При выборе вкладки присоединяем необходимый NavHostFragment и отсоединяем текущий с помощью транзакций FragmentManager-a.

Но в ходе работы с этим решением я переделал некоторые моменты, связанные со спецификой проекта:

  • Многие приложения имеют sign in / up flow, on boarding и прочие экраны, которые не должны входить в стеки, но даже в таком случае все достаточно просто оборачивается стандартными средствами. Навигацию между этими частями можно выстроить уже как обычно, например, как в предыдущей части.

  • В примере все стеки инициализируются сразу при старте приложения. Связано это с корректной работой NavigationBottomBar и обработкой Deep Link-ов. Но я часто сталкивался с проектами, где deep link-и не нужны и бар навигации требует кастомизации. Проект, на котором я обкатывал подходне исключение. Глядя на оригинальный файл NavigationExtensions в 250 loc, я решил выбросить все ненужное и сделать lazy-инициализацию NavHost-ов, оставив только основные функции:

Функция поиска / инициализации требуемого NavHost-фрагмента:

fun obtainNavHostFragment(        fragmentManager: FragmentManager,        fragmentTag: String,        navGraphId: Int,        containerId: Int): NavHostFragment {        // If the Nav Host fragment exists, return it        val existingFragment =        fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?        existingFragment?.let { return it }        // Otherwise, create it and return it.        val navHostFragment = NavHostFragment.create(navGraphId)        fragmentManager.beginTransaction()                .add(containerId, navHostFragment, fragmentTag)                .commitNow()        return navHostFragment}

Функция смены NavHost-ов:

protected fun selectTab(tab: Tab) {        val newFragment = obtainNavHostFragment(                childFragmentManager,                getFragmentTag(tabs.indexOf(tab)),                tab.graphId,                containerId         )         val fTrans = childFragmentManager.beginTransaction()         with(fTrans) {                  if (selectedFragment != null) detach(selectedFragment!!)                  attach(newFragment)                 commitNow()         }         selectedFragment = newFragment         currentNavController = selectedFragment!!.navController         tabSelected(tab)}

Функция своей обработки нажатия на кнопку Back:

activity?.onBackPressedDispatcher?.addCallback(         viewLifecycleOwner,         object: OnBackPressedCallback(true){                  override fun handleOnBackPressed() {                           val isNavigatedUp = currentNavController.navigateUp()                           if(isNavigatedUp){                                    return                           }else{                                    activity?.finish()                           }                  }         })

В итоге

Таким образом мы получаем iOS-like навигацию и даже лучше, так как имеем lazy-нагрузку на стеках и меньшее количество кода. Приятный бонусмы имеем полностью очевидную и прозрачную схему навигации, которую просто масштабировать и модифицировать.

Подробнее..

Navigation Component-дзюцу, vol. 1 BottomNavigationView

09.09.2020 12:15:30 | Автор: admin


Два года назад на Google I/O Android-разработчикам представили новое решение для навигации в приложениях библиотеку Jetpack Navigation Component. Про маленькие приложения уже было сказано достаточно, а вот о том, с какими проблемами можно столкнуться при переводе большого приложения на Navigation Component, информации практически нет.


В этой и следующих двух статьях я расскажу о кейсах, с которыми может встретиться разработчик, желающий опробовать Navigation Component в большом Android-приложении.


Это текстовая версия моего выступления в рамках серии митапов по Android 11 в Android Academy. Само выступление было на английском, статью пишу на русском. Кому удобнее смотреть велкам.


В первой статье я расскажу о кейсах, связанных с BottomNavigationView, во второй о кейсах с вложенными графами навигации, в третьей про навигацию в многомодульных приложениях, дип линки, встраиваемые фрагменты и диалоги. Все три статьи лонгриды, которые, однако, способны сэкономить много времени и вам, и вашей команде.


Disclaimer


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


Схема моего тестового приложения выглядит так:



В цикле статей мы разберём каждый переход, который описан на этой схеме, а также несколько кейсов, которые не поместились на картинку.


Кейсы с BottomNavigationView


Когда я только-только услышал про Navigation Component, мне стало интересно: как будет работать BottomNavigationView и как Google подружит несколько отдельных back stack-ов в разных вкладках. Два года назад с этим кейсом были некоторые проблемы, и я решил проверить, как там обстоят дела сегодня.


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


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


Первый опыт


Я установил Android Studio 4.1 Beta (последнюю более-менее стабильную версию на тот момент) и попробовал шаблон приложения с нижней навигацией. Начало было многообещающим.


  • Мне сгенерировали Activity в качестве контейнера для хоста навигации и нижней навигации

Вёрстка Activity из шаблона
<androidx.constraintlayout.widget.ConstraintLayout    android:id="@+id/container">    <com.google.android.material.bottomnavigation.BottomNavigationView        android:id="@+id/nav_view"        app:menu="@menu/bottom_nav_menu" />    <fragment        android:id="@+id/nav_host_fragment"        android:name="androidx.navigation.fragment.NavHostFragment"        app:defaultNavHost="true"        app:navGraph="@navigation/mobile_navigation" /></androidx.constraintlayout.widget.ConstraintLayout>

Я убрал шумовые атрибуты, чтобы было проще читать.


Стандартный ConstraintLayout, в который добавили BottomNavigationView и тэг <fragment> для инициализации NavHostFragment-а (Android Studio, кстати, подсвечивает, что вместо фрагмента лучше использовать FragmentContainerView).


  • Для каждой вкладки BottomNavigationView был создан отдельный фрагмент

Граф навигации из шаблона
<navigation    android:id="@+id/mobile_navigation"    app:startDestination="@+id/navigation_home">    <fragment        android:id="@+id/navigation_home"        android:name="com.aaglobal.graph_example.ui.home.HomeFragment"/>    <fragment        android:id="@+id/navigation_dashboard"        android:name="com.aaglobal.graph_example.ui.dashboard.DashboardFragment"/>    <fragment        android:id="@+id/navigation_notifications"        android:name="com.aaglobal.graph_example.ui.notifications.NotificationsFragment"/></navigation>

Все фрагменты были добавлены в качестве отдельных destination-ов в общий граф навигации.


  • А ещё в проект был добавлен файл-ресурс для описания меню BottomNavigationView

@menu-ресурс для описания табов
<menu xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android">    <item        android:id="@+id/navigation_home"        android:icon="@drawable/ic_home_black_24dp"        android:title="@string/title_home" />    <item        android:id="@+id/navigation_dashboard"        android:icon="@drawable/ic_dashboard_black_24dp"        android:title="@string/title_dashboard" />    <item        android:id="@+id/navigation_notifications"        android:icon="@drawable/ic_notifications_black_24dp"        android:title="@string/title_notifications" /></menu>

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


Пора запускать приложение


После создания приложения из шаблона я запустил его и сразу столкнулся с двумя проблемами.


Первая проблема: при переключении между вкладками их состояние не сохранялось.


А ну-ка покажи


Для проверки я добавил во вкладку Dashboard простенькую ViewModel со счётчиком. На гифке видно, как я переключаюсь со вкладки Home на вкладку Dashboard, увеличиваю счётчик до четырёх. После этого я переключился обратно на вкладку Home и вновь вернулся на Dashboard. Счётчик сбросился.


Баг с описанием этой проблемы уже два года висит в Issue Tracker-е. Чтобы решить её, Google-у потребовалось серьёзно переработать внутренности фреймворка Fragment-ов, чтобы поддержать возможность работать с несколькими back stack-ами одному FragmentManager-у. Недавно на Medium вышла статья Ian Lake, в которой он рассказывает, что Google серьёзно продвинулись в этом вопросе, так что, возможно, фикс проблемы с BottomNavigationView не за горами.


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


А ну-ка покажи


Для демонстрации этой проблемы я добавил на вкладку Dashboard кнопку, которая ведёт на следующий экран. На гифке видно, как я переключаюсь на вкладку Dashboard, увеличиваю счётчик до трёх, а затем перехожу на экран Graphic. Если я нажимаю на кнопку Back то всё работает как надо, состояние вкладки не сбрасывается. Но если, находясь на экране Graphic, я ещё раз нажму на вкладку Dashboard, то после возврата на первый экран в стеке увижу, что его состояние сброшено.


Не самое лучшее первое впечатление, подумал я. И начал искать фикс.


У нас есть workaround


Решение этих проблем живёт в специальном репозитории Google-а с примерами работы с Architecture Components, в проекте NavigationAdvancedSample.


Большая часть фикса расположена в файле NavigationExtensions.kt. В самом проекте довольно много кода, поэтому я не буду его разбирать подробно, а вместо этого подсвечу основные моменты, которые относятся к решению проблем.


  • Во-первых, для каждой вкладки вводится отдельный, независимый граф навигации

Граф навигации для одной из вкладок
<?xml version="1.0" encoding="utf-8"?><navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:id="@+id/navigation_home"    app:startDestination="@id/HomeFragment">    <fragment        android:id="@+id/HomeFragment"        android:name="com.aaglobal.jnc_playground.ui.home.HomeFragment"        android:label="@string/title_home"        tools:layout="@layout/fragment_home" /></navigation>

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


  • Во-вторых, для каждой вкладки под капотом создаётся отдельный NavHostFragment, который будет связан с графом навигации этой вкладки

Создание NavHostFragment-а для графа вкладки BottomNavigationView
private fun obtainNavHostFragment(    fragmentManager: FragmentManager,    fragmentTag: String,    navGraphId: Int,    containerId: Int): NavHostFragment {    // If the Nav Host fragment exists, return itval existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?    existingFragment?.let { return it }    // Otherwise, create it and return it.    val navHostFragment = NavHostFragment.create(navGraphId)    fragmentManager.beginTransaction()        .add(containerId, navHostFragment, fragmentTag)        .commitNow()    return navHostFragment}

FragmentManager пока что не поддерживает работу с множеством back stack-ов одновременно, поэтому пришлось придумать альтернативное решение, которое позволило ассоциировать с каждым графом свой back stack. Им стало создание отдельного NavHostFragment-а для каждого графа. Из этого следует, что с каждой вкладкой BottomNavigationView у нас будет связан отдельный NavController.


  • В-третьих, мы устанавливаем в BottomNavigationView специальный listener, который будет заниматься переключением между back stack-ами фрагментов

Listener для переключения между вкладками BottomNavigationView
setOnNavigationItemSelectedListener { item ->  val newlySelectedItemTag = graphIdToTagMap[item.itemId]  if (selectedItemTag != newlySelectedItemTag) {    fragmentManager.popBackStack(firstFragmentTag, FragmentManager.POP_BACK_STACK_INCLUSIVE)    val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)        as NavHostFragment    if (firstFragmentTag != newlySelectedItemTag) {      fragmentManager.beginTransaction()        .attach(selectedFragment)        .setPrimaryNavigationFragment(selectedFragment).apply {          graphIdToTagMap.forEach { _, fragmentTagIter ->            if (fragmentTagIter != newlySelectedItemTag) {              detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!)            }          }        }        .addToBackStack(firstFragmentTag)        .setReorderingAllowed(true)        .commit()    }    selectedNavController.value = selectedFragment.navController    true  } else {    false  }}

В прикреплённом кусочке кода мы видим, как при переключении между вкладками BottomNavigationView выполняется специальная транзакция в FragmentManager-е, которая прикрепляет фрагмент выбранной вкладки и отцепляет все остальные фрагменты. По сути, так мы и переключаемся между различными back stack-ами.


  • В итоге метод настройки BottomNavigationView возвращает разработчику специальную LiveData, которая содержит в себе NavController выбранной вкладки. Этот NavController можно использовать, например, для обновления надписи на ActionBar

Настраиваем BottomNavigationView в Activity
class RootActivity : AppCompatActivity(R.layout.activity_root) {  private var currentNavController: LiveData<NavController>? = null  private fun setupBottomNavigationBar() {      // Setup the bottom navigation view with a list of navigation graphs      val liveData = bottom_nav.setupWithNavController(          navGraphIds = listOf(            R.navigation.home_nav_graph,            R.navigation.dashboard_nav_graph,            R.navigation.notifications_nav_graph          ),          fragmentManager = supportFragmentManager,          containerId = R.id.nav_host_container,          intent = intent      )      // Whenever the selected controller changes, setup the action bar.      liveData.observe(this, Observer { ctrl -> setupActionBarWithNavController(ctrl) })      currentNavController = liveData  }}

Метод для настройки BottomNavigationView вызывают в onCreate-е, когда Activity создаётся в первый раз, затем в методе onRestoreInstanceState, когда Activity пересоздаётся с помощью сохранённого состояния.


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


Посмотреть, как это выглядит в коде


Опять же, не самая очевидная связь между этими элементами, зато работает.


После применения этого workaround-а первые две проблемы исчезли теперь состояние вкладки сохраняется между переключениями вкладок.


А ну-ка покажи

Первая проблема решилась:



И вторая тоже:



Адаптация workaround-а для фрагментов


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


Почему тебе нужен фрагмент?

Посмотрите внимательно на эту схему:



На ней можно увидеть, что пользователь начинает свой путь в приложении со Splash-экрана:



Google говорит, что Splash-экраны зло, ухудшающее UX приложения. Тем не менее, Splash-экраны суровая реальность большинства крупных Android-приложений. И если мы хотим использовать в нашем приложении Single Activity-архитектуру, то в качестве контейнера нижней навигации придётся использовать Fragment, а не Activity:



Я добавил вёрстку для фрагмента с нижней навигацией и перенёс настройку BottomNavigationView во фрагмент:


Посмотреть код
class MainFragment : Fragment(R.layout.fragment_main) {    private var currentNavController: LiveData<NavController>? = null    override fun onViewStateRestored(savedInstanceState: Bundle?) {        super.onViewStateRestored(savedInstanceState)        setupBottomNavigationBar()    }    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        super.onViewCreated(view, savedInstanceState)        if (savedInstanceState == null) {            setupBottomNavigationBar()        }    }}

Я добавил в свой пример Splash-экран и дополнительную вкладку для BottomNavigationView. А чтобы пример стал ещё более походить на приложение для соискателей hh.ru, я также убрал из него ActionBar.


Для этого я поменял тему приложения с Theme.MaterialComponents.DayNight.DarkActionBar на Theme.MaterialComponents.DayNight.NoActionBar и убрал код для связки NavController-а с ActionBar-ом:


Код настройки BottomNavigationView выглядел так
class MainFragment : Fragment(R.layout.fragment_main) {    private var currentNavController: LiveData<NavController>? = null    private fun setupBottomNavigationBar() {        val navGraphIds = listOf(            R.navigation.search__nav_graph,            R.navigation.favorites__nav_graph,            R.navigation.responses__nav_graph,            R.navigation.profile__nav_graph        )        val controller = bottom_navigation.setupWithNavController(            navGraphIds = navGraphIds,            fragmentManager = requireActivity().supportFragmentManager,            containerId = R.id.fragment_main__nav_host_container,            intent = requireActivity().intent        )        currentNavController = controller    }}

После всех манипуляций я включил режим Don't keep activities, запустил свой пример и получил краш при сворачивании приложения.


А ну-ка покажи


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


В чём была причина? При вызове onDestroyView активный NavHostFragment пытается отвязаться от NavController-а. Так как мой фрагмент-контейнер с нижней навигацией никак не привязывал к себе NavController, который он получил из LiveData, метод Navigation.findNavController из onDestroyView крашил приложение.


Добавляем привязку NavController-а к фрагменту с нижней навигацией (для этого в Navigation Component-е есть утилитный метод Navigation.setViewNavController), и проблема исчезает.


Кусочек кода с фиксом
class MainFragment : Fragment(R.layout.fragment_main) {    private var currentNavController: LiveData<NavController>? = null    private fun setupBottomNavigationBar() {        ...        currentNavController?.observe(            viewLifecycleOwner,            Observer { liveDataController ->                Navigation.setViewNavController(requireView(), liveDataController)            }        )    }}

Но это ещё не всё. Не выключая режим Don't keep activities, я попробовал свернуть, а затем развернуть приложение. Оно снова упало, но с другим неприятным исключением IllegalStateException в FragmentManager FragmentManager already executing transactions.


А ну-ка покажи


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


Краш происходит в методах, которые прикрепляют NavHostFragment к FragmentManager-у после их создания. Это исключение можно исправить при помощи костыля: обернуть методы attach-detach в Handler.post {}.


Фиксим IllegalStateException
// NavigationExtensions.ktprivate fun attachNavHostFragment(    fragmentManager: FragmentManager,    navHostFragment: NavHostFragment,    isPrimaryNavFragment: Boolean) {  Handler().post {    fragmentManager.beginTransaction()    .attach(navHostFragment)    .apply {      if (isPrimaryNavFragment) {        setPrimaryNavigationFragment(navHostFragment)      }    }    .commitNow()  }}

После добавления Handler.post приложение заработало, как надо.


Выводы по работе с BottomNavigationView


  • Использовать BottomNavigationView в связке с Navigation Component можно, если знать, где искать workaround-ы.
  • Если вы захотите иметь фрагмент в качестве контейнера нижней навигации BottomNavigationView, будьте готовы искать дополнительные фиксы для ваших проблем, так как скорее всего я поймал не все возможные краши.

На этом с BottomNavigationView всё, на следующей неделе расскажу про кейсы с вложенными графами навигации.

Подробнее..

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

16.09.2020 12:14:05 | Автор: admin


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


Это вторая из трёх статей про реализацию кейсов навигации при помощи 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. К этому хранилищу можно получить доступ через свойства NavControllerpreviousBackStackEntry и currentBackStackEntry. Но в своих примерах Google почему-то считает, что ваш вложенный флоу всегда состоит только из одного экрана.


Типичный пример работы с SavedStateHandle
// Flow screenfindNavController().previousBackStackEntry    ?.savedStateHandle    ?.set("some_key", "value")// Screen that waits resultval 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 flowval 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://personeltest.ru/away/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.ktfragment_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.ktoverride 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, я расскажу, как можно организовать навигацию в многомодульных приложениях и как работается с дип линками, а также покажу кейсы со встраиваемыми фрагментами и диалогами.

Подробнее..

Navigation Component-дзюцу, vol. 4 Переоценка

30.12.2020 10:17:29 | Автор: admin

Спустя два месяца после написания цикла статей Navigation Component-дзюцу я задумался: неужели всё действительно так плохо? Может быть я поддался волне критики гугловых разработок и просто пропустил тревожный звоночек, принявшись исправлять баг за багом, проблему за проблемой с помощью костылей и палок?

Оказалось, во многом так оно и есть: в этой статье-дополнении я хочу рассказать, в чём была проблема, как её исправить и как это поменяло моё мнение о Navigation Component.

Кейс с BottomNavigationView

Первая статья начиналась с примера использования BottomNavigationView в приложении с Navigation Component: я описывал тернистый путь от использования стандартного шаблона Android Studio с нижней навигацией до применения специальной extension-функции из репозитория Navigation Advanced Sample.

Напомни схему тестового приложения

Стандартный шаблон Android Studio с нижней навигацией, который использует Navigation Component, реализует нижнюю навигацию в полном соответствии с гайдлайнами Material Design то есть при переключении между вкладками стек экранов сбрасывается. Чтобы реализовать сохранение состояния вкладок можно воспользоваться специальной extension-функцией, которая под капотом создаёт для каждой вкладки нижней навигации отдельный NavHostFragment. К нему и будет привязан отдельный граф навигации со своим back stack-ом.

Оказалось, что при адаптации этой extension-функции для фрагментов я допустил серьёзную ошибку: использовал не тот FragmentManager. Так как мы строим навигацию внутри фрагмента, а не Activity, мне следовало использовать childFragmentManager, привязанный к фрагменту-контейнеру нижней навигации, а не supportFragmentManager, который был привязан к Activity.

Правильный вариант выглядит так:

Код настройки BottomNavigationView внутри фрагмента
/** * Main fragment -- container for bottom navigation */class MainFragment : Fragment(R.layout.fragment_main) {    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        super.onViewCreated(view, savedInstanceState)        if (savedInstanceState == null) {            setupBottomNavigationBar()        }    }    override fun onViewStateRestored(savedInstanceState: Bundle?) {        super.onViewStateRestored(savedInstanceState)        // Now that BottomNavigationBar has restored its instance state        // and its selectedItemId, we can proceed with setting up the        // BottomNavigationBar with Navigation        setupBottomNavigationBar()    }    /**     * Called on first creation and when restoring state.     */    private fun setupBottomNavigationBar() {        val navGraphIds = listOf(            R.navigation.search__nav_graph,            R.navigation.favorites__nav_graph,            R.navigation.responses__nav_graph,            R.navigation.profile__nav_graph        )        // Setup the bottom navigation view with a list of navigation graphs        fragment_main__bottom_navigation.setupWithNavController(            navGraphIds = navGraphIds,            fragmentManager = childFragmentManager, // Самая важная строка            containerId = R.id.fragment_main__nav_host_container,            intent = requireActivity().intent        )    }}

Эта ошибка повлекла за собой описанные мной проблемы: краши в неожиданных местах, костыли для обратной навигации, странную привязку NavController-а.

Как так не заметили, что используете parentFragmentManager?

У меня есть несколько версий:

  • часто меняя код для описания большого примера, я мог не заметить разницы между поведением приложения при использовании supportFragmentManager-а и childFragmentManager-а;

  • мог подвести эмулятор;

  • а может быть, имела место банальная невнимательность при переносе кода с Advanced navigation sample с Activity на фрагменты; в исходном коде примера с Activity по понятным причинам использовался supportFragmentManager.

Как видите, нам больше не нужны никакие Handler.post для фиксов крашей IllegalStateException: FragmentManager already execute transaction. Кроме того, исчезает необходимость привязывать NavController, полученный из extension-а setupWithNavController, ко View нашего фрагмента. Плюс ко всему, у нас нет никаких крашей при сворачивании и разворачивании приложения ура.

С учётом этого фикса кейс с BottomNavigationView делается по щелчку. Из коробки вам может не хватить только одного: иногда приложению требуется запоминать порядок выбора табов нижней навигации и при нажатии на кнопку Back возвращаться не на первый таб, а на предыдущий выбранный.

Навигация из вложенного графа во внешний граф

Благодаря использованию childFragmentManager-а мы не только исправили много проблем, но и упростили несколько других кейсов. В частности, всё стало гораздо проще с кейсом открытия вложенного флоу без нижней навигации.

Напомни схему

Речь идёт об этой части схемы:

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

Правильно инициализировав BottomNavigationView, мы избавились от необходимости руками привязывать NavController ко View контейнера с нижней навигацией (в коде это MainFragment). Это было неочевидно, но привело к проблемам, связанным с обратной навигацией во флоу авторизации, когда мы были вынуждены искать там правильный NavController:

В коде StartAuthFragment было вот так
callback = object : OnBackPressedCallback(true) {    override fun handleOnBackPressed() {            Navigation.findNavController(                requireActivity(),                R.id.activity_root__fragment__nav_host            ).popBackStack()    }}.also {            requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, it)}

Теперь мы можем спокойно избавиться от этих переопределений OnBackPressedCallback-ов. Всё стало гораздо проще.

Навигация по условию

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

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

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

Первый способ заключался в пробрасывании флажка об открытии флоу авторизации со Splash-экрана и дальнейшей обработке этого флага в OnBackPressedCallback-е. Второй способ сводится к модификации текущего графа навигации: мы можем в runtime-е поменять startDestination графа на нужный нам первый фрагмент.

Ещё один вариант реализации навигации по условию
splashViewModel.splashNavCommand.observe(viewLifecycleOwner, Observer { splashNavCommand ->    val navController = Navigation.findNavController(requireActivity(), R.id.activity_root__fragment__nav_host)    val mainGraph = navController.navInflater.inflate(R.navigation.app_nav_graph)    // Way to change the first screen at runtime.    mainGraph.startDestination = when (splashNavCommand) {        SplashNavCommand.NAVIGATE_TO_MAIN -> R.id.MainFragment        SplashNavCommand.NAVIGATE_TO_AUTH -> R.id.auth__nav_graph        null -> throw IllegalArgumentException("Illegal splash navigation command")    }    navController.graph = mainGraph})

Мы по-прежнему выбираем начальный экран в SplashViewModel, но теперь в observer-е перестраиваем граф навигации и устанавливаем его в рутовый NavController, который получаем из Activity.

При таком способе навигации экран Splash-а больше не находится в back stack-е, и нажатие на кнопку Back на первом экране авторизации сразу закроет приложение без необходимости добавлять OnBackPressedCallback, завязанный на аргумент.

Что ещё нужно сделать: поправить способ перехода с последнего экрана флоу авторизации на главный экран. Раньше мы закрывали флоу авторизации с помощью findNavController().popBackStack и пробрасывали результат о пройденной авторизации через SavedStateHandle, чтобы заново открывшийся Splash-экран перевёл нас на главный экран. Теперь можно поступить проще:

Навигация с последнего экрана авторизации
// Navigate back from auth flowval result = findNavController().popBackStack(R.id.auth__nav_graph, true)if (result.not()) {    // we can't open new destination with this action    // --> we opened Auth flow from splash    // --> need to open main graph    findNavController().navigate(R.id.MainFragment)}

Метод popBackStack возвращает true, если стек был извлечён хотя бы один раз и пользователь был перемещён в какой-то другой destination, а false в противном случае. Если граф авторизации был первым открытым destination-ом после Splash-экрана (а так и будет, поскольку мы изменили startDestination), этот метод вернёт нам false.

Убрав из back stack-а все экраны авторизации, мы вернулись в рутовый граф, где в качестве start destination-а выбран именно граф авторизации. При этом, если открыть граф авторизации, например, с главного экрана, вызов popBackStack уже вернёт true, и мы не выполним ещё один переход на главный экран.

Работа с диплинками

С исправленной инициализацией BottomNavigationView при запуске команды на открытие диплинка через ADB больше не происходит никаких крашей это прекрасно. Но никуда не делась особенность со сбросом стека: приложение по-прежнему целиком перезапускается, и нужно придумывать свои собственные способы обработки диплинков.

И как же это повлияло на мнение о Navigation Component

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

  • Нижняя навигация через BottomNavigationView Navigation Component из коробки соответствует гайдам Material design-а (не сохраняется стек при переходе между вкладками), но если вам требуется поведение а-ля iOS (когда стек вкладок должен сохраняться), можно использовать extension-функцию, которая даст нужное поведение.

  • Навигация во вложенные графы и обратно всё работает корректно, навигацию обратно можно реализовать через NavController.popBackStack(R.id.nestednavgraph), никаких костылей.

  • Навигация из вложенного контейнера во внешний (например, из контейнера с нижней навигацией в контейнер без неё) реализуется через поиск правильного NavController-а и не вызывает никаких проблем.

  • Навигацию на старте приложения по условию можно реализовывать разными способами либо через аргументы, либо через модификацию стартового графа в runtime-е. Модификация графа может сильно упростить этот кейс;

  • Навигация между модулями из трёх предложенных способов надёжно работают два: описание графа в app-модуле + навигация через интерфейсы и описание графа в отдельном модуле, который подсоединяется ко всем остальным.

  • Кейс с пробросом результата из вложенного графа, как правильно заметили в комментариях к одной из статей, проще сделать через какую-нибудь реактивную шину или Result API и не использовать никакой SavedStateHandle.

Что может оттолкнуть вас в Navigation Component:

  • Навигация через deep link-и потому что есть особенность со сбросом back stack-а, а это поведение подойдёт не всем приложениям;

  • Зависимость от тулинга и (опционально) кодогенерация пока редактор графа навигации не выделили в отдельный плагин Android Studio, чтобы получить какие-то обновления редактора, нужно ожидать обновления Android Studio + опционально, с помощью gradle-плагинов вы можете сгенерировать много кода, а это может замедлить сборку;

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

Спасибо @shipa_oблагодаря которому я нашел эту ошибку.

Полезные ссылки

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru