В Android 13 Google представил новую «фишку»: predictive‑навигация. Это API позволяет пользователю «посмотреть» на какой экран он вернется, не выполняя непосредственно возврат. Подобный функционал довольно давно есть в iOS и, например, в Telegram на Android. Теперь же данный функционал должен работать в Android «из коробки», а с Android 16 будет включен по умолчанию. Немного поресерчив тему можно найти что для его работы необходимо включить флаг enableOnBackInvokedCallback и мигрировать на BackPressedDispatcher. Посмотрим так ли это.

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

Включаем OnBackInvokedCallback

Рассматриваемый проект уже смигрирован на BackPressedDispatcher, так что сразу включаем OnBackInvokedCallback как указано в документации.

<application
    ...
    android:enableOnBackInvokedCallback="true"
    ... >
...
</application>

Запускаем приложение, ииии.... ничего не работает.

Predictive back не работает
Predictive back не работает

Потратив некоторое время на поиск того, что может мешать работе функционала, решил все же попробовать отключить все OnBackPressedCallback, так или иначе попадающие во флоу запуска home экрана, и вуаля:

 Predictive back закрывает только Activity
Predictive back закрывает только Activity

Однако если перейти куда-либо дальше внутрь приложения, жест назад будет сразу закрывать MainActivity:

Без BackPressDispatcher навигация в приложении сломана
Без BackPressDispatcher навигация в приложении сломана

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

Восстанавливаем навигацию между фрагментами.

Среди отключенных OnBackPressedCallback есть такой:

  requireActivity().onBackPressedDispatcher.addCallback {
            if (childFragmentManager.backStackEntryCount > 0) {
                childFragmentManager.popBackStack()
            } else {
                this.isEnabled = false
                requireActivity().onBackPressedDispatcher.onBackPressed()
            }
        }

Не трудно догадаться, что его отключение и привело к поломке внутренней навигации, однако попытка включить его снова ломает Predictive-навигацию. Что же тут происходит и как с этим быть?

Тут стоить немного отойти от темы и присмотреться к OnBackPressedDispatcher API. Когда Google его презентовали, изначально оно показалось совсем неудобным. Популярный до него подход предполагал создание колбэков, возвращающих true и false в зависимости от того обработал ли он нажатие назад или нет. Этот подход позволял регистрировать несколько колбэков и обрабатывать их последовательно. Теперь же у нас нет возвращаемого значения и нам предлагается включать/выключать колбэк.

На самом деле объяснение такому API есть и оно довольно логичное. Если заглянуть в OnBackPressedCallback, то мы увидим, что это абстрактный класс, требующий обязательного определения handleOnBackPressed, однако там есть ещё опциональные методы: handleOnBackStarted, handleOnBackProgressed и handleOnBackCancelled.

Так вот handleOnBackPressed является одним из двух терминальных методов, фактически он вызывается когда переход назад УЖЕ осуществлен. Таким образом, внутри него не может приниматься решение о том куда идти и что показывать пользователю. И именно поэтому включение такого колбэка сразу выключает Predictive-навигацию, а точнее полностью отдаёт её управление нам через те самые опциональные методы.

Так что же делать? Исходя из API OnBackPressedCallback получается два варианта: либо включать/выключать колбэк заранее, до того как пользователь решил уйти с экрана назад, либо полностью самим реализовывать анимацию ухода. В целом оба варианта реализуемые, но с фрагментами есть и третий путь.

Спасибо @JGMaks его cтатье, откуда мы можем узнать, что вообще можно полностью отказаться от этого колбэка и просто делегировать управление стэком child фрагменту. Добавляем этот код в фрагмент, в fragmentManager которого осуществляется навигация.

parentFragmentManager.commit {  
	setPrimaryNavigationFragment(childFragment)  
} 

Запускаем приложение и видим что навигация восстановилась, однако пока без Predictive-анимации.

Навигация восстановлена, но predictive back так же работает только с Activity
Навигация восстановлена, но predictive back так же работает только с Activity

Predictive анимация во фрагментах

Далее необходимо понять почему не работает анимация во фрагментах. Судя по документации, анимация должна работать из коробки начиная с Fragments 1.7.0, однако в реальности она не работает при использовании кастомных анимаций на основе xml ресурсов и для работы predictive необходимо использовать Transition API. Что-ж, меняем анимацию и смотрим что получилось. (Важно отметить, что не быть анимации тоже не может, так у себя такие кейсы заменил на Fade анимацию).

nextFragment.enterTransition = TransitionSet().apply {  
    addTransition(Slide(Gravity.END))  
}  
nextFragment.returnTransition = TransitionSet().apply {  
    addTransition(Slide(Gravity.END))  
}  
currentFragment.exitTransition = TransitionSet().apply {  
    addTransition(Fade())  
}  
currentFragment.reenterTransition = TransitionSet().apply {  
    addTransition(Fade())  
}
Полностью работающий Predictive back
Полностью работающий Predictive back

