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

hqxlbq0azvqgk2swgs6nxbg7gug.jpeg

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

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

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

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


Disclaimer

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

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

oubzofreyh4uung7flv_e_pgsku.png

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


Кейсы с BottomNavigationView

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

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


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

yv6dw_g_m-tg1lrw00wyx4ulqbe.png


Первый опыт

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


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


Вёрстка Activity из шаблона


    

    

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

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


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


Граф навигации из шаблона


    

    

    

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


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


@menu-ресурс для описания табов


    

    

    

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


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

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

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


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

44mlg6ihrlktebowkqyb3boalic.gif

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

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

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


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

_ablenuxe9bdc_4mr06xptgpedq.gif

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

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


У нас есть workaround

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

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


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


Граф навигации для одной из вкладок



    

Соответственно, для примера 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 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
}

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? = 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, совпадали с идентификаторами графов навигации.


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

np1jiyuujukxpnx7sxfecat-ega.png

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

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


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

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

xfoixwtfqzxse_ofzzbjkppkmty.gif

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

dornb5srzs5oniji-bbzvwj-mdc.gif


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

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


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

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

oubzofreyh4uung7flv_e_pgsku.png

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

daxh5sieymnmntm1_u9jvz5wqaq.png

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

iihfxnio5g92v9wpucpchtvsof4.png

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


Посмотреть код
class MainFragment : Fragment(R.layout.fragment_main) {

    private var currentNavController: LiveData? = 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? = 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, запустил свой пример и… получил краш при сворачивании приложения.


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

fljzdnejfrzea5-0hk9q6jppek4.gif

На гифке видно, как я запустил приложение, и после 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? = null

    private fun setupBottomNavigationBar() {
        ...
        currentNavController?.observe(
            viewLifecycleOwner,
            Observer { liveDataController ->
                Navigation.setViewNavController(requireView(), liveDataController)
            }
        )
    }

}

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


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

6xcymqexsunaspdb6mw8no1f4eo.gif

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

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


Фиксим IllegalStateException
// NavigationExtensions.kt

private 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 всё, на следующей неделе расскажу про кейсы с вложенными графами навигации.

© Habrahabr.ru