Многие разработчики под Андроид сталкивались с проблемой реализации анимаций и переходов при открытии новых фрагментов. Нам предлагается использовать либо добавление фрагментов в контейнер, наслаивая их друг на друга, либо реплэйс (замена одного фрагента на другой). У реплэйса есть четыре вида анимаций:
.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: Я наконец-то написал библиотеку, основанную на этой статье.