Ура! В целом это уже рабочий фукнционал, однако ещё есть необработанные кейсы.

Восстанавливаем выключенные OnBackInvokedCallback

В самом начале мы отключили все мешавшиеся OnBackInvokedCallback. Давайте посмотрим что они делали и как вернуть их работу. Я выделил несколько основных сценариев их использования:

  1. Работа с child backstack

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

  3. Custom - навигация без использования backstack, например для вкладок BottomBar

  4. Отправка дефолтного результата через FragmentResultAPI

  5. Отправка аналитики об уходе с экрана

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

В моем кейсе для навигации между вкладками BottomBar не используется backstack. Это позволяет реализовать логику с возвратом на главную страницу по нажатию назад из любого состояния. В целом этот как раз тот кейс, когда мы можем полностью сами реализовать OnBackInvokedCallback. Примеров у Google какая должна быть анимация в этом кейсе я не нашел. Поэтому для примера можем реализовать логику с уменьшением открытой вкладки (в идеале конечно надо показать ещё экран, на который будем переходить)

private val selectFirstSectionCallback by lazy {  
    object : OnBackPressedCallback(false) {  
        override fun handleOnBackPressed() {  
            binding.bottomNavigationView.selectedItemId = INITIAL_SECTION_ID  
            binding.fragmentContent.scaleX = 1f  
            binding.fragmentContent.scaleY = 1f  
        }  
  
        override fun handleOnBackProgressed(backEvent: BackEventCompat) {  
            binding.fragmentContent.scaleX = (1 - BACK_PRESS_ANIMATION_STEP * backEvent.progress).toFloat()  
            binding.fragmentContent.scaleY = (1 - BACK_PRESS_ANIMATION_STEP * backEvent.progress).toFloat()  
        }  
  
        override fun handleOnBackCancelled() {  
            binding.fragmentContent.scaleX = 1f  
            binding.fragmentContent.scaleY = 1f  
        }  
    }  
}

Логика же включения этого колбэка такая: когда childFragmentManager доходит до начала backStack мы включаем selectFirstSectionCallback, чтобы перехватить дальнейшую навигацию и вернуть пользователя на главную. Соответсвенно, если backstack childFragmentManager > 1, колбэк остается выключенным. И тут появляется неожиданная проблема. Как выяснилось, в актуальной на момент написания ст��тьи Fragments 1.8.8, мы не можем поймать момент, когда backstack становится равен 0. Происходит этого из-за того, что при его расчете учитывается и сам переход (mTransitioningOp), однако по его окончанию колбэк об изменении стэка не вызывается.

public int getBackStackEntryCount() {  
    return mBackStack.size() + (mTransitioningOp != null ? 1 : 0);  
}

Увы, никакого варианта как это красиво обработать я не нашел и написал небольшой костыль, который в цикле проверяет состояние backstack и пытается отловить момент, когда он становится 0

childFragmentManager.addOnBackStackChangedListener {  
    lifecycleScope.launch {  
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {    
            while (!updateSelectFirstSectionCallback()) {  
                   delay(BACKSTACK_LISTENING_DELAY)  
            }  
        }  
    }            
}

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

Predictive back между секциями
Predictive back между секциями

Оставшиеся кейсы использования OnBackInvokedCallback относятся к выполнению некого side-эффекта при закрытии экрана. К сожалению бескомпромиссного решения тут нет. Варианта использования OnBackInvokedCallback для получения события о закрытии экрана API не подразумевает. Самое близкое, что приходит на ум в этом случае, это переопределение onDestroy. (Однако если вдруг у вас в проекте есть возврат результата по нажатию на кнопку назад, правильнее будет пересмотреть эту логику и возвращать результат по какому-либо явному действию пользователя, например нажатию кнопки "применить").

Выводы

Подведем итог, какие шаги необходимо предпринять для поддержки PredictiveBack

  1. Провести ревизию использования OnBackInvokedCallback: колбэки должны включаться/выключаться заранее, внутри себя не должны управлять backstack или вызывать onBackPressed

  2. Мигрировать работу навигации на использование механизма setPrimaryNavigationFragment (для навигации на фрагментах)

  3. Мигрировать на TrasitionAPI (аналогично для фрагментов)

  4. Включить PredictiveBack

  5. Реализолавть PredictiveBack поддержку для оставшихся кейсов

На мой взгляд Predictive навигации давно не хватало Android. C ней приложения кажутся более интерактивными и "живыми". К сожалению остаются кейсы, где её использование невозможно и результат перехода может оставаться неочевидным для пользователя. Главная же трудность заключае��ся в том, что действие назад теперь необходимо определять заранее, что может потребовать существенного изменения логики.