November 2, 2019

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

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

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

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

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

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

    <! -- Остальная часть меню -- >
</menu>

и 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>

Затем нужно связать меню с контроллером навигации:

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 нет меню:

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

<?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>

Я добавил параметр непосредственно в корневой элемент графа (navigation), но можно добавить в целевой фрагмент. От этого изменится только способ получения параметра.

<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'ы в основной граф

Я добавил add и edit action'ы в один из фрагментов, так они будут доступны только из него.

<?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)

4. Получим 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. Один из способов описан на StackOverflow.

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


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

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

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

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