Всем привет! Меня зовут Денис Загуменнов, я из команды ленты и рекомендаций ВКонтакте. Мы занимаемся новостной лентой, стеной, разделом «Рекомендации», записями, комментариями, VK Donut, подкастами и социальным графом. То есть всем, что касается потребления контента и взаимодействия с ним.

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

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

Когда пользователь впервые переходит к этому экрану, карточки анимированно перемещаются влево и вправо. Так они объясняют, как и в какую сторону их надо свайпать, чтобы добавить кого-то в друзья или пропустить заявку. 

Карточки можно перемещать в любом направлении. Свайп вправо принимает заявку, а влево — пропускает её. Если просматриваем рекомендации, смахивание влево будет скрывать их, а вправо — отправлять от нас заявку в друзья. Или можно не свайпать, а нажимать кнопки внизу: карточки сами улетят в нужную сторону.

Заявки и рекомендации разделены специальной карточкой, чтобы их случайно не перепутать. А когда пользователь впервые отправляет заявку в друзья, разбирая рекомендации, мы выводим предупреждение — чтобы он убедился, что добавляет именно того человека, которого собирался.

В этой статье мы покажем, как устроены онбординг, механизм размещения и перемещения карточек, подгрузка данных, предзагрузка изображений. А ещё разберём оптимизации, которые можно сделать для плавного перемещения карточек, и другие варианты реализации их подвижного набора.

Структура статьи

  1. Представление и размещение карточек на экране.

    1.1. Получение вьюхи в RecyclerView.

    1.2. Кастомный LayoutManager.

    1.3. Кастомный SnapHelper.

    1.4. Кастомный SmoothScroller.

    1.5. ScrollVectorProvider.

    1.6. Анимации добавления, удаления и перемещения вьюх.

  2. Перемещение карточек.

    2.1. Онбординг пользователя.

    2.2. Двухэтапная анимация карточки.

  3. Подгрузка данных.

  4. Предзагрузка изображений.

  5. Оптимизации.

  6. Другие возможные варианты решения задачи.

  7. Вывод.

1. Представление и размещение карточек на экране

Для реализации механики мы выбрали RecyclerView с собственным LayoutManager. RecyclerView переиспользует вьюхи и позволяет изменить вид представления списка элементов, просто заменив LayoutManager на любой другой. Кроме того, с таким подходом мы можем повторно использовать наши средства пагинации данных и предзагрузки изображений. Механизм RecyclerView устроен гибко: он состоит из нескольких компонентов, у каждой из которых своя зона ответственности.

LayoutManager измеряет размеры и размещение элементов. SnapHelper отвечает за то, чтобы скролл «примагничивался» к определённому элементу списка. SmoothScroller программно позволяет плавно прокрутить список, а ItemAnimator анимирует вьюхи, когда добавляются и удаляются элементы из списка данных адаптера.

1.1. Получение вьюхи в RecyclerView

Прежде чем описывать получение вьюхи, расскажу об основных используемых компонентах:

  1. RecycledViewPool — это основное хранилище вьюхолдеров, предназначенных для переиспользования.

    Вьюхолдеры с вьюхами попадают в RecycledViewPool в последний момент, для длительного хранения. Можно использовать один вьюпул на несколько RecyclerView в приложении. Это полезно, когда есть несколько экранов с одними и теми же классами вьюхолдеров.

    Также в RecycledViewPool хранятся средние времена на создание и привязку вьюхолдеров каждого типа. Эта информация нужна, чтобы правильно оценивать, когда можно создать или привязать вьюхолдер — в оставшееся свободным время от кадра до кадра.

  2. Скрап — это список вьюхолдеров с вьюхами, которые находятся в RecyclerView и подходят для переиспользования и перепривязки данных. То есть:

    • вьюхолдер может быть валидным — то есть идентификаторам position и itemId внутри него можно доверять и они соответствуют itemViewType. Этот вьюхолдер не нужно привязывать к другим данным;

    • или элемент, соответствующий ему, был удалён из адаптера;

    • или адаптер имеет stableId. 

    Заскрапленная вьюха — это та, что всё ещё находится в RecyclerView, но была помечена для удаления или переиспользования. То есть она не удаляется из RecyclerView. И получается, что помещение в скрап или получение из него вьюх — это более лёгкие действия по сравнению с операциями вьюпула.

  3. Класс Recycler отвечает за управление вьюхами, заскрапленными или удалёнными из RecyclerView для переиспользования.

  4. ChildHelper это вспомогательный класс, который абстрагирует работу с вьюхами RecyclerView. В этом классе есть два набора методов. Первые взаимодействуют только с нескрытыми вьюхами и копируют названия методов ViewGroup: getChildAt, getChildCount и т. д. А второй набор работает со всеми вьюхами, включая скрытые — в нём следует использовать методы getUnfilteredChildCount или getUnfilteredChildAt.

    Внутри ChildHelper есть список скрытых вьюх. Они попадают в него, когда анимируются: например, при исчезновении или замене на другую. Все вьюхи из этого списка отрисовываются как обычные из списка RecyclerView.

  5. Кеш вьюхолдеров в Recycler. У этого кеша есть максимальный размер, по умолчанию он равен 2. Если вьюхолдер, который отправляется в переиспользование, подходит для кеширования (то есть он валидный, не удалён, не требует обновления, имеет позицию в адаптере), то он помещается в кеш. Если кеш полон, то из начала списка удаляется вьюхолдер и перемещается в RecycledViewPool. Если же вьюхолдер не попал в этот кеш, то он перемещается в RecycledViewPool. Можно влиять на максимальный размер кеша через метод Recycler.setViewCacheSize. Но итоговый максимальный размер будет равен количеству, указанному в setViewCacheSize, плюс максимальное число вьюх в префетче.

  6. RecyclerView предоставляет дополнительный уровень кеширования вьюх, и разработчик может полностью контролировать его. Этот уровень должен наследоваться от класса ViewCacheExtension.

