Всем привет! На связи Максим Бредихин, Android-разработчик в Тинькофф. А это — третья статья об интересных моментах из Fragment API, о которых вы, возможно, не знали.

Наливайте чай, садитесь поудобнее и приятного чтения!

Сегодня у нас будет немного подкапотных подробностей, поэтому для наших гостей из будущего хочу уточнить, что описанные детали реализации актуальны для версии Fragments:1.5.2.

Multi-backstack

В версии Fragments 1.4.0 появилась интересная фича: множественный backstack. Использовать ее можно для сохранения backstack при переходах между вкладками, чтобы избежать такого поведения:

Состояние теряется при переключении между вкладками
Состояние теряется при переключении между вкладками

Разберемся, как сделать все красиво.

Создадим пару фрагментов и добавим им кнопку для открытия внутреннего экрана.

Чтобы авантюра сработала, в FragmentTransaction.addToBackStack(String?) нужно указать уникальные имена. Пусть это будут First tab и Second tab для соответствующих вкладок.

// First fragment
button.setOnClickListener {
  parentFragmentManager.commit {
    setReorderingAllowed(true) // Обязательно для использования Multi-backstack
    replace<FirstInnerFragment>(R.id.container)
    addToBackStack("First tab")
  }
}
// Аналогично для второго фрагмента

Затем под каждый фрагмент добавим табики, а при переходах будем сохранять и восстанавливать backstack’и.

// MainActivity
tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
  override fun onTabSelected(tab: TabLayout.Tab?) {
    when (tab?.text) {
      "Tab 1" -> {
        // Сохраняем backstack другой вкладки
        fragmentManager.saveBackStack("Second tab")
        
        // Добавляем фрагмент на случай, если backstack нашей вкладки пустой
        fragmentManager.commit {
          setReorderingAllowed(true)
          replace<FirstFragment>(R.id.container)
        }
        
        // Восстанавливаем наш backstack
        fragmentManager.restoreBackStack("First tab")
      }
      "Tab 2" -> { /* Аналогично первой вкладке */  }
    }
  }
  …
})

Важно! Не совершайте коммит одновременно с восстановлением стека. Лучше выбрать что-нибудь одно.

Вот какие еще моменты нужно учесть:

  • Имена, указанные в saveBackStack(String) и restoreBackStack(String), должны совпадать с именами, указанными в addToBackStack(String?). При вызове saveBackStack(String) FragmentManager проходит по всему своему backstack’у и собирает все транзакции по указанному имени, после чего сохраняет их в HashMap, используя имя как ключ.

  • Имена должны быть уникальными в пределах FragmentManager’а, иначе в HashMap произойдет коллизия и один backstack перетрет другой.

  • saveBackStack("name") работает аналогично popBackStack("name"). За исключением того, что он сохраняет транзакции, которые откатывает.

  • Если в сохраняемом backstack есть транзакции без вызова FragmentTransaction.setReorderingAllowed(true), вызов saveBackStack() приведет к крашу с IllegalArgumentException.

  • Если backstack с указанным именем пустой или не существует, saveBackStack() ничего не сохранит.

  • restoreBackStack() — асинхронная операция. Нельзя гарантировать, что сразу после нее backstack восстановится.

  • Если не сохранить backstack с указанным именем, restoreBackStack() ничего не сделает.

  • Все backstack’и хранятся в SavedState.

В итоге мы получаем то, что хотели:

Состояние вкладок сохраняется при переходах, а кнопка «Назад» отрабатывает корректно. Красота!
Состояние вкладок сохраняется при переходах, а кнопка «Назад» отрабатывает корректно. Красота!

Когда сохраненный backstack будет не нужен, мы можем его почистить, вызвав FragmentManager.clearBackStack(String).

fragmentManager.clearBackStack("First tab")

OnBackPressedDispatcher

Мы привыкли переопределять onBackPressed() в Activity для ручной обработки системной кнопки «Назад». Однако в версии Activity 1.0.0 появилась альтернатива, которая развивалась с каждой новой версией — OnBackPressedDispatcher

В Android 13 это API было интегрировано c PredictiveBack, а переопределение onBackPressed() пометили deprecated.

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

Чтобы добавить callback из фрагмента, нужно обратиться к родительской Activity.

// Используем расширение из activity-ktx
// Добавляем callback
val callback = requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
  // handle onBackPressed
}

// Отключаем callback
callback.isEnabled = false

// Удаляем callback
callback.remove()

