Многие разработчики под Андроид сталкивались с проблемой реализации анимаций и переходов при открытии новых фрагментов. Нам предлагается использовать либо добавление фрагментов в контейнер, наслаивая их друг на друга, либо реплэйс (замена одного фрагента на другой). У реплэйса есть четыре вида анимаций:
.beginTransaction() .setCustomAnimations( R.anim.enter_from_left, //Анимация открытия фрагмента 2 R.anim.exit_to_right, //Анимация закрытия фрагмента 1 R.anim.enter_from_right, //Анимация открытия фрагмента 1 R.anim.exit_to_left) //Анимация закрытия фрагмента 2 .replace(R.id.container, myFragment) .commit()

С реплэйсами проблема заключается в том, что а) — предыдущий фрагмент уничтожается, б) — нет возможности задать действие по закрытию фрагмента жестом (например, как это реализовано в Google Inbox).
Добавление фрагментов в стек (add) позволяет использовать анимации только к открываемому фрагменту, задний будет неподвижен.
И все это, конечно, сопровождается плохим рендерингом и выбитыми кадрами.
В результате, даже такие крупные приложения как ВКонтакте или Инстаграм вообще не используют анимаций фрагментов в своих приложениях.
Полтора года назад телеграм представил Telegram x (тестовая версия своего клиента). Они решили эту проблему так:

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

