Когда вы начинаете создавать приложение, в котором хотя бы
несколько экранов, всегда встает вопрос - как лучше реализовать
навигацию. Вопрос становится интереснее и сложнее, когда вы
собираетесь делать многомодульное приложение. Примерно полтора года
назад я рассказывал как
можно реализовать навигацию c помощью Jetpack в многомодульном
проекте. И вот спустя время, я наткнулся на свою реализацию и
понял, что можно на том же Jetpack летать по модулям проще: без
магии и DI.
Архитектура проекта
Чтобы покрыть основные кейсы я покажу как реализовать навигацию
на многомодульном проекте такой структуры:
Типичная архитектура Android проекта: feature-модули c
реализацией экранов зависят от shared-модулей с общей логикой. И
app модуль, который зависит от feature и shared.
Сейчас довольно популярен подход Single Activity, поэтому в моем
примере будет всего одна Activity с глобальным хостом, в котором
будут переключаться фрагменты
Подготовка
От модуля shared:navigation зависят почти все
модули проекта не просто так. В этом модуле реализована функция
расширения фрагмента для реализации переходов.
fun Fragment.navigate(actionId: Int, hostId: Int? = null, data: Serializable? = null) {val navController = if (hostId == null) {findNavController()} else {Navigation.findNavController(requireActivity(), hostId)}val bundle = Bundle().apply { putSerializable("navigation data", data) }navController.navigate(actionId, bundle)}
У функции есть параметры:
-
actionId - id действия графа навигации
-
hostId - id хоста графа навигации. Если не будет передан, то
будет использован текущий хост
-
data - объект с данными типа Serializable
В этом же модуле реализована функция расширения фрагмента для
получения данных, которые были переданы при выполнении действия
навигации.
val Fragment.navigationData: Serializable?get() = arguments?.getSerializable("navigation data")
Также в этом модуле надо описать id хостов навигации, чтобы к
ним был доступ из feature модулей. Для этого в директории ресурсов
надо создать файл res/value/ids.xml
<?xml version="1.0" encoding="utf-8"?><resources><item name="host_global" type="id"/><item name="host_main" type="id"/></resources>
Отлично! Подготовка завершена, можно приступать к самой
реализации навигации.
Простые переходы в feature-модулях
Сэмулируем типичное поведение экрана splash.
Обычно с этого экрана идет переход либо к онбордингу, либо к
главному экрану приложения, либо к экрану авторизации. Реализуем
нечто похожее: пусть фрагмент фичи splash будет
уметь переходить на экран онбординга и на главный экран по нажатию
кнопки.
Для начала создади id для этих действий: запишем их в
res/value/ids.xml модуля
splash
<?xml version="1.0" encoding="utf-8"?><resources><item name="action_splashFragment_to_mainFragment" type="id"/><item name="action_splashFragment_to_onboardingFragment" type="id"/></resources>
Id для действий переходов я рекомендую создавать именно в
модулях фич, которые будут использовать эти действия, а не в модуле
shared:navigation. Это позволяет модулю знать
только о необходимых действиях.
Теперь можно использовать созданные id для выполнения
переходов.
import com.example.smmn.shared.navigation.navigateclass SplashFragment : Fragment(R.layout.fragment_splash) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) buttonToOnboarding.setOnClickListener { navigate(R.id.action_splashFragment_to_onboardingFragment) } buttonToMain.setOnClickListener { navigate(R.id.action_splashFragment_to_mainFragment) } }}
Обратите внимание, что для выполнения перехода используется
функция расширения из модуля
shared:navigation.
Но чтобы этот переход заработал надо настроить глобальный хост и
реализовать глобальную навигацию.
Глобальный хост
В нашей архитектуре всего одна Activity. Она будет содержать
глобальный хост для фрагментов. Для этого нам ничего не потребуется
реализовывать в самом коде Activity.
class MainActivity : AppCompatActivity(R.layout.activity_main)
Хост добавить надо в ее разметке
activity_main.xml
<?xml version="1.0" encoding="utf-8"?><androidx.fragment.app.FragmentContainerView 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/host_global" android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" app:defaultNavHost="true" app:navGraph="@navigation/navigation_global" tools:ignore="FragmentTagUsage" />
Глобальная навигация
Это навигация, которая происходит в глобальном хосте. Для ее
реализации надо реализовать в модуле app граф
навигации res/navigation/navigation_global.xml
<?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" android:id="@+id/navigation_global" app:startDestination="@id/splashFragment"> <fragment android:id="@+id/splashFragment" android:name="com.example.smmn.feature.splash.SplashFragment" android:label="SplashFragment"> <action android:id="@id/action_splashFragment_to_mainFragment" app:destination="@id/mainFragment" app:popUpTo="@id/navigation_global" /> <action android:id="@id/action_splashFragment_to_onboardingFragment" app:destination="@id/onboardingFragment" app:popUpTo="@id/navigation_global" /> </fragment> <fragment android:id="@+id/mainFragment" android:name="com.example.smmn.feature.main.MainFragment" android:label="MainFragment" > <action android:id="@id/action_mainFragment_to_splashFragment" app:popUpTo="@id/navigation_global" app:destination="@id/splashFragment" /> </fragment> <fragment android:id="@+id/onboardingFragment" android:name="com.example.smmn.feature.onboarding.OnboardingFragment" android:label="OnboardingFragment"> <action android:id="@id/action_onboardingFragment_to_mainFragment" app:destination="@id/mainFragment" app:popUpTo="@id/navigation_global" /> </fragment></navigation>
Обратите внимание, что у каждого фрагмента есть набор action
(действий) с помощью которых происходит переход между фрагментами.
В действии указывается на какой фрагмент будет выполнен переход и
как обрабатывать переход назад, например, при нажатии кнопки
"Back".
И очень важно отметить, что id действий прописаны без знака +,
то есть мы не создаем id в этом графе, а используем id, прописанные
в feature модуле.
Прописанные id в модуле splash
<item name="action_splashFragment_to_mainFragment" type="id"/><item name="action_splashFragment_to_onboardingFragment" type="id"/>
Использование их в действиях глобального графа
<action android:id="@id/action_splashFragment_to_mainFragment" app:destination="@id/mainFragment" app:popUpTo="@id/navigation_global" /> <action android:id="@id/action_splashFragment_to_onboardingFragment" app:destination="@id/onboardingFragment" app:popUpTo="@id/navigation_global" />
Вложенный хост
В Jetpack навигации есть возможность использовать вложенный
хост. Это очень полезно, когда мы хотим сделать меню типа
BottomNavigation и использовать для этого меню отдельный граф
навигации.
В нашем примере во вложенном хосте будут фичи профиля и
настроек.
Благодаря библиотеке navigation-ui, реализовать вложенную
навигацию довольно просто.
В модуле main создадим меню для
BottomNavigation в res/menu/menu_main.xml
<?xml version="1.0" encoding="utf-8"?><menu xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"> <item android:id="@+id/profileFragment" android:icon="@drawable/ic_baseline_account_circle_24" android:title="@string/main_menu_title_profile" /> <item android:id="@+id/settingsFragment" android:icon="@drawable/ic_baseline_settings_24" android:title="@string/main_menu_title_settings" /></menu>
Создадим граф навигации в
res/navigation/navigation_main.xml
<?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" android:id="@+id/navigation_main" app:startDestination="@id/profileFragment"> <fragment android:id="@+id/profileFragment" android:name="com.example.smmn.feature.profile.ProfileFragment" android:label="ProfileFragment"> <action android:id="@id/action_profileFragment_to_infoFragment" app:destination="@id/infoFragment" /> </fragment> <fragment android:id="@+id/settingsFragment" android:name="com.example.smmn.feature.settings.SettingsFragment" android:label="SettingsFragment" /> <fragment android:id="@+id/infoFragment" android:name="com.example.smmn.feature.info.InfoFragment" android:label="InfoFragment" /></navigation>
Здесь важно указать у фрагментов те же id что указаны в файле
меню res/menu/menu_main.xml. И не забывать, что id
действий брать из модулей фич.
Осталось добавить хост и меню в разметку фрагмента
res/layout/fragment_main.xml
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <fragment android:id="@id/host_main" android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="0dp" app:defaultNavHost="true" app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:navGraph="@navigation/navigation_main" /> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottomNavigationView" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@android:color/white" app:elevation="8dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:menu="@menu/menu_main" /></androidx.constraintlayout.widget.ConstraintLayout>
И в самом фрагменте настроить bottomNavigationView
class MainFragment : Fragment(R.layout.fragment_main) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) NavigationUI.setupWithNavController( bottomNavigationView, Navigation.findNavController(requireActivity(), R.id.host_main) ) }}
Переходы между фрагментами из разных хостов
Довольно частый случай, когда надо перейти с экрана, который
находится внутри вложенного хоста, на экран глобального хоста.
Например, у нас есть главный экран c хостом для экранов главных
фич: настроек и профиля.
И на экране настроек, который находится внутри хоста главного
экрана (не глобальный хост, а глубже) надо выполнить переход на
экран сплэша, который находится в глобальном хосте. Например это
может понадобиться, если надо разлогинить текущего
пользователя.
В этом случае также воспользуемся функцией расширения фрагмента,
но укажем id глобального хоста. Мы имеем к нему доступ из фичи, так
как он прописан в модуле shared:navigation.
class SettingsFragment : Fragment(R.layout.fragment_settings) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) buttonToSplash.setOnClickListener { navigate(R.id.action_mainFragment_to_splashFragment, R.id.host_global) } }}
Id действия по аналогии с предыдущим переходом прописан в самом
модуле фичи res/values/ids.xml
<?xml version="1.0" encoding="utf-8"?><resources><item name="action_mainFragment_to_splashFragment" type="id"/></resources>
Переходы между фрагментами с передачей и получением данных
Чтобы выполнить переход с передачей данных необходимо, чтобы
данные можно было положить в bundle. Это могуг быть какие-то
примитивные типы или объекты Serializable классов.
Выше я уже реализовал функцию расширения фрагмента для
выполнения перехода, в которую можно передать объект Serializable
класса. Аналогично вы можете реализовать передачу примитивных
типов.
Чтобы передать объект Serializable класса надо чтобы модуль
фичи, с которой происходит переход, и модуль фичи, на которую
происходит переход, имели доступ к модулю с таким классом. В нашем
случае создадим модуль shared:model где будет
лежать Serializable класс Info.
data class Info( val name: String, val surname: String) : Serializable
Переход будет происходить с экрана profile на
экран info. Создадим объект Info и передадим его в
функцию расширения фрагмента.
class ProfileFragment : Fragment(R.layout.fragment_profile) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) buttonToInfo.setOnClickListener { navigate(R.id.action_profileFragment_to_infoFragment, data = Info("name", "surname")) } }}
И получим данные используя другую функцию расширения фрагмента,
созданную ранее.
class InfoFragment : Fragment(R.layout.fragment_info) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val info = navigationData as? Info ?: return textView.text = info.toString() }}
Так это будет выглядеть в приложении
Заметьте, что мы не указывали в каком хосте выполнить переход, и
переход произошел в текущем хосте.
Заключение
Таким несложным способом можно организовать навигацию в вашем
многомодульном проекте, используя Jetpack и пару функций
расширения. Этот подход функционально не отличается от подхода,
который я описывал ранее, но в использовании он намного проще и
лаконичнее.
Оставляю ссылку на код примера
приложения.
Буду рад обратной связи!