Всем привет! Хочу рассказать об особенностях в работе Navigation Architecture Component, из-за которых у меня сложилось неоднозначное впечатление о библиотеке.
Эта статья не пошаговая инструкция, в ней опущены детали реализации, чтобы сосредоточить внимание на ключевых моментах. В интернете есть немалоодинаковых примеров использования (есть и переводы) — они помогут познакомиться с библиотекой. Так же перед чтением предлагаю изучить документацию.
Сразу скажу, библиотеку безусловно считаю полезной и не исключаю возможности неверного использования, но, пожалуй, я перепробовал всё прежде чем писать эту статью.
Итак, вот сценарии, при реализации которых ожидания по функционалу не совпали с реальностью в реализации:
Это одна из тех функций, которые повлияли на решение использовать Navigation Component.
Нужно всего лишь сделать одинаковыми id пунктов меню
и id экранов (destination в графе навигации)
затем нужно связать меню с контроллером навигации:
Навигация в меню заработала — ну разве не чудо?!
Обратите внимание на «гамбургер» (иконка меню), при переключении между пунктами меню он меняет своё состояние на кнопку «назад». Такое поведение показалось непривычным (привычное — как в приложении play market) и, какое-то время, я пытался разобраться, что же сделал не так?
Всё так! Перечитав документацию по принципам навигации (а именно: пункты два и три), понял, что «гамбургер» показывается только для startDestination, вернее так: кнопка «назад» показывается для всех, кроме startDestination. Ситуацию можно поменять применив различные уловки в подписке (addOnNavigatedListener()) на изменение destination, но их даже описывать не стоит. Работает так, нужно смириться.
Activity может выступать в качестве navigation host и, в то же время, в графе навигации может выступать в роли одного из destination. Открытие Activity без вложенного графа навигации работает как ожидается, то есть вызов:
осуществит переход (как в случае с фрагментами) и откроет запрошенную Activity.
Гораздо интереснее рассмотреть случай, когда целевая Activity сама выступает в роли navigation host, то есть вариант 2 из документации:
В качестве примера давайте рассмотрим Activity для добавления заметки. В ней будет основной фрагмент с полями ввода EditFragment, он в графе навигации будет startDestination. Давайте положим, что при редактировании нам нужно прикрепить фото, для этого будем переходить к PhotoFragment для получения снимка с камеры. Граф навигации будет выглядеть так:
EditActivity мало отличается от MainActivity. Основное отличие в том, что на EditActivity нет меню:
Activity открывается, навигация внутри неё работает:
Опять обратим внимание на кнопку навигации в toolbar — на стартовом EditFragment нет кнопки «Назад к parent Activity» (а хотелось бы). С точки зрения документации, тут всё законно: новый граф навигации, новое значение startDestination, на startDestination не показывается кнопка «Назад», конец.
Для тех, кому хочется вернуть привычное поведение c parent activity, сохранив при этом функционал переключения между фрагментами, могу предложить такойкостыль подход:
Подписка нужна для того, чтобы для NavigationUI.ActionBarOnNavigatedListener все destination не являлись startDestination. Таким образом NavigationUI.ActionBarOnNavigatedListener не будет скрывать кнопку навигации (за деталями стоит обратиться к исходникам). Добавим к этому обработку onSupportNavigateUp() штатным образом на startDestination и получим то, что хотелось.
Стоит сказать, что решение это далеко от идеала хотя бы потому, что это неочевидное вмешательство в поведение библиотеки. Полагаю, могут возникнуть проблемы в случае использования deep links (ещё не проверял).
В Navigation Component есть механизм передачи параметров от одного destination другому. Есть даже инструмент для обеспечения безопасности типов за счёт кодогенерации (неплохо).
Сейчас мы разберём случай, из-за которого я не смог поставить твёрдую пятёрку этому функционалу.
Вернёмся к EditActivity, достаточно привычный сценарий, когда одна Activity используется для создания и редактирования объектов. При открытии объекта для редактирования в Activity нужно передать, например, id объекта — давайте сделаем это штатным образом:
Вы, наверняка, обратили внимание на особенности получения параметров в EditFragment. Так работает, потому что edit action (из пункта 1) передаёт аргументы в EditActivity, а она, в свою очередь, почему-тожадничает не передаёт её в граф (например, вызовом navController.graph.setDefaultArguments()). Эту особенность можно обойти, если вручную подготовить navigation controller. Один из способов описан на StackOwerflow.
Пожалуй, наибольшая сложность возникнет при одновременном использовании в качестве startDestination и обычного destination. То есть, при переходе и передаче параметров в startDestination из любого другого destination этого графа, фрагменту придётся самостоятельно определять, откуда извлекать параметры: из arguments или из intent.extras. Это нужно иметь ввиду при проектировании переходов с передачей параметров.
Резюмируя, хочу отметить, что сам не перестал использовать библиотеку и, несмотря на перечисленныенедостатки особенности, считаю её достаточно полезной, чтобы рекомендовать к использованию. Очень надеюсь, что в следующих релизах изменится ситуация по крайней мере с передачей параметров в startDestination.
Благодарю за внимание. Рабочего Вам кода!
Исходники для статьи размещены на GitHub.
Эта статья не пошаговая инструкция, в ней опущены детали реализации, чтобы сосредоточить внимание на ключевых моментах. В интернете есть немало
Сразу скажу, библиотеку безусловно считаю полезной и не исключаю возможности неверного использования, но, пожалуй, я перепробовал всё прежде чем писать эту статью.
Итак, вот сценарии, при реализации которых ожидания по функционалу не совпали с реальностью в реализации:
- переключение между пунктами меню в navigation drawer
- открытие новой Activity со своим графом навигации
- передача параметров в startDestination
Переключение между пунктами меню
Это одна из тех функций, которые повлияли на решение использовать Navigation Component.
Нужно всего лишь сделать одинаковыми id пунктов меню
activity_main_drawer.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:showIn="navigation_view">
<group android:checkableBehavior="single">
<item
android:id="@+id/importFragment"
android:icon="@drawable/ic_menu_camera"
android:title="Import"/>
<item
android:id="@+id/galleryFragment"
android:icon="@drawable/ic_menu_gallery"
android:title="Gallery"/>
<item
android:id="@+id/slideshowFragment"
android:icon="@drawable/ic_menu_slideshow"
android:title="Slideshow"/>
<!-- Остальная часть меню -->
и id экранов (destination в графе навигации)
mobile_navigation.xml
<?xml version="1.0" encoding="utf-8"?>
<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/mobile_navigation"
app:startDestination="@id/importFragment">
<fragment
android:id="@+id/importFragment"
android:name="com.xiii.navigationapplication.ImportFragment"
android:label="fragment_import"
tools:layout="@layout/fragment_import"/>
<fragment
android:id="@+id/galleryFragment"
android:name="com.xiii.navigationapplication.GalleryFragment"
android:label="fragment_gallery"
tools:layout="@layout/fragment_gallery"/>
<fragment
android:id="@+id/slideshowFragment"
android:name="com.xiii.navigationapplication.SlideshowFragment"
android:label="fragment_slideshow"
tools:layout="@layout/fragment_slideshow"/>
</navigation>
затем нужно связать меню с контроллером навигации:
MainActivity.kt
class MainActivity : AppCompatActivity() {
private val navController by lazy(LazyThreadSafetyMode.NONE) {
Navigation.findNavController(this, R.id.nav_host_fragment)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(toolbar)
// предоставим управление "гамбургером" в toolbar
NavigationUI.setupWithNavController(toolbar, navController, drawer_layout)
// связывание контроллера навигации и меню
nav_view.setupWithNavController(navController)
}
// добавим поддержку кнопки назад
override fun onSupportNavigateUp() = navController.navigateUp()
}
Навигация в меню заработала — ну разве не чудо?!
Обратите внимание на «гамбургер» (иконка меню), при переключении между пунктами меню он меняет своё состояние на кнопку «назад». Такое поведение показалось непривычным (привычное — как в приложении play market) и, какое-то время, я пытался разобраться, что же сделал не так?
Всё так! Перечитав документацию по принципам навигации (а именно: пункты два и три), понял, что «гамбургер» показывается только для startDestination, вернее так: кнопка «назад» показывается для всех, кроме startDestination. Ситуацию можно поменять применив различные уловки в подписке (addOnNavigatedListener()) на изменение destination, но их даже описывать не стоит. Работает так, нужно смириться.
Открытие новой Activity
Activity может выступать в качестве navigation host и, в то же время, в графе навигации может выступать в роли одного из destination. Открытие Activity без вложенного графа навигации работает как ожидается, то есть вызов:
navController.navigate(R.id.editActivity)
осуществит переход (как в случае с фрагментами) и откроет запрошенную Activity.
Гораздо интереснее рассмотреть случай, когда целевая Activity сама выступает в роли navigation host, то есть вариант 2 из документации:
В качестве примера давайте рассмотрим Activity для добавления заметки. В ней будет основной фрагмент с полями ввода EditFragment, он в графе навигации будет startDestination. Давайте положим, что при редактировании нам нужно прикрепить фото, для этого будем переходить к PhotoFragment для получения снимка с камеры. Граф навигации будет выглядеть так:
edit_navigation.xml
<?xml version="1.0" encoding="utf-8"?>
<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/edit_navigation"
app:startDestination="@id/editFragment">
<fragment
android:id="@+id/editFragment"
android:name="com.xiii.navigationapplication.ui.edit.EditFragment"
android:label="fragment_edit"
tools:layout="@layout/fragment_edit">
<action
android:id="@+id/action_editFragment_to_photoFragment"
app:destination="@id/photoFragment"/>
</fragment>
<fragment
android:id="@+id/photoFragment"
android:name="com.xiii.navigationapplication.ui.edit.PhotoFragment"
android:label="fragment_photo"
tools:layout="@layout/fragment_photo"/>
</navigation>
EditActivity мало отличается от MainActivity. Основное отличие в том, что на EditActivity нет меню:
EditActivity.kt
class EditActivity : AppCompatActivity() {
private val navController by lazy(LazyThreadSafetyMode.NONE) {
Navigation.findNavController(this, R.id.nav_host_fragment)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_edit)
setSupportActionBar(toolbar)
// предоставим управление кнопкой "Назад" в toolbar
NavigationUI.setupWithNavController(toolbar, navController)
}
override fun onSupportNavigateUp() = navController.navigateUp()
fun takePhoto(view: View) {
navController.navigate(R.id.action_editFragment_to_photoFragment)
}
}
Activity открывается, навигация внутри неё работает:
Опять обратим внимание на кнопку навигации в toolbar — на стартовом EditFragment нет кнопки «Назад к parent Activity» (а хотелось бы). С точки зрения документации, тут всё законно: новый граф навигации, новое значение startDestination, на startDestination не показывается кнопка «Назад», конец.
Для тех, кому хочется вернуть привычное поведение c parent activity, сохранив при этом функционал переключения между фрагментами, могу предложить такой
1. Укажем родительскую Activity в манифесте
<activity android:name=".EditActivity"
android:parentActivityName=".MainActivity"
android:theme="@style/AppTheme.NoActionBar">
<!-- Parent activity meta-data to support 4.0 and lower -->
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".MainActivity" />
</activity>
2. Добавим подписку, в которой будем подменять id startDestination
class EditActivity : AppCompatActivity() {
private val navController by lazy(LazyThreadSafetyMode.NONE) {
Navigation.findNavController(this, R.id.nav_host_fragment)
}
private var isStartDestination = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_edit)
setSupportActionBar(toolbar)
val startDestinationId = navController.graph.startDestination
// будем принудительно подменять id, чтобы NavigationUI.ActionBarOnNavigatedListener не пытался скрыть кнопку
// при этом для всех остальных destination будем предоставлять оригинальный startDestination
navController.addOnNavigatedListener { controller, destination ->
isStartDestination = destination.id == startDestinationId
// R.id.fake_start_destination этот id вручную определён для семантики и обеспечения уникальности
controller.graph.startDestination = if (isStartDestination) R.id.fake_start_destination else startDestinationId
}
// предоставим управление кнопкой "Назад" в toolbar
NavigationUI.setupActionBarWithNavController(this, navController)
}
override fun onSupportNavigateUp(): Boolean {
// на startDestination не будем использовать навигацию от Navigation Component
return if (isStartDestination) super.onSupportNavigateUp() else navController.navigateUp()
}
fun takePhoto(view: View) {
navController.navigate(R.id.action_editFragment_to_photoFragment)
}
}
Подписка нужна для того, чтобы для NavigationUI.ActionBarOnNavigatedListener все destination не являлись startDestination. Таким образом NavigationUI.ActionBarOnNavigatedListener не будет скрывать кнопку навигации (за деталями стоит обратиться к исходникам). Добавим к этому обработку onSupportNavigateUp() штатным образом на startDestination и получим то, что хотелось.
Стоит сказать, что решение это далеко от идеала хотя бы потому, что это неочевидное вмешательство в поведение библиотеки. Полагаю, могут возникнуть проблемы в случае использования deep links (ещё не проверял).
Передача параметров в startDestination
В Navigation Component есть механизм передачи параметров от одного destination другому. Есть даже инструмент для обеспечения безопасности типов за счёт кодогенерации (неплохо).
Сейчас мы разберём случай, из-за которого я не смог поставить твёрдую пятёрку этому функционалу.
Вернёмся к EditActivity, достаточно привычный сценарий, когда одна Activity используется для создания и редактирования объектов. При открытии объекта для редактирования в Activity нужно передать, например, id объекта — давайте сделаем это штатным образом:
1. Добавим параметр в граф для EditActivity
Я добавил параметр непосредственно в корневой элемент графа (navigation), но можно добавить в целевой фрагмент. От этого изменится только способ получения параметра.
<?xml version="1.0" encoding="utf-8"?>
<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/edit_navigation"
app:startDestination="@id/editFragment">
<argument
android:name="id"
app:argType="integer"/>
<fragment
android:id="@+id/editFragment"
android:name="com.xiii.navigationapplication.ui.edit.EditFragment"
android:label="fragment_edit"
tools:layout="@layout/fragment_edit">
<action
android:id="@+id/action_editFragment_to_photoFragment"
app:destination="@id/photoFragment"/>
</fragment>
<fragment
android:id="@+id/photoFragment"
android:name="com.xiii.navigationapplication.ui.edit.PhotoFragment"
android:label="fragment_photo"
tools:layout="@layout/fragment_photo"/>
</navigation>
2. Добавим action's в основной граф
Я добавил add и edit action's в одни из фрагментов, так они будут доступны только из него.
<?xml version="1.0" encoding="utf-8"?>
<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/mobile_navigation"
app:startDestination="@id/importFragment">
<fragment
android:id="@+id/importFragment"
android:name="com.xiii.navigationapplication.ImportFragment"
android:label="fragment_import"
tools:layout="@layout/fragment_import">
<action
android:id="@+id/add"
app:destination="@id/editActivity">
<argument
android:name="id"
app:argType="integer"
android:defaultValue="0"/>
</action>
<action
android:id="@+id/edit"
app:destination="@id/editActivity">
<argument
android:name="id"
app:argType="integer"/>
</action>
</fragment>
<fragment
android:id="@+id/galleryFragment"
android:name="com.xiii.navigationapplication.GalleryFragment"
android:label="fragment_gallery"
tools:layout="@layout/fragment_gallery"/>
<fragment
android:id="@+id/slideshowFragment"
android:name="com.xiii.navigationapplication.SlideshowFragment"
android:label="fragment_slideshow"
tools:layout="@layout/fragment_slideshow"/>
<activity
android:id="@+id/editActivity"
android:name="com.xiii.navigationapplication.EditActivity"
android:label="activity_edit"
tools:layout="@layout/activity_edit"/>
</navigation>
3. Подготовим параметры и запросим переход
В этом примере ImportFragmentDirections — автоматически сгенерированый safe-args класс.
val direction = ImportFragmentDirections.edit(123 /* id заметки */)
navController.navigate(direction)
3. Получим id во фрагменте
class EditFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
// работает во остальных случаях, когда fragment не создаётся в качестве startDestination
// val id = EditFragmentArgs.fromBundle(arguments)
val id = EditFragmentArgs.fromBundle(requireActivity().intent.extras)
return inflater.inflate(R.layout.fragment_edit, container, false)
}
}
Вы, наверняка, обратили внимание на особенности получения параметров в EditFragment. Так работает, потому что edit action (из пункта 1) передаёт аргументы в EditActivity, а она, в свою очередь, почему-то
Пожалуй, наибольшая сложность возникнет при одновременном использовании в качестве startDestination и обычного destination. То есть, при переходе и передаче параметров в startDestination из любого другого destination этого графа, фрагменту придётся самостоятельно определять, откуда извлекать параметры: из arguments или из intent.extras. Это нужно иметь ввиду при проектировании переходов с передачей параметров.
Резюмируя, хочу отметить, что сам не перестал использовать библиотеку и, несмотря на перечисленные
Благодарю за внимание. Рабочего Вам кода!
Исходники для статьи размещены на GitHub.