Если кратко, скрапы и кеши используются, когда вьюхи находятся или в RecyclerView, или в процессе анимаций исчезновения, добавления или перемещения, или при работе с совсем недавними вьюхами. А RecycledViewPool — в самый последний момент, для длительного хранения.

Получение вьюхи в LayoutManager происходит через Recycler посредством вызова recycler.getViewForPosition(position). Внутри этого метода Recycler попытается сначала найти необходимую вьюху в скрапах и кешах (об этом расскажу ниже), а если не найдёт, то создаст новую. Также в методе getViewForPosition происходит привязка необходимых данных из адаптера, но только если:

  • к вьюхолдеру ещё не привязаны данные,

  • или вьюхолдер требует их обновления,

  • или он помечен невалидным.

В методе getViewForPosition(position) Recycler ищет вьюху в таком порядке: 

  1. В скрапе изменяемых в данный момент вьюх — ищет вьюхолдер по позиции в адаптере (последовательно проходит по списку скрапа). А затем, если не найдёт, то по stableId (при условии, что у адаптера был установлен флаг setHasStableId(true)).

  2. В скрапе присоединённых вьюх — поиск вьюхолдера так же идёт по позиции в адаптере. 

  3. В списке скрытых вьюх (напомню: они попадают сюда, когда анимируются) в ChildHelper, поиск по позиции в адаптере.

  4. В кеше вьюхолдеров Recycler, поиск по позиции в адаптере.

  5. В скрапе присоединённых вьюхолдеров — поиск по stableId (если был установлен флаг setHasStableId(true)).

  6. В кеше вьюхолдеров Recycler — поиск по stableId (с тем же условием).

  7. В кастомном кеше ViewCacheExtension — поиск по позиции в адаптере и типу (если кеш был установлен разработчиком).

  8. В пуле RecycledViewPool — поиск по типу.

1.2. Кастомный LayoutManager

Механика работы метода LayoutManager имеет много общего с onLayout и onMeasure у вьюгруппы. Но у него своя специфика: в его логику заложено, что ячейки можно переиспользовать и прокручивать горизонтально или вертикально внутри списка. LayoutManager измеряет и размещает вьюхи в RecyclerView, а также определяет логику того, как и когда переиспользовать те, что больше не видны пользователю. С помощью LayoutManager можно сделать списки любого вида.

Размещение карточек происходит в колбэке:

fun onLayoutChildren(recycler: Recycler, state: State)

Вызывая этот колбэк, RecyclerView говорит нам о том, что сейчас надо добавить и расположить все вьюхи, а в аргументах передаёт состояние State и Recycler. State содержит информацию о текущем состоянии RecyclerView:

  1. Текущий шаг лейаута (STEP_START — его начало, STEP_LAYOUT — сам процесс лейаута, STEP_ANIMATIONS — анимирование).

  2. Целевая позиция прокрутки (для SmoothScroller).

  3. Количество элементов в адаптере при предыдущем и текущем лейаутах.

  4. Количество элементов, которые были удалены из адаптера после предыдущего лейаута и не были размещены в RecyclerView.

  5. Позицию и id элемента, на который указывает фокус. Если это дочерняя вьюха элемента, то хранится её id. Эти данные сохраняются перед лейаутом. После его завершения, если ранее сфокусированная вьюха была заменена на другую для того же элемента, фокус автоматически перемещается на новую.

  6. Прочие служебные флаги состояния и вспомогательные флаги для анимаций.

RecyclerView всегда передаёт во все колбэки один и тот же объект State. В нём можно хранить данные по ключу. Это хранилище реализовано через SparseArray<Object>. С его помощью можно передавать объекты между компонентами, не наблюдая за жизненными циклами.

Recycler собирает вьюхи в скрап и позволяет получить вьюху для указанной позиции. 

В методе onLayoutChildren мы размещаем три верхних карточки стека на экране, учитывая их перемещения по координатам. Там же отправляем события о перемещении карточки пользователем и, если она вышла за определённые пределы, — выполняем переход к следующей. Поясню, почему три карточки. Первую мы перемещаем жестами. Вторую видим под первой, и она постепенно заменяет её при перемещении. А третья становится видна, когда первая карточка уже за пределами экрана и вторая встаёт на её место.

override fun onLayoutChildren(recycler: Recycler, state: State) {
   // Размещаем все карточки в layout(recycler)
   layout(recycler)
   // Проверяем, изменился ли датасет с последнего вызова onLayoutChildren
   if (!state.didStructureChange()) return
   // Если изменился, то берём верхнюю карточку
   val topView = getTopView() ?: return
   // И отправляем колбэк о том, что появилась карточка на экране
   callback?.onCardAppeared(topView, this.state.topPosition)
}

В функции layout(recycler) размещаем три верхних карточки на экране:

private fun layout(recycler: RecyclerView.Recycler) {
   state.width = width
   state.height = height
   if (state.isSwipeCompleted()) {
       onSwipeCompleted(recycler)
   }
   // Открепляем все вьюхи и помещаем в скрап
   detachAndScrapAttachedViews(recycler)
   var i = state.topPosition
   while (i < state.topPosition + animationProvider.visibleCards && i < itemCount) {
       val child = recycler.getViewForPosition(i)
       addView(child, 0)
       val stableId = adapter?.getItemId(i) ?: 0L
       if (needMeasure(child, stableId)) {
           // Измеряем карточку
           measureCardView(child, stableId)
       }
       // Устанавливаем новое состояние
       animationProvider.update(i, child, state)
       i++
   }
   if (state.status.isDragging) {
       callback?.onCardDragging(state.direction, animationProvider.getRatio(state))
   }
}

private fun measureCardView(child: View, stableId: Long) {
  measureChildWithMargins(child, 0, 0)
  val parentTop = paddingTop
  val parentLeft = paddingLeft
  val parentRight = state.width - paddingRight
  val parentBottom = state.height - paddingBottom
  // Размещаем карточку
  val childPaddingHorizontal = (parentRight - paddingLeft - child.measuredWidth) / 2
  val childPaddingVertical = (parentBottom - paddingTop - child.measuredHeight) / 2
  layoutDecoratedWithMargins(child,
             parentLeft + childPaddingHorizontal,
             parentTop + childPaddingVertical,
             parentRight - childPaddingHorizontal,
             parentBottom - childPaddingVertical)
  addMeasuredViewHolder(stableId)
}

В функции onSwipeCompleted производим действия, если закончился жест свайпа — то есть карточка улетела за пределы экрана:

private fun onSwipeCompleted(recycler: Recycler) {
   // Удаляем и отправляем вьюху в переиспользование
   getTopView()?.let {
       removeAndRecycleView(it, recycler)
   }
   // Направление свайпа
   val direction = state.direction
   val directionHorizontal = state.directionHorizontal
   // Позиция свайпнутой карточки
   val position = state.topPosition
   val prevStatus = state.status
   // Переходим к следующему состоянию
   state.set(state.status.toAnimatedStatus())
   state.topPosition++
   state.x = 0
   state.y = 0
   if (state.topPosition == state.targetPosition) {
       state.targetPosition = RecyclerView.NO_POSITION
   }
   // Отправляем колбэки на следующий цикл лупера
   handler.post {
       // Определяем, пальцем или кнопкой было произведено действие
       val isManual = prevStatus == Status.ManualSwipeAnimating || prevStatus == FinishManualSwipeAnimating
       val isButton = prevStatus == Status.ButtonSwipeAnimating
       if (isManual || isButton) {
           // Отправляем колбэк о свайпе карточки
           callback?.onCardSwiped(direction, directionHorizontal,  position, isManual)
       }
       // Отправляем колбэк о появлении новой карточки, если она есть
       getTopView()?.let {
           callback?.onCardAppeared(it, state.topPosition)
       }
   }
}

Очистку сохранённого состояния, которое было необходимо для лейаута вьюх и больше не понадобится, можно разместить в этом колбэке:

fun onLayoutCompleted(state: State)

Используем его, так как он вызывается только один раз и в последнюю очередь, после лейаута всех вьюх RecyclerView. Лейаут может включать несколько вызовов onLayoutChildren(Recycler, State) из-за анимации или перерасчёта лейаута RecyclerView — это происходит, когда RecyclerView или хотя бы одна её вьюха не имеют точно заданных размеров.

Перемещение карточек по координатам происходит в этих методах:

fun scrollVerticallyBy(dy: Int, recycler: Recycler, state: State): Int
fun scrollHorizontallyBy(dx: Int, recycler: Recycler, state: State): Int

где dx/dy — расстояние, на которое необходимо проскроллить список.

Внутри этих методов происходит смещение верхней карточки на dx/dy пикселей, а в результате возвращается пройденное расстояние. Если достигнут конец набора или карточку нельзя перемещать (она разделяет заявки и рекомендации), то вернётся 0.

В этих методах, как и в onLayoutChildren, мы:

  • размещаем три верхних карточки стека на экране с учётом их перемещений, 

  • вызываем колбэки о перемещении карточки пользователем,

  • если карточка ушла за определённые пределы — выполняем переход к следующей.

Текущее смещение карточки известно из позиции скролла. Перемещение карточки вычисляется по такой логике:

override fun scrollHorizontallyBy(dx: Int, recycler: Recycler, s: State) =
    when (state.status) {
       Status.Idle,
       Status.RewindAnimating,
       Status.ButtonSwipeAnimating,
       Status.FinishManualSwipeAnimating,
       Status.AutomaticRemoveAnimating,
       Status.ManualSwipeAnimating,
       Status.OnBoardingAnimating,
       Status.OnBoardingCanceling -> {
           state.x -= dx
           layout(recycler)
           dx
       }
       Status.Dragging -> {
           // Если можно перемещать указанную карточку (карточка, разделяющая заявки и рекомендации, не должна перемещаться свайпом, только нажатием кнопки «Перейти к рекомендациям») 
           if (this.canDrag(this.topPosition, state.direction)) {
               state.x -= dx
               layout(recycler)
               dx
           } else {
               0
           }
       }
       else -> 0
}

Такая же логика и для скролла по вертикали.

При перемещении и анимации вьюхи размещаются через функцию layout(recycler) — код написан выше.

Чтобы разрешить или запретить перемещение карточек по координатам, используются эти методы:

fun canScrollVertically(): Boolean 
fun canScrollHorizontally(): Boolean

Например, если canScrollHorizontally() вернёт false, то scrollHorizontallyBy() не будет вызван.

Смену состояния прокрутки отслеживаем с помощью колбэка: 

fun onScrollStateChanged(state: Int)

В колбэке выше мы отслеживаем состояние перемещения карточки. При необходимости автоматически продолжаем свайп или фиксируем карточку в определённом положении. Фриз нужен в онбординге и когда показываем диалог о подтверждении действия.

override fun onScrollStateChanged(s: Int) {
   when (s) {
       RecyclerView.SCROLL_STATE_IDLE -> {
           when {
               // При паузе свайпа (когда показываем диалог подтверждения действия) и в онбординге ничего не делаем
               state.isPauseSwipe || state.isSwipeOnBoardingAnimating -> {}
               state.status == Status.OnBoardingCanceling -> {
                   state.set(Status.Idle)
               }
               state.targetPosition == RecyclerView.NO_POSITION || state.topPosition == state.targetPosition -> {
                   state.set(Status.Idle)
                   state.targetPosition = RecyclerView.NO_POSITION
               }
               // topPosition — текущая позиция верхней карточки в списке
               state.topPosition < state.targetPosition -> {
                   // Если не нужно показывать диалог подтверждения, то выполняем переход к следующей карточке
                   // Иначе переходим в состояние паузы
                   if (this.canContinueSwipe(this.topPosition, state)) {
                       val type = when {
                           state.status == Status.AutomaticRemoveAnimating -> {
                              ScrollType.AutomaticRemove
                           }
                           state.directionHorizontal == Direction.Right -> if (state.status == Status.ButtonSwipeAnimating) {
                              ScrollType.ButtonAccept
                           } else {
                              ScrollType.FinishManualAccept
                           }
                           else -> if (state.status == Status.ButtonSwipeAnimating) {
                              ScrollType.ButtonDecline
                           } else {
                              ScrollType.FinishManualDecline
                           }
                       }
                       smoothScrollToNext(state.targetPosition, type)
                   } else {
                       state.pauseSwipe()
                   }
               }
               // В противном случае возвращаемся к предыдущему элементу
               else -> smoothScrollToPrevious(state.targetPosition)
           }
       }
       RecyclerView.SCROLL_STATE_DRAGGING -> {
           state.set(UserDiscoverState.Status.Dragging)
       }
   }
}

Методы smoothScrollToNext и smoothScrollToPrevious выполняют переход к следующей или предыдущей карточке и выглядят так:

private fun smoothScrollToNext(position: Int, type: ScrollType) {
   this.state.targetPosition = position
   val scroller = SmoothScrollerFactory.create(type, this)
   scroller.targetPosition = this.topPosition
   startSmoothScroll(scroller)
}

private fun smoothScrollToPrevious(position: Int) {
   // topPosition — текущая позиция верхней карточки в списке 
   getTopView()?.let {
       callback?.onCardDisappeared(it, this.topPosition)
   }
   this.state.targetPosition = position
   this.state.topPosition--
   val scroller = SmoothScrollerFactory.create(ScrollType.AutomaticRewind, this)
   scroller.targetPosition = this.topPosition
   startSmoothScroll(scroller)
}

Для скролла с анимацией переопределяем этот метод:

fun smoothScrollToPosition(recyclerView: RecyclerView?, s: State?, position: Int)

В нём мы плавно, с анимацией, должны перейти к элементу с определённой позицией. Для этого создаём экземпляр нашего собственного SmoothScrollerэто класс, который определяет, как будет анимироваться скролл. И затем вызываем startSmoothScroll(SmoothScroller).

private fun smoothScrollToPosition(position: Int) {
   // topPosition — текущая позиция верхней карточки в списке
   if (state.topPosition < position) {
       val type = if (state.status == Status.ButtonSwipeAnimating) {
          ScrollType.ButtonAccept
       } else {
          ScrollType.FinishManualAccept
       }
       smoothScrollToNext(position, type)
   } else {
       smoothScrollToPrevious(position)
   }
}

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

Чтобы поддержать скролл к указанной позиции без анимации, переопределяем этот метод:

fun scrollToPosition(position: Int)