Все просто. А теперь запомним несколько особенностей работы OnBackPressedDispatcher’a:

  • OnBackPressedDispatcher вызывается в super.onBackPressed().

  • Мы можем добавить любое количество callback.

  • Если мы указали lifecycleOwner, callback будет добавлен, как только компонент перейдет в STARTED, и удален — в DESTROYED.

  • Если мы не указали lifecycleOwner, удалить callback нужно руками.

  • Если callback’ов нет, нажатие обработает Activity.

Несмотря на то, что количество callback’ов может быть любым, обрабатывать нажатия будет только один — последний добавленный, находящийся в состоянии enabled.

requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
  // ignored
}
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
  // handle onBackPressed
}
val callback = requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
  // not enabled
}
callback.isEnabled = false

Primary Navigation Fragment

Это фрагмент, chidFragmentManager’у которого делегируется обработка системной кнопки «Назад» и операции FragmentManager.popBackStack() без параметров.

Создадим два фрагмента: в первом сделаем кнопку «Назад», во втором — кнопку для открытия InnerFragment. Затем добавим эти фрагменты в контейнеры.

// FirstFragment
buttonBack.setOnClickListener {
  parentFragmentManager.popBackStack()
}

// SecondFragment
buttonGoDeep.setOnClickListener {
  childFragmentManager.commit {
    setReorderingAllowed(true)
    replace<InnerFragment>(R.id.container)
    addToBackStack(null)
  }
}

// MainActivity
supportFragmentManager.commit {
  add<FirstFragment>(R.id.container1)
  add<SecondFragment>(R.id.container2)
}
Системная кнопка «Назад» и кнопка «Go Back» работают некорректно
Системная кнопка «Назад» и кнопка «Go Back» работают некорректно

Получилось не совсем так, как мы ожидали. Случилось это, потому что мы вызываем popBackStack() на FragmentManager’е с пустым стеком. Ведь мы добавляли транзакцию в childFragmentManager второго фрагмента, а откатить ее пытались через parentFragmentManager первого, который в этом случае выступает supportFragmentManager’ом родительской Activity. Да, получается «Санта-Барбара», но таков наш Android.

Более того, по умолчанию именно supportFragmentManager обрабатывает системную кнопку «Назад». Поэтому мы вышли из приложения, несмотря на добавленный в backstack InnerFragment.

Чтобы решить эту проблему, укажем, что теперь второй фрагмент будет главным по backstack’у, то есть Primary Navigation Fragment’ом.

// SecondFragment
override fun onAttach(context: Context) {
  super.onAttach(context)
  parentFragmentManager.commit {
    setPrimaryNavigationFragment(this@SecondFragment)
  }
}
Теперь кнопки и backstack работают как ожидалось
Теперь кнопки и backstack работают как ожидалось

Важно запомнить:

  • В рамках одного FragmentManager’а может быть только один Primary Navigation Fragment.

  • Чтобы вернуть управление родителю, нужно вызвать FragmentTransaction.setPrimaryNavigationFragment(null).

  • Primary Navigation Fragment’ом может быть только уже добавленный в контейнер фрагмент или фрагмент, который будет добавлен в той же транзакции.

  • Если Primary Navigation Fragment удалили из контейнера, управление переходит родителю.

  • Если у Primary Navigation Fragment пустой backstack, операция передается родителю.

  • Если родитель — не Activity или Primary Navigation Fragment, вызов FragmentTransaction.setPrimaryNavigationFragment(this) из ребенка приведет к тому, что ему будут делегироваться только вызовы childFragmentManager.popBackStack() его родителя.

Разберем, что происходит под капотом: supportFragmentManager отлавливает системное нажатие «Назад» или вызов popBackStack() и отдает его установленному через него Primary Navigation Fragment’у. Тот делегирует следующему и так далее до последнего, образуя цепь. Если мы разорвем цепь посередине, остальные звенья останутся целыми. Другими словами, для восстановления всей цепи достаточно будет восстановить лишь одно звено. А дальше вступят в силу вышеописанные принципы. 

Связь FragmentManager’ов и передача popBackStack() через PrimaryNavigationFragment
Связь FragmentManager’ов и передача popBackStack() через PrimaryNavigationFragment

Нажатие кнопки «Назад» отслеживается через тот же OnBackPressedDispatcher, который я описал выше. Внутри FragmentManager’а есть колбек, который активируется в одном из двух кейсов:

  1. У нас есть незавершенные операции транзакции, добавляемой в backstack, или не завершена работа со всем backstack’ом (сохранение, восстановление, popBackStack()).

  2. Фрагмент-хост этого FragmentManager’а — Primary Navigation Fragment и все его родители, соответственно, тоже, а его backstack не пустой.