Итак, создаем класс NavigatorViewPager:
class NavigatorViewPager : ViewPager { init { init() } constructor(context: Context) : super(context) constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) override fun canScrollHorizontally(direction: Int): Boolean { return false } // инициализируем private fun init() { // PageTransformer нужен для переопределения анимаций открываемых фрагментов setPageTransformer(false, NavigatorPageTransformer()) // Отключаем оверскролл overScrollMode = View.OVER_SCROLL_NEVER //Поскольку стандартная анимация открытия новой страницы слишком быстрая, // используем свое поведение setDurationScroll(300) } // Устанавливаем продолжительность анимации открытия фрагмента fun setDurationScroll(millis: Int) { try { val viewpager = ViewPager::class.java val scroller = viewpager.getDeclaredField("mScroller") scroller.isAccessible = true scroller.set(this, OwnScroller(context, millis)) } catch (e: Exception) { e.printStackTrace() } } //Добавляем интерполятор для замедления открытия фрагмента DecelerateInterpolator() inner class OwnScroller(context: Context, durationScroll: Int) : Scroller(context, DecelerateInterpolator(1.5f)) { private var durationScrollMillis = 1 init { this.durationScrollMillis = durationScroll } override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) { super.startScroll(startX, startY, dx, dy, durationScrollMillis) } } }
Теперь у нас есть наш Навигатор, который мы используем в качестве контейнера для всех фрагментов в нашем Активити:
<info.yamm.project2.navigator.NavigatorViewPager xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/navigator_view_pager" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/black" android:fitsSystemWindows="false" tools:context=".activities.MainActivity"/>
Бэкграунд ставим черный. Это нужно для имитации тени на закрываемом фрагменте. дальше будет понятней.
Теперь нам нужен адаптер, в который мы будем помещать фрагменты:
class NavigatorAdapter(val fragmentManager: FragmentManager) : FragmentStatePagerAdapter(fragmentManager) { //Используем ArrayList для создания динамического стэка фрагментов private val listOfFragments: ArrayList<BaseFragment> = ArrayList() //Добавляем фрагмент fun addFragment(fragment: BaseFragment) { listOfFragments.add(fragment) notifyDataSetChanged() } // Удаляем фрагмент fun removeLastFragment() { listOfFragments.removeAt(listOfFragments.size - 1) notifyDataSetChanged() } // Получаем размер стэка фрагментов fun getFragmentsCount(): Int { return listOfFragments.size } override fun getItemPosition(`object`: Any): Int { val index = listOfFragments.indexOf(`object`) // Используем для предотвращения дублирования фрагментов в стеке return if (index == -1) PagerAdapter.POSITION_NONE else index } override fun getItem(position: Int): Fragment? { return listOfFragments[position] } override fun getCount(): Int { return listOfFragments.size } override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { super.destroyItem(container, position, `object`) } }
Сразу создадим Трансформер для нашего Навигатора:
class NavigatorPageTransformer : ViewPager.PageTransformer { override fun transformPage(view: View, position: Float) { // PageTransformer используется для анимации между переходами экранов view.apply { val pageWidth = width when { // Все экраны в стеке справа от текущего position <= -1 -> { // Используем флаг INVISIBLE у всех экранов справа от текущего // для оптимизации рендеринга visibility = View.INVISIBLE } // Экран, который появляется справа от текущего при открытии нового фрагмента position > 0 && position <= 1 -> { alpha = 1f visibility = View.VISIBLE translationX = 0f } // Анимация ухода текущего фрагмента влево при открытии нового // (со смещением и изменением прозрачности, помните черный бэкграунд у NavigatorViewPager?) position <= 0 -> { alpha = 1.0F - Math.abs(position) / 2 translationX = -pageWidth * position / 1.3F visibility = View.VISIBLE } //Все врагменты слева от текущего, убираем их из отрисовки else -> { visibility = View.INVISIBLE } } } } }
Теперь — самое интересное! Прописываем необходимые действия по открытию фрагментов в нашем Активити:
class MainActivity : BaseActivity() { private lateinit var navigatorAdapter: NavigatorAdapter private lateinit var navigatorViewPager: NavigatorViewPager private lateinit var mainFragment: MainFragment override fun onCreate(savedInstanceState: Bundle?) { setTheme(info.yamm.project2.R.style.AppTheme) // Инициализируем навигатор navigatorViewPager = findViewById<NavigatorViewPager>(info.yamm.project2.R.id.navigator_view_pager) // Наш основной экран(я в нем использую BottomNavigationView с четырьмя вкладками) mainFragment = MainFragment() // Адаптер navigatorAdapter = NavigatorAdapter(supportFragmentManager) // И сразу добавляем наш первый экран addFragment(mainFragment) // Присоединяем адаптер к навигатору navigatorViewPager.adapter = navigatorAdapter // Хардкодим число одновременно открытых фрагментов // поясню: мы используем FragmentStatePagerAdapter, который уничтожает // фрагменты дальше второго в стеке. // FragmentPagerAdapter нам не подходит, потому что при закрытии фрагмента нам нужно именно // уничтожение, чтобы избежать дублирования фрагмента. // А, поскольку мы используем флаг INVISIBLE в PageTransformer // для ушедших в стек фрагментов, фпс не проседает даже при большом количестве // одновременно открытых фрагментов. Рекомендую поэкспериментировать с настройками, // и подобрать лучший вариант для вас navigatorViewPager.offscreenPageLimit = 30 var canRemoveFragment: Boolean = false // При помощи этой переменной определяем направление движения экрана var sumPositionAndPositionOffset = 0.0f // Устанавливаем слушатель navigatorViewPager.addOnPageChangeListener(object : OnPageChangeListener { //Удаляем фрагмент из стэка только тогда, когда движение полностью завершено override fun onPageScrollStateChanged(state: Int) { if (state == 0 && canRemoveFragment) { while ((navigatorAdapter.getFragmentsCount() - 1) > navigatorViewPager.currentItem) { navigatorAdapter.removeLastFragment() } } } // Определяем направление движения экрана и позволяем // удалить фрагмент только если он движется вправо override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { canRemoveFragment = position + positionOffset < sumPositionAndPositionOffset sumPositionAndPositionOffset = position + positionOffset } override fun onPageSelected(position: Int) { } }) } // Используем этот метод из любого фрагмента для добавления нового фрагмента в стек fun addFragment(fragment: BaseFragment) { navigatorAdapter.addFragment(fragment) navigatorViewPager.currentItem = navigatorViewPager.currentItem + 1 } // Переопределяем нажатие кнопки "назад" override fun onBackPressed() { if (navigatorAdapter.getFragmentsCount() > 1) { navigatorViewPager.setCurrentItem(navigatorViewPager.currentItem - 1, true) } else { finish() } } }
Вот, собственно, и всё. Теперь на любом фрагменте вызываем метод из Активити:
(activity as MainActivity).addFragment(ConversationFragment())
А при свайпе вправо он сам удалится из стека при помощи нашего слушателя OnPageChangeListener.
Посмотреть как это все работает на реальном примере можно здесь.
UPD 03.03.2020: Я наконец-то написал библиотеку, основанную на этой статье.
