Анимируем RecyclerView легко без перехода на ViewPager2

  • Tutorial


Когда мы работаем с коллекциями и их отображением, перед многими из нас часто
встает выбор между ViewPager (теперь ещё и ViewPager2) и RecyclerView. Эти
компоненты похожи друг на друга по области применения, но серьезно отличаются
интерфейсом и реализацией. Начиная с support library 24.2.0 границы между
данными компонентами стали ещё более размытыми, т.к. появился вспомогательный
класс SnapHelper для автоматического доведения сhildView до
определенного положения на экране, и без устаревшего ViewPager стало проще
обходиться. С недавним релизом ViewPager2, казалось бы, о старом ViewPager и о
практиках его имитации вообще можно забыть (ViewPager2 — это по сути
RecyclerView с дополнительными вспомогательными классами, он позволяет
практически идентично повторить поведение ViewPager и сохраняет совместимость со
старым api).


Так ли это на самом деле? Лично для меня всё оказалось не так просто. Во-первых,
в классическом RecyclerView отсутствует интерфейс PageTransformer для
анимирования сhildView в зависимости от позиции (далее по тексту используется
понятие «позиционная анимация»). Во-вторых, неприятными сюрпризами долгожданного
ViewPager2 оказались модификатор класса final, который ставит крест на
переопределении метода onInterceptTouchEvent (компонент мало пригоден для
вложения горизонтальных списков в вертикальные), и приватность поля
recyclerView.


Итак, столкнувшись в очередной раз с трудностями позиционной анимации при
отображении коллекций с помощью RecyclerView и поковырявшись в ViewPager2 и
MotionLayout, я подумал, что позаимствовать принцип работы
ViewPager.PageTransformer для классической реализации RecyclerView а-ля
ViewPager2 не самая плохая идея.


Задача, в контексте которой это затевалось, была достаточно необычной:


  • сделать компонент для отображения коллекции в горизонтальном и вертикальном
    представлении
  • горизонтальный список должен повторять поведение ViewPager и пролистываться
    со sticky-эффектом
  • при движении нижней “шторки” (BottomSheetBehavior) должна происходить
    анимированная трансформация ориентации списка — выпадение элементов лесенкой
  • должна быть возможность выбора элемента из вертикального списка с
    анимированным сдвигом остальных элементов влево и последующим превращением
    вертикального списка в горизонтальный.
    Запутались? Вот вам пример такого горизонтально-вертикального списка в
    интернет-банкинг приложении "Мой кредит" Банка Хоум Кредит:


1. Делаем компонент для анимированного списка


Сам компонент было решено спроектировать как наследника ConstraintLayout с двумя
recyclerView внутри. Прогресс анимации берется из BottomSheetCallback нашего
BottomSheetBehavior:


class DinosaursActivity : AppCompatActivity(), SelectorTransformListener, ItemChangeListener {

    private lateinit var bottomSheetBehavior: BottomSheetBehavior<FrameLayout>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setupBottomSheetBehavior()
    }

    private fun setupBottomSheetBehavior() {
        bottomSheetBehavior = BottomSheetBehavior.from(layoutBottomSheet).apply {
            state = BottomSheetBehavior.STATE_EXPANDED
            setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
                override fun onStateChanged(bottomSheet: View, newState: Int) {}

                override fun onSlide(bottomSheet: View, offset: Float) {
                  //передаем прогресс движения шторки в наш компонент
                    selector.transformation(offset)
                }
            })
        }
    }
}

У компонента есть метод transformation, принимающий прогресс “превращения” как
аргумент.


class AnimatedSelector(context: Context, attrs: AttributeSet? = null) :
    ConstraintLayout(context, attrs), SnapListener {

    constructor(context: Context) : this(context, null)

    init {
        LayoutInflater.from(context).inflate(R.layout.layout_animated_selector, this, true)
        horizontalAdapter = InfinityAdapter()
        verticalAdapter = DefaultAdapter()
        horizontalRecycler.layoutManager =
            LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
        verticalRecycler.layoutManager =
            LinearLayoutManager(context, RecyclerView.VERTICAL, false)
        horizontalRecycler.adapter = horizontalAdapter
        verticalRecycler.adapter = verticalAdapter
        }

    fun transformation(progress: Float) {
      //делим весь прогресс движения шторки на две части для последовательного управления анимацией
        val verticalProgress = if (progress < 0.6f) progress / 0.6f else 1f
        val horizontalProgress = 1f - (if (progress > 0.6f) (progress - 0.6f) / 0.4f else 0f)
      //здесь мы будем передавать прогресс в наши будущие прогресс-аниматоры
      }     
}