Если колбек активен, он просто вызывает FragmentManager.popBackStackImmediate(). Но мы помним, что активный колбек может быть только один. Следовательно, если мы добавим OnBackPressedCallback после завершения транзакции над Primary Navigation Fragment, когда он перейдет в состояние RESUMED, это может сломать всю его работу с системной кнопкой «Назад», ведь мы будем сами ее перехватывать. А если добавить колбек до этого момента, то сначала очистится backstack у Primary Navigation Fragment и только потом мы начнем отлавливать нажатия на кнопку «Назад».

Fragment Result API

В версии Fragments 1.4.0 нам дали возможность возвращать результат из фрагмента его родителю. В документации есть прекрасная иллюстрация принципа работы этого API.

Принцип работы Fragment Result Api
Принцип работы Fragment Result Api

Да, без FragmentManager’а тут тоже не обошлось. Фактически результат просто отправляется в FragmentManager, который доставляет его последнему подписчику.

Fragment Result Api выглядит максимально просто:

// Добавление слушателя
FragmentManager.setFragmentResultListener(requestKey: String, lifecycleOwner: LifecycleOwner, listener: FragmentResultListener)

// Удаление слушателя
FragmentManager.clearFragmentResultListener(requestKey: String)

// Отправка результата
FragmentManager.setFragmentResult(requestKey: String, result: Bundle)

// Удаление результата
FragmentManager.clearFragmentResult(requestKey: String)

// Слушатель
interface FragmentResultListener {
   fun onFragmentResult(requestKey: String, result: Bundle);
}

Для отправки результатов нужно указать уникальный в рамках FragmentManager’а ключ и положить результат в виде Bundle. Чтобы прочитать этот результат, мы должны указать такой же ключ в том же FragmentManager’е. Другими словами, возвращаем результат в parentFragmentManager, слушаем результат через тот же FragmentManager, через который совершили коммит.

Для работы с API нужно понимать несколько моментов:

  • Слушатели хранятся в HashMap, поэтому каждый новый слушатель удаляет предыдущий с таким же ключом.

  • Прослушивать результаты и отправлять их нужно через один FragmentManager.

  • Результат прослушивается только в состоянии выше STARTED.

  • Если на момент отправки результата слушателя нет или он находится в состоянии ниже STARTED, результат сохранится внутри FragmentManager до появления валидного слушателя.

  • Если до появления валидного слушателя мы отправили несколько результатов, доставлен будет только последний.

  • Если на момент отправки результата есть валидный слушатель, он будет доставлен мгновенно.

FragmentManager не разбирается, какой фрагмент — родительский, а какой — дочерний, поэтому мы можем «вернуть» результат из родительского фрагмента или Activity в дочерний. Это возможно, но делать так не нужно:

// MainActivity
supportFragmentManager.commit { replace<InnerFragment>(R.id.container) }
supportFragmentMananger.setFragmentResult("Result key", Bundle())

// InnerFragment
parentFragmentMananger.setFragmentResultListener("Result key", this) { key, bundle ->
	// handle result
}

А вот так делать можно и нужно:

// MainActivity
supportFragmentManager.commit { replace<InnerFragment>(R.id.container) }
supportFragmentMananger.setFragmentResultListener("Result key", this) { key, bundle ->
	// handle result
}

// InnerFragment
parentFragmentMananger.setFragmentResult("Result key", Bundle())

Fragment-ktx

А теперь облегчим работу с Fragment Result API через несколько функций-расширений. Все методы используют parentFragmentManager и позволяют избавиться от кусочка шаблонного кода, который я описывал выше. Расширения актуальны для версии Fragment-ktx: 1.5.2

fun Fragment.setFragmentResult(requestKey: String, result: Bundle)
fun Fragment.clearFragmentResult(requestKey: String)
fun Fragment.setFragmentResultListener(requestKey: String, listener: (String, Bundle) -> Unit)
fun Fragment.clearFragmentResultListener(requestKey: String)

Заключение

Закончилась третья часть нашего путешествия по изучению возможностей Fragment API. На этом этапе пути мы узнали про множественный backstack, научились отлавливать нажатие системной кнопки «Назад», узнали, кто такой Primary Navigation Fragment, и познакомились с Fragment Result API.

Отныне вы гуру backstack’а и сможете отловить баги в навигации еще до их появления.

В следующей, заключительной, части поговорим об анимациях и работе с меню. До встречи!

Комментарии (0)