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