Переход к элементу делаем максимально просто: сохраняем целевую позицию в наш LayoutManager и вызываем у него requestLayout(). Произойдёт вызов onLayout у LayoutManager, и все карточки разместятся на экране для этой целевой позиции.

Для добавления вьюхи используется метод addView:

fun addView(child: View)

Это не то же самое, что addView у ViewGroup. В нашем случае у метода другая реализация: он учитывает специфику работы с RecyclerView, позволяет добавить и переиспользовать вьюху, которую мы получили из скрапа.

Чтобы отсоединять вьюхи от RecyclerView, применяются методы detachAndScrapView и detachAndScrapAttachedViews:

fun detachAndScrapView(child: View, recycler: Recycler)

Отсоединяет прикреплённую вьюху и добавляет её в скрап, чтобы можно было повторно привязать и переиспользовать отсоединённую. Если вьюхолдер, в котором находится вьюха, помечен невалидным и в адаптере не определён stableId, то сама вьюха удаляется из RecyclerView, а её вьюхолдер попадает в RecycledViewPool. Иначе вьюха детачится от RecyclerView, и вьюхолдер с ней попадает в скрап присоединённых вьюхолдеров.

fun detachAndScrapAttachedViews(recycler: Recycler)

Этот метод (detachAndScrapAttachedViews) похож на detachAndScrapView, только отсоединяет все вьюхи RecyclerView. Алгоритм тот же.

Наблюдение за изменениями элементов 

Чтобы актуализировать значения переменной с текущей позицией скролла, нам нужно следить за изменениями элементов в коллекции. Для этого используем существующие колбэки LayoutManager:

fun onItemsAdded(recyclerView: RecyclerView, positionStart: Int, itemCount: Int)
fun onItemsChanged(recyclerView: RecyclerView, positionStart: Int, itemCount: Int)
fun onItemsMoved(recyclerView: RecyclerView, positionStart: Int, itemCount: Int)
fun onItemsRemoved(recyclerView: RecyclerView, positionStart: Int, itemCount: Int)
fun onItemsUpdated(recyclerView: RecyclerView, positionStart: Int, itemCount: Int)
fun onItemsUpdated(recyclerView: RecyclerView, positionStart: Int, itemCount: Int, payload: Any)

1.3. Кастомный SnapHelper

SnapHelper сопоставляет с произвольным положением прокрутки какую-либо вьюху — её мы и будем называть целевой. С помощью SnapHelper можно сделать так, чтобы скролл «примагничивался» к определённой точке целевой вьюхи.

SnapHelper обрабатывает fling-жесты, но для правильной работы мы должны или реализовывать в LayoutManager интерфейс ScrollVectorProvider, или переопределить onFling(int, int) и обрабатывать fling-жест вручную. В нашей реализации я выбрал подход, в котором LayoutManager реализовывает интерфейс ScrollVectorProvider (так как возможности более низкоуровневого способа через onFling(int, int) нам не нужны).

Для жеста смахивания карточки учитываются следующие параметры: 

  • скорость перемещения,

  • расстояние, на которое сместилась карточка. 

В методе  findTargetSnapPosition мы получаем и запоминаем скорости перемещения по осям x и y. Координаты смещения карточки известны из текущей позиции скролла, а скорости перемещения мы получили в findTargetSnapPosition. Так что нам достаточно информации в calculateDistanceToFinalSnap, чтобы продолжить, завершить или отменить жест.

fun calculateDistanceToFinalSnap(layoutManager: LayoutManager, targetView: View): IntArray?

Он используется для того, чтобы сообщать RecyclerView расстояние прокрутки до определённой точки целевой вьюхи, когда SnapHelper перехватил fling-жест. В нашей реализации мы всегда будем возвращать нулевое расстояние. Нам не нужно, чтобы SnapHelper скроллил RecyclerView, — мы сами обрабатываем поведение и перемещение карточки.

override fun calculateDistanceToFinalSnap(
       layoutManager: RecyclerView.LayoutManager,
       targetView: View
): IntArray {
   if (layoutManager !is UsersDiscoverLayoutManager) return IntArray(2)
   // topPosition — текущая позиция верхней карточки в списке карточек
  if (layoutManager.findViewByPosition(layoutManager.topPosition) == null) return IntArray(2)
 
   val state = layoutManager.state
 
   // Если сейчас состояние паузы, то ничего не делаем
   if (state.isPauseSwipe) return IntArray(2)
   // Если сейчас онбординг, то ничего не делаем
   if (state.isSwipeOnBoardingAnimating) return IntArray(2)
 
   val x = targetView.translationX.toInt()
   val y = targetView.translationY.toInt()
   // Если смещения нет, то ничего не делаем
   if (x == 0 && y == 0) return IntArray(2)
 
   val horizontal = abs(x) / layoutManager.getWidth().toFloat()
   // Если жест быстрый по оси х или относительное смещение карточки больше, чем layoutManager.swipeThreshold, то 
   if (isFast(velocityX) || layoutManager.swipeThreshold < horizontal) {
   resetVelocity()
 
     if (layoutManager.canContinueSwipe(layoutManager.topPosition, layoutManager.state)) {
       // Определяем тип скролла
       val type = when {
           state.status == Status.AutomaticRemoveAnimating -> ScrollType.AutomaticRemove
           state.directionHorizontal == Direction.Right -> if (state.status == Status.ButtonSwipeAnimating) {
               ScrollType.ButtonAccept
           } else {
               ScrollType.FinishManualAccept
           }
               state.status == Status.ButtonSwipeAnimating -> ScrollType.ButtonDecline
               else -> ScrollType.FinishManualDecline
           }
           // Выполняем завершение жеста автоматическим скроллом
           layoutManager.finishSwipe(type)
      } else {
           // Останавливаем карточку
           layoutManager.pauseSwipe()
      }
   } else {
       // Отменяем жест, возвращаем карточку на место
       layoutManager.cancelManualSwipe()
   }
   // Возвращаем пустой массив
   return IntArray(2)
}

Допустимые сценарии дальнейшего поведения:

  1. Продолжить перемещение карточки в том направлении, куда смахнул пользователь. Карточка продолжит перемещаться, если выполняется одно из условий:

    — достаточно большая скорость жеста в допустимых направлениях. В нашем случае их два: влево и вправо;

    — достаточно большое смещение карточки в допустимых направлениях;

    — карточка не должна замирать на месте после действий пользователя с ней (см. п. 2).

  2. Остановить карточку на том месте, куда переместил её пользователь. После того как он убрал палец от экрана, перемещать карточку не надо. Это необходимо для сценария, когда пользователь первый раз отправляет заявку в друзья. Тогда мы показываем ему подтверждающий диалог, и в этот момент карточка замирает.

  3. Вернуть карточку на начальную позицию, если не выполняются условия в пункте 1 и если пользователь передумал отправлять заявку в друзья (в пункте 2).

Чтобы определить вьюху, к которой будем примагничиваться, переопределяем этот метод:

fun findSnapView(layoutManager: LayoutManager): View?

Он вызывается, когда SnapHelper готов к примагничиванию. Этот метод будет вызываться явно, когда состояние прокрутки становится неактивным после скролла, а также когда SnapHelper готовится к примагничиванию после fling-жеста. Если этот метод возвращает значение null, то SnapHelper не будет примагничиваться.

Так как мы выполняем все перемещения с самой верхней карточкой, то возвращаем ссылку на неё, если она в пределах экрана. Дальше SnapHelper передаст её как targetView в метод calculateDistanceToFinalSnap.

override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
   if (layoutManager !is UsersDiscoverLayoutManager) return null
 
   // layoutManager.topPosition — текущая позиция верхней карточки в списке карточек
   val view = layoutManager.findViewByPosition(layoutManager.topPosition) ?: return null
 
   val x = view.translationX.toInt()
   val y = view.translationY.toInt()
   val width = layoutManager.getWidth()
   val height = layoutManager.getHeight()
   return if (x > width || y > height || x == 0 && y == 0) {
       null
   } else view
}

Чтобы определить позицию, на которую нужно примагничиваться, с учётом скорости жеста, используем этот метод:

fun findTargetSnapPosition(layoutManager: LayoutManager, velocityX: Int, velocityY: Int): Int

где velocityX и velocityY — скорости жеста по горизонтальной и вертикальной осям.

Такой метод мы применяем только для отслеживания скорости жеста. Поэтому в результате функции передаём позицию в адаптере самой верхней карточки элемента. При свайпе изменяется не список, а указатель на верхнюю карточку.

override fun findTargetSnapPosition(
       layoutManager: RecyclerView.LayoutManager,
       velocityX: Int,
       velocityY: Int
): Int {
   this.velocityX = abs(velocityX)
   this.velocityY = abs(velocityY)
   return if (layoutManager is UsersDiscoverLayoutManager) {
       // Текущая позиция верхней карточки в списке карточек
       layoutManager.topPosition
   } else {
       RecyclerView.NO_POSITION
   }
}

1.4. Кастомный SmoothScroller

Этот класс отвечает за отслеживание позиции, к которой приведёт скролл, и предоставляет данные для программной прокрутки.

На каждый вызов startSmoothScroll(SmoothScroller) необходимо создавать новый экземпляр класса SmoothScroller — так как он хранит в себе состояние и его нельзя нормально переиспользовать.

fun onStart()

Возможные состояния:

  • анимация свайпа (с указанием информации о том, каким образом она была инициирована: кнопкой или жестом);

  • анимация возврата на место;

  • онбординг.

Чтобы знать момент, когда программная прокрутка остановилась, переопределяем этот метод:

fun onStop()

Это последний колбэк программной прокрутки — и подходящее место, чтобы очистить состояние. Здесь мы отправляем слушателям события о том, что: 

  • карточку свайпнули вправо или влево, 

  • закончился онбординг, 

  • карточка вернулась на место,

  • карточка вышла на первый план.

Для поиска целевой вьюхи при прокрутке изменяем в колбэке onSeekTargetStep полученный SmoothScroller.Action. Так:

fun onSeekTargetStep(dx: Int, dy: Int, state: State, action: Action)

Этот колбэк используется, чтобы определить следующую прокрутку для поиска вьюхи с учётом смещений dx, dy. Action — класс, который содержит информацию о программном скролле с анимацией, запрошенном SmoothScroller.

Когда целевая вьюха найдена, то вызывается этот метод:

fun onTargetFound(targetView: View, state: State, action: Action)

Это последний колбэк, который получит SmoothScroller. В нём надо обновить полученный Action, чтобы установить параметры прокрутки в направлении целевой вьюхи. Они устанавливаются методом update у Action:

  • dx — на сколько пикселей нужно проскроллить горизонтально;

  • dy — на сколько пикселей нужно проскроллить вертикально;

  • duration — длительность скролла,

  • interpolator — интерполятор анимации.

Приведу пример с нашим классом SmoothScroller и нажатием кнопки «Принять» или «Добавить». Здесь происходит автоматический свайп в правую сторону для принятия заявки:

class AcceptSmoothScroller(
   private val manager: UsersDiscoverLayoutManager
) : RecyclerView.SmoothScroller() {
   override fun onTargetFound(targetView: View, state: RecyclerView.State, action: Action) {
       val x = targetView.translationX
       val y = targetView.translationY
       action.update(
           manager.getDx(x, Direction.Right, manager.state),
           manager.getDy(y, Direction.Right, manager.state),
           manager.swipeAnimationDuration,
           manager.swipeAnimationInterpolator
       )
   }
   
   override fun onStart() {
       manager.state.set(Status.ButtonSwipeAnimating)
       val topView = manager.getTopView() ?: return
       manager.callback?.onCardDisappeared(topView, manager.topPosition)
   }
}

1.5. ScrollVectorProvider

Это интерфейс, который предоставляет классу SmoothScroller вектор-направление (показывает, куда нужно двигаться).

В нашем LayoutManager мы должны реализовать интерфейс ScrollVectorProvider и переопределить этот метод:

fun computeScrollVectorForPosition(targetPosition: Int): PointF?

В нём мы должны рассчитать вектор-направление, в котором может быть найдена целевая позиция. LayoutManager не должен проверять, существует ли элемент с такой позицией в адаптере. Мы смещаемся всегда только вперёд на одну позицию и обрабатываем это сами в своих SmoothScroller и SnapHelper. Так что нам не нужно знать направление — возвращаем здесь null.

1.6. Анимации добавления, удаления и перемещения вьюх

За анимации добавления, удаления и перемещения элементов отвечает ItemAnimator. По умолчанию RecyclerView использует стандартный DefaultItemAnimator. В нём реализованы анимации элементов. Их добавления и удаления в адаптере приводят к анимациям появления, исчезновения и перемещения вьюх. 

Если вьюхи размещаются только внутри onLayoutChildren(Recycler, State) и не происходит больше никаких действий, влияющих на их лейаут, — то у RecyclerView достаточно информации, чтобы выполнить стандартные анимации. Так что можно возвращать false в supportsPredictiveItemAnimations(). 

Если же нужна какая-то особенная анимация, то надо переопределять supportsPredictiveItemAnimations() и возвращать true, а также добавлять дополнительную логику в onLayoutChildren(Recycler, State). Это приведёт к тому, что onLayoutChildren(Recycler, State) будет вызываться дважды. Первый раз — в качестве предварительного прохода лейаута, чтобы определить, где были вьюхи до реального лейаута. И второй раз, чтобы сделать результирующий лейаут.

На этапе подготовки к лейауту элементы запоминают свои позиции до расположения, чтобы их можно было верно разместить. Кроме того, удалённые вьюхи вернутся из скрапа, чтобы помочь определить правильный лейаут для других. Удалённые не должны добавляться в список вьюх RecyclerView. Но при этом они должны использоваться в вычислении правильного лейаута других вьюх — включая те, что ранее не отображались на экране (это так называемые APPEARING-вьюхи), но чьё положение вне экрана до лейаута можно определить. Второй проход лейаута — это реальный лейаут, в котором будут использоваться только неудалённые вьюхи. 

2. Перемещение карточек

2.1. Онбординг

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

Рассмотрим оба случая:

  1. Автоматические перемещения карточек вправо и влево.

    Перемещение карточки происходит через запуск SmoothScroller. Сначала — в правую сторону на половину ширины экрана. Затем на колбэк onStop в первом SmoothScroller запускаем новый SmoothScroller — влево на расстояние ширины экрана.

    Например, чтобы сместить карточку вправо, в методе onTargetFound обновляем action, указывая необходимое нам смещение по x и y, а также длительность и интерполятор для анимации перемещения карточки.

    action.update(
           manager.getDx(x, Direction.Right, manager.state),
           manager.getDy(y, Direction.Right, manager.state),
           manager.swipeAnimationDuration,
           manager.swipeAnimationInterpolator
    )

    На onStart мы запоминаем текущее состояние того, что происходит (помечаем, что это состояние онбординга) — поэтому в SnapHelper эти жесты не учитываются.

  2. Замирание карточки, когда показывается предупреждающий диалог о добавлении друга. После действия в нём карточка возвращается на исходное место или улетает.

2.2. Двухэтапная анимация карточки