2. Решаем проблему позиционного анимирования


Реализацию анимации я начал с интерфейса для позиционной анимации — аналога
ViewPager.PageTransformer.


Идея была такой — есть абстрактный класс ItemViewTransformer с абстрактным
методом transformItemView, повторяющим сигнатуру метода transformPage интерфейса
ViewPager.PageTransformer:


abstract class ItemViewTransformer {

   fun attachToRecycler(recycler: RecyclerView) {}

   private fun updatePositions() {}

   abstract fun transformItemView(view: View, position: Float)
}

В attachToRecycler происходит инициализация свойства recyclerView и слушателей
RecyclerView.AdapterDataObserver и RecyclerView.OnScrollListener:


    fun attachToRecycler(recycler: RecyclerView) {
        check(recycler.layoutManager is LinearLayoutManager) { "Incorrect LayoutManager Type" }
        this.layoutManager = recycler.layoutManager as LinearLayoutManager
        this.recyclerView = recycler
        this.attachedAdapter = recycler.adapter as RecyclerView.Adapter<*>

        dataObserver = object : RecyclerView.AdapterDataObserver() {
            override fun onChanged() {
                updatePositions()
            }

            override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
                onChanged()
            }

            override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
                onChanged()
            }

            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
                onChanged()
            }

            override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
                onChanged()
            }

            override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
                onChanged()
            }
        }
        attachedAdapter.registerAdapterDataObserver(dataObserver)

        scrollListener = object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            }

            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                updatePositions()
            }
        }

        recyclerView.addOnScrollListener(scrollListener)
    }

После вызова updatePosition происходят вызовы метода transformItemView для
каждого видимого childView с передачей в аргументы текущей позиции относительно
левого края (такой расчет позиции справедлив только для childViews одинаковой
ширины/высоты с учетом отступов):


    private fun updatePositions() {
        val childCount = layoutManager.childCount
        for (i in 0 until childCount) {
            val view = layoutManager.getChildAt(i)
            view?.let {
                val position: Float = if (layoutManager.orientation == RecyclerView.HORIZONTAL) {
                    (view.left - currentFrameLeft) / (view.measuredWidth + view.marginLeft + view.marginRight)
                } else {
                    (view.top - currentFrameLeft) / (view.measuredHeight + view.marginTop + view.marginBottom)
                }
                transformItemView(view, position)
            }
        }
    }

Начиная с этого момента, вы можете взять, например, свой старый добрый
pageTransformer, на который вы когда-то потратили так много времени, и
переиспользовать его код для recyclerView.


3. Sticky-эффект горизонтального списка


В примере выше sticky-эффект реализован как раз с помощью этого абстрактного
класса. В имплементации метода размер и положение описываем простейшими
кусочными функциями:


class BouncingTransformer(currentItemViewOffset: Int) : ItemViewTransformer(currentItemViewOffset) {
    override fun transformItemView(view: View, position: Float) {
        when {
            position > -1f && position < 0 -> {
                view.scaleX = 1f
                view.scaleY = 1f
                view.translationX = 0f
            }
            position >= 0f && position < 0.5f -> {
                view.scaleY = 1f - 0.2f * position / 0.5f
                view.scaleX = 1f - 0.2f * position / 0.5f
                view.translationX = view.width * 0.4f - view.width * 0.4f * (0.5f - position) / 0.5f
            }
            position >= 0.5f && position < 1f -> {
                view.scaleY = 0.8f
                view.scaleX = 0.8f
                view.translationX = view.width * (0.9f - position)
            }
            position >= 1f && position < 2f -> {
                view.scaleY = 0.8f
                view.scaleX = 0.8f
                view.translationX = -view.width * 0.1f
            }
        }
    }
}

