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

от автора

Когда мы работаем с коллекциями и их отображением, перед многими из нас часто
встает выбор между 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
  • не добавляли новые зависимости в проект
  • реализовали достаточно сложную анимационную последовательность на основе
    всего двух новых абстрактных классов.

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

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

ссылка на оригинал статьи https://habr.com/ru/company/homecredit/blog/489956/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *