Android navigation component. Простые вещи, которые приходится делать самому +8


Всем привет! Хочу рассказать об особенностях в работе Navigation Architecture Component, из-за которых у меня сложилось неоднозначное впечатление о библиотеке.

Эта статья не пошаговая инструкция, в ней опущены детали реализации, чтобы сосредоточить внимание на ключевых моментах. В интернете есть немало одинаковых примеров использования (есть и переводы) — они помогут познакомиться с библиотекой. Так же перед чтением предлагаю изучить документацию.

image

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

Итак, вот сценарии, при реализации которых ожидания по функционалу не совпали с реальностью в реализации:

  • переключение между пунктами меню в 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()
}


Навигация в меню заработала — ну разве не чудо?!

Menu navigation

Обратите внимание на «гамбургер» (иконка меню), при переключении между пунктами меню он меняет своё состояние на кнопку «назад». Такое поведение показалось непривычным (привычное — как в приложении play market) и, какое-то время, я пытался разобраться, что же сделал не так?

Всё так! Перечитав документацию по принципам навигации (а именно: пункты два и три), понял, что «гамбургер» показывается только для startDestination, вернее так: кнопка «назад» показывается для всех, кроме startDestination. Ситуацию можно поменять применив различные уловки в подписке (addOnNavigatedListener()) на изменение destination, но их даже описывать не стоит. Работает так, нужно смириться.

Открытие новой Activity


Activity может выступать в качестве navigation host и, в то же время, в графе навигации может выступать в роли одного из destination. Открытие Activity без вложенного графа навигации работает как ожидается, то есть вызов:

navController.navigate(R.id.editActivity)

осуществит переход (как в случае с фрагментами) и откроет запрошенную Activity.
Гораздо интереснее рассмотреть случай, когда целевая Activity сама выступает в роли navigation host, то есть вариант 2 из документации:

Navigation scheme

В качестве примера давайте рассмотрим 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 открывается, навигация внутри неё работает:

Edit activity navigation

Опять обратим внимание на кнопку навигации в 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, а она, в свою очередь, почему-то жадничает не передаёт её в граф (например, вызовом navController.graph.setDefaultArguments()). Эту особенность можно обойти, если вручную подготовить navigation controller. Один из способов описан на StackOwerflow.

Пожалуй, наибольшая сложность возникнет при одновременном использовании в качестве startDestination и обычного destination. То есть, при переходе и передаче параметров в startDestination из любого другого destination этого графа, фрагменту придётся самостоятельно определять, откуда извлекать параметры: из arguments или из intent.extras. Это нужно иметь ввиду при проектировании переходов с передачей параметров.



Резюмируя, хочу отметить, что сам не перестал использовать библиотеку и, несмотря на перечисленные недостатки особенности, считаю её достаточно полезной, чтобы рекомендовать к использованию. Очень надеюсь, что в следующих релизах изменится ситуация по крайней мере с передачей параметров в startDestination.

Благодарю за внимание. Рабочего Вам кода!

Исходники для статьи размещены на GitHub.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (11):

  1. barbanel
    /#19339282

    Отдельное спасибо за КДПВ, порадовали =)

    • XIII-th
      /#19340656

      Рад, что удалось отразить основную мысль )

  2. Logos_Intellect
    /#19341396

    Google, как бы, намекает на использование подхода Single Activity

    • XIII-th
      /#19341580

      Это верно, но мне хотелось разделить логику просмотра и редактирования, чтобы не перегружать одну Activity элементами, которые нужны «не всем».
      Рассмотрим, к примеру, toolbar — это общий компонент для всех фрагментов. Одни фрагменты
      (фрагменты списков) захотят видеть в нём поиск, другим нужно будет показывать сжимающуюся картинку (инфо фрагменты), а третьим и вовсе захочется встроить в toolbar поле ввода (фрагменты редактирования). Реализовав всё это в одной activity, мы получим монстр-toolbar/actvity со всеми вытекающими последствиями.
      Такой подход имеет право на жизнь, но мне привычнее жить с разделением ответственности.

      • Logos_Intellect
        /#19342782

        Так у каждого фрагмента свой toolbar в xml файле прописываешь. Я обычно делаю общее только DrawerLayout или BottomNavigationBar. Ну и естественно всякие алерты вроде «Нет подключения к интернету»

    • egordeev
      /#19341620

      вроде фрагменты были придуманы как workaround?

      • XIII-th
        /#19341628

        К сожалению, не понял вопроса

        • egordeev
          /#19342582

          я по поводу этого комментария:

          Google, как бы, намекает на использование подхода Single Activity

      • XIII-th
        /#19345176

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

    • kapmayn
      /#19349576

      нет, гугл намекает на использование подхода «1 фича = 1 активити»

      • XIII-th
        /#19349590

        Дай, пожалуйста, ссылку, где можно почитать про такой посыл. Я пока встретил только про Single Activity