Аттачим наш BouncingTransformer сразу после инициализации адаптера и
recyclerView:


    LayoutInflater.from(context).inflate(R.layout.layout_animated_selector, this, true)
        horizontalAdapter = InfinityAdapter()
        horizontalRecycler.layoutManager = ScrollDisablingLayoutManager(context, RecyclerView.HORIZONTAL, false)
        horizontalRecycler.adapter = horizontalAdapter
        bouncingTransformer = BouncingTransformer(-cardShift)
                .apply { attachToRecycler(horizontalRecycler) }
    }

После компиляции мы получим пролистывание списка со sticky-эффектом:



В общем-то, ничто не мешает анимировать список, например, как колоду карт, и не
придется прибегать даже к помощи ItemDecoration.


4. Делаем универсальный прогресс-аниматор


Следующим этапом был реализован аналогичный интерфейс для выполнения
value-анимации с абстрактным методом onUpdate:


abstract class ItemViewAnimatorUpdater {

    fun attachToRecycler(recycler: RecyclerView): ItemViewAnimatorUpdater {}

    fun update(progress: Float = 0f) {}

    abstract fun onUpdate(view: View, visiblePosition: Int, adapterPosition: Int, progress: Float)
}

Здесь attachToRecycler инициализирует лишь свойство recyclerView:


    fun attachToRecycler(recycler: RecyclerView): ItemViewAnimatorUpdater {
        check(recycler.layoutManager is LinearLayoutManager) { "Incorrect LayoutManager Type" }
        this.layoutManager = recycler.layoutManager as LinearLayoutManager
        this.recyclerView = recycler
        return this
    }

Методом update задается прогресс анимации для видимой позиции или позиции
адаптера:


    fun update(progress: Float = 0f) {
        val childCount = layoutManager.childCount
        for (i in 0 until childCount) {
            val view = layoutManager.getChildAt(i)
            view?.let {
                val visiblePosition = if (layoutManager.orientation == RecyclerView.HORIZONTAL) {
                    view.right.toFloat() / (view.measuredWidth + view.marginLeft + view.marginRight)
                } else {
                    view.bottom.toFloat() / (view.measuredHeight + view.marginTop + view.marginBottom)
                }
                val adapterPosition = recyclerView.getChildAdapterPosition(view)
                onUpdate(view, ceil(visiblePosition).toInt(), adapterPosition, progress)
            }
        }
    }

5. Трансформация списка


Теперь мы можем описать трансформацию “лесенкой” вертикального recyclerView в
имплементации метода onUpdate:


class EmergingUpdater(private val horizontalOffset: Int, private val verticalOffset: Int) : ItemViewAnimatorUpdater() {
    override fun onUpdate(view: View, visiblePosition: Int, adapterPosition: Int, progress: Float) {
        view.translationX = progress * (visiblePosition) * horizontalOffset
        if (visiblePosition > 1) {
            view.translationY = -verticalOffset * visiblePosition + (1 - progress) * visiblePosition * verticalOffset
        }
    }
}

Аттачим точно так же, как предыдущий класс, прогресс берем из
BottomSheetCallback. После компиляции увидим следующее:



6. Эффект разбегания для горизонтального списка


Этой трансформации должен предшествовать эффект “разбегания” вьюшек в
горизонтальном списке:


class ScatterUpdater(private val leftOffset: Int, private val rightOffset: Int) : ItemViewAnimatorUpdater() {
    override fun onUpdate(view: View, visiblePosition: Int, adapterPosition: Int, progress: Float) {
        if (visiblePosition == 1) {
            view.translationX = -progress * leftOffset
        } else {
            view.translationX = progress * rightOffset - view.width * 0.1f
        }
    }
}

Получаем желаемое поведение:



7. Анимация выбора элемента в вертикальном списке


При выборе элемента в вертикальном списке должна произойти анимированная
трансформация в горизонтальный список с пресетом в нем выбранного ранее
childView. Для достижения нужного эффекта удаляем остальные элементы из
вертикального адаптера. Анимацию удаления элементов реализуем на свой вкус, либо
нашим ItemViewAnimatorUpdater:


class SlideLeftWithExcludeUpdater(private val excludedPosition: Int, private val leftOffset: Int) : ItemViewAnimatorUpdater() {
    override fun onUpdate(view: View, visiblePosition: Int, adapterPosition: Int, progress: Float) {
        if (adapterPosition != excludedPosition) {
            view.translationX = -progress * leftOffset
        }
    }
}

