Превращаем ViewPager в менеджер фрагментов с анимацией в стиле iOS

в 15:01, , рубрики: fragments, kotlin, tutorial, viewpager, Разработка под android

Многие разработчики под Андроид сталкивались с проблемой реализации анимаций и переходов при открытии новых фрагментов. Нам предлагается использовать либо добавление фрагментов в контейнер, наслаивая их друг на друга, либо реплэйс (замена одного фрагента на другой). У реплэйса есть четыре вида анимаций:

Вживую всё это выглядит примерно так:
    .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()

image

С реплэйсами проблема заключается в том, что нам не удастся использовать анимации наслоения одного фрагмента на другой по типу iOS — анимированное наслоение не работает, один фрагмент просто замещает другой.

Добавление фрагментов в стек (add) позволяет использовать анимации только к открываемому фрагменту, задний будет неподвижен.

И все это, конечно, сопровождается плохим рендерингом и выбитыми кадрами.

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

Полтора года назад телеграм представил Telegram x (тестовая версия своего клиента). Они решили эту проблему так:

image

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

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

image

Итак, создаем класс 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.

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

Автор: pashashik

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js