Когда перемещается верхняя карточка (будем называть её первой), вторая плавно увеличивается до размера первой, но сохраняет смещение вниз на dy точек. После того как пользователь свайпнул первую карточку за пределы экрана, вторая уже в полном размере плавно перемещается по оси y на исходное место первой. А третья тем временем плавно опускается вниз, на место второй.

3. Подгрузка данных и пагинация 

Для работы со списками у нас есть собственный фреймворк для RecyclerView, который умеет:

  • хранить ключ пагинации и размер страницы для подгрузки;

  • выполнять запросы:

    — первой страницы без ключа пагинации;

    — последующих страниц с ключом пагинации;

    — попытки перезапроса страницы (после ошибки);

    — обновления списка и загрузки первой страницы.

  • следить за состоянием скролла — за сколько элементов до конца начинать подгрузку следующей страницы;

  • отправлять колбэки для состояний:

    • процесса загрузки (для показа скелетона);

    • успешной загрузки данных (для заполнения списка);

    • пустого состояния (для вывода сообщения о пустом списке);

    • ошибки (для вывода сообщения об ошибке с кнопкой «Повторить»).

4. Предзагрузка изображений

Изображения, которые используются в больших карточках, сопоставимы по размеру с экраном устройства. Так что есть особенность: они тяжёлые и дольше загружаются из сети. Чтобы слишком часто не показывать пользователям заглушку, мы используем предзагрузку изображений в кеш: на несколько элементов списка вперёд, по колбэку скролла. Когда карточка появляется на экране, изображение уже заранее прогружено и берётся из кеша.

Так как мы построили механизм карточек через RecyclerView, то легко можем переиспользовать наш инструмент, который предзагружает изображения для записей в новостной ленте. Нам останется только реализовать интерфейсы FirstVisiblePositionProvider и LastVisiblePositionProvider. Позицию самого первого элемента мы всегда знаем — topPosition, а позиция последнего — это минимальное из чисел: 

  1. topPosition + количество видимых карточек.

  2. Индекс последнего элемента в списке карточек.

class UsersDiscoverLayoutManager : RecyclerView.LayoutManager(),
    RecyclerView.SmoothScroller.ScrollVectorProvider,
    FirstVisiblePositionProvider,
    LastVisiblePositionProvider {
    ...
    override fun getFirstVisiblePosition() = topPosition
    override fun getLastVisiblePosition() = 
        (topPosition + visibleCards).coerceIn(0, itemCount - 1)
}

5. Оптимизации

  • setHasFixedSize(boolean)

Если устанавливаем в true, то это обозначает, что никакой контент или дочерние вьюхи внутри RecyclerView не влияют на его высоту или ширину. Владея этой информацией, RecyclerView не выполнит лишние измерения. В данном случае я выставил setHasFixedSize(true).

  • Adapter.setHasStableIds(boolean)

Если передаём true, это сообщает RecyclerView, что нужно ориентироваться, помимо позиций, ещё и на stableId элементов. 

Рекомендуется использовать stableId для каждого ViewHolder — это помогает RecyclerView переиспользовать вьюхи, если меняется список. В нашей задаче я определил для каждого элемента stableId и выставил setHasStableIds(true).

  • setNestedScrollingEnabled(boolean)

Если вам не нужно, чтобы этот контейнер прокручивался внутри другого скролла, тогда передавайте false. Отключение позволяет не выполнять дополнительные вычисления для жестов. В данной задаче у меня нет скролла внутри карточки, поэтому я выставил флаг  setNestedScrollingEnabled(false).

6. Другие возможные варианты для решения задачи

  1. Написать полностью свою ViewGroup с механизмом лейаута вьюх. Придётся вручную анимировать перемещение, обрабатывать и определять жесты броска (флинг) и перетаскивания. Можно унаследоваться от класса AdapterView — это позволит использовать стандартный адаптер, который для каждого элемента в последовательности предоставляет соответствующую ему вьюху. Минус этого решения в том, что вьюхи не будут переиспользуемыми. А значит, если предстоит работать с большими данными, появятся проблемы с производительностью. Также мы не сможем переиспользовать существующие фреймворки, приспособленные для работы с RecyclerView:  например, фреймворк пагинации списка, предзагрузки контента и другие.

  2. Использовать MotionLayout. Простой и быстрый способ создать похожий экран. Из плюсов — минимум кода, в основном всё через xml. Минусы те же, что и в первом варианте: невозможно переиспользовать вьюхи.

  3. Использовать ViewPager с PageTransformer. Но минусы, описанные выше, в силе и здесь.

7. Вывод

В результате реализация оказалась не очень трудозатратной, и благодаря этой механике стало больше разобранных заявок в друзья.

Так как мы выбрали RecyclerView с собственным LayoutManager, то смогли переиспользовать существующие средства пагинации данных, предзагрузки изображений и т. д. А ещё этот подход позволил нам абстрагироваться от внешнего представления списка.

Теоретически, в текущей реализации мы можем заменить LayoutManager на любой другой — например, LinearLayoutManager или GridLayoutManager. И, соответственно, получить обычный линейный список или сетку. Так мы можем экспериментировать с разными представлениями, не затрагивая основной механизм работы с данными.