Либо классически с помощью ItemAnimator:


class RemoveItemAnimator(private val leftOffset: Int, private val duration: Long) : DefaultItemAnimator() {
    override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean {
        val animator = ValueAnimator.ofFloat(0f, 1f)
        animator.addUpdateListener { valueAnimator ->
            holder.itemView.translationX = -(valueAnimator.animatedValue as Float) * leftOffset
        }
        animator.addListener(object : Animator.AnimatorListener {
            override fun onAnimationRepeat(p0: Animator?) {
            }

            override fun onAnimationEnd(p0: Animator?) {
                animator.removeAllListeners()
            }

            override fun onAnimationCancel(p0: Animator?) {
            }

            override fun onAnimationStart(p0: Animator?) {
            }
        })
        animator.duration = duration
        animator.start()
        return false
    }
}

Получаем похожее поведение:



Завершаем трансформацию списка выездом правого видимого элемента горизонтального
списка:


class SlideRightUpdater(private val rightOffset: Int) : ItemViewAnimatorUpdater() {
    override fun onUpdate(view: View, visiblePosition: Int, adapterPosition: Int, progress: Float) {
        if (visiblePosition == 1) {
            view.translationX = 0f
        } else {
            view.translationX = progress * rightOffset - view.width * 0.1f
        }
    }
}

После компиляции видим:



8. Результаты


По большому счету на этом реализация анимации завершена. Что в итоге мы
получили:


  • отказались от использования ViewPager, что, как минимум, обещает лучшую
    консистентность адаптеров, а значит и более удобный data building
  • получили преимущества ViewPager.PageTransformer — удобное позиционное
    анимирование
  • сохранили преимущества RecyclerView — более экономное потребление ресурсов,
    гибкую систему нотификации изменений адаптера, ItemAtimator, ItemDecoration
  • не добавляли новые зависимости в проект
  • реализовали достаточно сложную анимационную последовательность на основе
    всего двух новых абстрактных классов.

Надеюсь, эта статья окажется для вас полезной. Удачи!



Исходники проекта

Комментарии 4

    0
    Сам не пользовался, но вот как раз интересно не помог ли бы здесь будущий MotionLayout?
      +1
      Здравствуйте! Использовать MotionLayout для решения таких задач можно. Для этого вам понадобится:
      1. Описать в MotionScene с помощью ConstrantSet размеры, положение и форму ваших childView для каждого задуманного крайнего состояния анимации (или сделать это программно)
      2. Описать в MotionScene с помощью Transition последовательность переходов между ConstrantSet и области для совершения свайпов.
      3.Имплементировать интерфейс MotionLayout.TransitionListener для программного добавления Transition (для зацикливания анимации пролистывания) и для вызова событий динамического биндинга ChildView.
      4.Реализовать удобную для вас модель DataBinding, например, с помощью AndroidX LiveData или Rx-Java.
      Вот что получилось у меня:
      image
      Предпочел описанный в статье подход, потому что использование MotionLayout в качестве замены специального ViewGroup для работы с коллекциями, предполагает разработку полноценного адаптера и собственного ViewGroup. В общем, задача очень трудоемкая, если решение должно получиться качественным.
      Спасибо за интересный вопрос!
        0
        Здорово, спасибо, что попробовали. А в тот же проект на отдельную ветку можете добавить пример и с MotionLayout?
          +1
          Когда пробовал работать с MotionLayout и философией ConstraintLayout2.0, я слишком поздно понял, что не стоит увлекаться описанием ConstraintSet для такой сложной анимации в xml. В общем, у меня получился настоящий god-xml, который было очень сложно поддерживать. Начал делать программную динамическую верстку и улучшать DataBinding, и вот тогда осознал конечную стоимость качественного решения на MotionLayout. В общем, даже не коммитил этот код. Видеозаписи, которую я вставил в ответ, уже несколько месяцев.
          Вообще, фреймворк отличный. Думаю он быстро станет фаворитом в создании сложной анимации. Если хотите пощупать MotionLayout, но не знаете с чего начать, очень вам рекомендую статью на harb «MotionLayout: анимации лучше, кода — меньше». Она полностью базируется на том подходе, который я описал выше

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое