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

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

В этой статье:

  • Отметка «Нравится» и выбор реакций.

  • Общая схема взаимодействия компонентов реакций.

  • Фасад реакций.

  • Взаимодействие ReactionsView с другими компонентами.

  • Поп-ап выбора реакций.

  • Обработка тачей поп-апа реакций.

  • Бизнес-логика поп-апа.

  • Анимации открытия, закрытия, выбора, отправки реакций.

  • Режим чтения с экрана (TalkBack).

  • Воспроизведение анимаций реакций.

  • Предзагрузка реакций.

  • Где это пригодится.

Отметка «Нравится» и выбор реакций

Нажимая «Нравится», пользователи выражают интерес к записи. Но одной этой отметки бывает недостаточно, чтобы показать разные эмоции. А в некоторых случаях ставить «Нравится» просто неуместно. 

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

В мобильном приложении ВКонтакте пользователь оставляет реакции так: долгим нажатием на кнопку «Нравится» у записи вызывает поп-ап с реакциями, а затем ставит нужную — можно выбирать, не отпуская палец. Об этой и других особенностях реализации реакций в Android-приложении я расскажу в статье.

Общая схема взаимодействия компонентов реакций

В команде ленты мы используем MVP, чтобы отделять бизнес-логику от представления. Во View расположен адаптер для RecyclerView, а Presenter предоставляет для него данные — список объектов, описывающих ячейки записей. Каждой такой ячейке соответствует один ViewHolder.

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

Например, на скриншоте ниже показано, как разбивается моя запись с репостом из VK Team (каждая вьюха выделена синим прямоугольником):

  • Заголовок записи с аватаркой автора и датой.

  • Заголовок оригинальной записи.

  • Текст моей записи.

  • Вложение-статья.

  • Футер записи с кнопками.

Выставление реакции происходит так:

  • открываем поп-ап долгим нажатием;

  • сохраняем слабую ссылку на вьюху футера;

  • пользователь выбирает реакцию;

  • воспроизводим анимацию в кнопке футера, используя слабую ссылку.

Таким образом мы обновляем только часть футера, не применяя методы адаптера.

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

В презентере находится ReactionsFacade, этот фасад передаётся в адаптер.

При создании вьюхолдеры футера поста получают фасад реакций. 

Фасад реакций

ReactionsFacade реализует структурный шаблон проектирования. Это фасад, который позволяет скрыть сложность системы: все возможные внешние вызовы сводятся к одному объекту, делегирующему их соответствующим объектам системы.

Фасад содержит вьюху ReactionsView и конфиг с описанием доступной функциональности — ReactionsConfig

Как происходит установка: на кнопку реакции устанавливается слушатель касаний (с помощью метода setOnTouchListener); а вьюхолдер, содержащий кнопку, реализует интерфейс View.OnTouchListener. У ReactionsView есть обработчик onTouch, в который передаются:

  • вьюха, из которой произошёл тач;

  • событие тача MotionEvent

  • вьюхолдер, где находится вьюха из первого пункта;

  • объект, к которому относится реакция, реализующий интерфейс Reactionable (это может быть любой объект, но в данном случае передаётся пост).

Зависимости:

  • При создании ReactionsFacade передаётся ReactionsConfig — он содержит конфигурацию с описанием доступной функциональности. Необходим для экспериментальных функций, которые можно выключать.

  • ReactionsView.

Взаимодействие ReactionsView с другими компонентами

ReactionsView — класс, который предоставляет взаимодействие с вьюхами для ReactionsFacade.

Cоздание и показ поп-апа ReactionsPopupWindow происходит в ShowReactionRunnable. ReactionsPopupWindow в качестве контентной вьюхи использует кастомную вьюху ReactionsPopupView. В ней размещается бабл поп-апа и все анимации реакций.

inner class ShowReactionRunnable(
        private val reactionSet: ReactionSet
) : Runnable {
    override fun run() {
        val view = getAnchorView() ?: return
        view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
        openPopup(view, reactionSet)
    }
}

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

Зависимости:

  • ReactionsPopupWindow — наследник PopupWindow, необходим для присоединения вьюхи поп-апа выбора реакций ReactionsPopupView к окну.

  • Ссылка на ReactionsPresenter (выполняет роль классического презентера) — она содержит бизнес-логику и управляет интерфейсом.

  • ReactionsButtonTouchDelegate — делегат, необходимый только для обработки жестов с кнопки. 

  • Определяет жесты:

  • Скролл — если пользователь скроллит, то отклоняет обработку тачей, чтобы тачи обрабатывались RecyclerView.

  • Одиночное и долгое нажатие — в случае длительного нажатия открывает поп-ап и последующие тачи делегирует уже в его вьюху.

Ниже показываю, как выглядят onTouch в ReactionsButtonTouchDelegate.

  • на действие MotionEvent.ACTION_DOWN — метод onTouchDown:

private fun onTouchDown(
        view: View,
        viewHolder: ReactionableViewHolder,
        event: MotionEvent,
        model: Reactionable
): Boolean {
    val activePointerId = event.getPointerId(FIRST_POINTER_INDEX)
    this.view.setActivePointerId(activePointerId)
    this.view.setAnchor(view, viewHolder)
    scrollTouchDelegate.setEvent(event)
    presenter.setModel(model)
    (view.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(true)
    startTime = System.currentTimeMillis()
    this.view.showDelayed(model)
    this.view.showRipple(event.x, event.y)
    return true
}
  • MotionEvent.ACTION_UPonTouchUp:

private fun onTouchUp(): Boolean {
    performClickIfNeeded()
    view.closePopup()
    return true
}
  • MotionEvent.ACTION_CANCELonTouchCancel:

private fun onTouchCancel(): Boolean {
    view.closePopup()
    return true
}
  • MotionEvent.ACTION_MOVEonTouchMove:

private fun onTouchMove(event: MotionEvent): Boolean {
    val isScrollGesture = scrollTouchDelegate.isScroll(event)
    if (isScrollGesture) {
        view.closePopup()
    }
    return !isScrollGesture
}

И ещё зависимости:

  • ReactionsStateController — управляет логикой переходов состояний реакций в ответ на действия с UI.

  • ReactionsViewSettings — содержит информацию с настройками отображения поп-апа. 

    Что можно настроить:

  • с какой стороны и с каким отступом от якорной вьюхи будет отображаться поп-ап;

  • отступы от экрана для отображения поп-апа реакций;

  • размер каждой реакции в выделенном и невыделенном состояниях;

  • цвет подсказки с названием реакции;

  • размер шрифта подсказки названия реакции.

  • ReactionsCallback — коллбэк для получения событий об открытии и закрытии поп-апа, выбора реакции и её отправки.

  • В ReactionsView хранится слабая ссылка на ReactionViewHolder (позволяет анимированно обновлять интерфейс). ReactionViewHolder — интерфейс вьюхолдера, который хранит внутри себя вьюху с отображением реакции. Все вьюхолдеры с кнопкой реакций — это наследники ReactionViewHolder.

Поп-ап выбора реакций

ReactionsPopupWindow — наследник PopupWindow. Необходим, чтобы к окну вьюхи присоединялся поп-ап выбора реакций ReactionsPopupView

ReactionsPopupView — вьюха, которая занимает весь экран. В onLayout размещаем поп-ап реакций и сами реакции. Поп-ап реализован через контейнер со скроллом — так поддерживаем маленькие экраны. Он содержит вьюхи, аниматор ReactionsPopupViewAnimator и делегат тачей ReactionsPopupTouchDelegate.

ReactionsViewSettings — здесь находятся настройки визуального отображения. 

Измерение

В onMeasure измеряем все текстовые вьюхи названий реакций и размер контейнера с реакциями.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val width = MeasureSpec.getSize(widthMeasureSpec)
    val height = MeasureSpec.getSize(heightMeasureSpec)

    val needUpdateVisibleRect = !isPopupHiding
    if (!checkAnchor(needUpdateVisibleRect, width, height)) {
        dismissListener?.invoke()
        return
    }

    super.onMeasure(widthMeasureSpec, heightMeasureSpec)

    measureTitles()
    measurePopupWidth()

    if (needUpdateVisibleRect) {
        calcPopupCoordinates()
    }

    val popupWidthMeasureSpec = MeasureUtils.makeSpecExactly(dialogWidth)
    val popupHeightMeasureSpec = MeasureUtils.makeSpecExactly(dialogHeight)
    scrollView.measure(popupWidthMeasureSpec, popupHeightMeasureSpec)
}

В методе measureTitles измеряются вьюхи, которые используются для надписей реакций.

private fun measureTitles() {
    // Измеряем все textView, чтобы потом в onLayout разместить вручную
    for (index in titleViews.indices) {
        titleViews[index].measure(0, 0)
    }
}

Функция measurePopupWidth измеряет ширину поп-апа. Если она меньше родительской ширины с учётом больших отступов, то итоговая будет без изменений.

private fun measurePopupWidth() {
    when {
        // Если по ширине диалог получается меньше родительской ширины с учётом больших отступов
        originalDialogWidth < this.measuredWidth - settings.screenPaddingStart - settings.screenPaddingEnd -> {
            screenPaddingStart = settings.screenPaddingStart
            screenPaddingEnd = settings.screenPaddingEnd
            dialogWidth = originalDialogWidth
        }
        // Если по ширине диалог получается меньше родительской ширины с учётом маленьких отступов
        originalDialogWidth < this.measuredWidth - settings.screenPaddingSmallStart - settings.screenPaddingSmallEnd -> {
            screenPaddingStart = settings.screenPaddingSmallStart
            screenPaddingEnd = settings.screenPaddingSmallEnd
            dialogWidth = originalDialogWidth
        }
        // Если поп-ап в любом случае выходит за пределы, то сокращаем на необходимую величину
        // так, чтобы у последней видимой реакции было видно половину
        else -> {
            screenPaddingStart = settings.screenPaddingStart
            screenPaddingEnd = settings.screenPaddingEnd

            val availableWidth = this.measuredWidth - settings.screenPaddingStart - settings.screenPaddingEnd - firstChildMarginStart
            val numVisibleReactions = (availableWidth / dialogChildWidth - 1).coerceAtLeast(0)
            dialogWidth = firstChildMarginStart + dialogChildWidth * numVisibleReactions + dialogChildWidth / 2
        }
    }
}

В calcPopupCoordinates рассчитываются координаты места, где будет показан поп-ап, с учётом границ экрана.

private fun calcPopupCoordinates() {
    calcPopupCoordinatesX()
    calcPopupCoordinatesY()
}

private fun calcPopupCoordinatesX() {
    // Поп-ап размещается по центру над якорем
    dialogX = anchorViewLocation.left - thisLocation.left + anchorWidth / 2 - dialogWidth / 2

    // Корректируем, если вышли за пределы допустимых границ
    when {
        // Если левая координата поп-апа выходит за пределы, то смещаем вправо
        dialogX < screenPaddingStart -> {
            dialogX = screenPaddingStart
        }
        // Если правая координата поп-апа выходит за пределы, то смещаем влево
        dialogX + dialogWidth > this.measuredWidth - screenPaddingEnd -> {
            dialogX = this.measuredWidth - dialogWidth - screenPaddingEnd
        }
    }
}

private fun calcPopupCoordinatesY() {
    // Размещаем над кнопкой установки реакции
    dialogY = anchorViewLocation.top - dialogHeight - settings.anchorMarginBottom - thisLocation.top

    // Если нет места, то размещаем под кнопкой установки реакции
    if (dialogY < settings.offsetTop) {
        dialogY = anchorViewLocation.top + anchorHeight + settings.anchorMarginBottom - thisLocation.top
    }
}

Размещение

Про функцию измерения поговорили, теперь — о размещении контейнера. Всё это происходит в onLayout.  

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    val needUpdateVisibleRect = !isPopupHiding
    // Если якорь не присоединён к окну, то ничего не размещаем на экране и закрываем поп-ап
    // В случае анимации скрытия поп-апа не надо обновлять положение
    // Если якорь не виден, то закрываем поп-ап
    if (!checkAnchor(needUpdateVisibleRect, this.measuredWidth, this.measuredHeight)) {
        dismissListener?.invoke()
        return
    }

    // Размещаем контейнер с реакциями в координатах (dialogX, dialogY)
    // В dialogX и dialogY уже учтён RTL, поэтому тут уже располагается в абсолютных координатах
    layoutChildLeft(scrollView, dialogX, dialogY)

    // Размещаем реакции
    layoutReactions(needUpdateVisibleRect)
}

Подробнее опишем, что означает каждая функция.

  • В checkAnchor проверяем, видна ли кнопка реакций и присоединена ли она к окну:

private fun checkAnchor(needUpdateVisibleRect: Boolean, width: Int, height: Int): Boolean {
    if (!checkAnchorIsAttachedToWindow()) return false
    if (!needUpdateVisibleRect) return true
    return checkAnchorVisibility(width, height)
}

/**
 * Проверяет, виден ли якорь
 */
private fun checkAnchorVisibility(width: Int, height: Int): Boolean {
    this.getLocationOnScreen(thisLocation)

    val anchorView = view.getAnchorView() ?: return false
    anchorView.getLocationOnScreen(anchorViewLocation)
    anchorView.getGlobalVisibleRect(rect)
    return anchorViewLocation.top >= thisLocation.top &&
            anchorViewLocation.top + anchorView.measuredHeight <= thisLocation.top + height &&
            anchorViewLocation.left + anchorView.measuredWidth <= thisLocation.left + width &&
            anchorViewLocation.left >= thisLocation.left &&
            !rect.isEmpty
}

/**
 * Проверяет, присоединён ли якорь к окну
 */
private fun checkAnchorIsAttachedToWindow(): Boolean {
    val anchorView = view.getAnchorView()
    return anchorView != null && anchorView.isAttachedToWindow
}
  • Функция layoutChildLeft размещает view там, где левый верхний угол в координатах (x, y):

private fun layoutChildLeft(view: View, x: Int, y: Int) {
    if (view.visibility != View.VISIBLE) return

    val width = view.measuredWidth
    val height = view.measuredHeight
    val lp = view.layoutParams as? MarginLayoutParams
    val childLeft = x + (lp?.leftMargin ?: 0)
    val childTop = y + (lp?.topMargin ?: 0)
    view.layout(childLeft, childTop, childLeft + width, childTop + height)
}
  • В функции layoutReactions размещаем вьюхи реакций, входящих в набор:

private fun layoutReactions() {
    val thisRect = this.getVisibleRect()
    for (index in scrollView.reactionContainerViews.indices) {
        val reactionContainerView = scrollView.reactionContainerViews[index]
        val reactionRect = reactionContainerView.getVisibleRect()

        val reactionView = scrollView.reactionViews[index]
        layoutReactionView(reactionView, reactionRect, thisRect)

        val titleView = titleViews[index]
        layoutTitle(titleView, reactionRect, thisRect)
    }
}
  • В layoutReactionView размещаем вьюху конкретной реакции:

private fun layoutReactionView(reactionView: View, reactionRect: Rect, thisRect: Rect) {
    // Если вьюха реакции — дочерняя вьюха этой вью-группы (а не скролл-вью), то размещаем её
    if (reactionView.parent !== this) return

    reactionView.layout(
            reactionRect.left - thisRect.left,
            reactionRect.top - thisRect.top,
            reactionRect.right - thisRect.left,
            reactionRect.bottom - thisRect.top
    )
}
  • layoutTitle размещает titleView по центру над реакцией с границами reactionRect в границах вью-группы thisRect:

private fun layoutTitle(titleView: View, reactionRect: Rect, thisRect: Rect) {
    val bottomText = reactionRect.top - thisRect.top - nameTextBottomMargin
    val topText = bottomText - titleView.measuredHeight
    val leftText = calcTextHorizontalOffset(titleView, reactionRect, thisRect)
    val rightText = leftText + titleView.measuredWidth
    titleView.layout(leftText, topText, rightText, bottomText)
}

Вычисляем левый отступ для надписи над реакцией, учитывая границы экрана:

private fun calcTextHorizontalOffset(titleView: View, reactionRect: Rect, thisRect: Rect): Int {
    val leftTextOffset = reactionRect.centerX() - thisRect.left - titleView.measuredWidth / 2
    return when {
        leftTextOffset < screenPaddingStart -> screenPaddingStart
        leftTextOffset + titleView.measuredWidth > this.measuredWidth - screenPaddingEnd -> {
            this.measuredWidth - screenPaddingEnd - titleView.measuredWidth
        }
        else -> leftTextOffset
    }
}

Рисование

Вот так выглядит метод dispatchDraw в ReactionsPopupView:

override fun dispatchDraw(canvas: Canvas) {
    layoutPopup()
    drawPopup(canvas)
    super.dispatchDraw(canvas)
}

В функции layoutPopup вычисляем координаты и границы поп-апа:

private fun layoutPopup() {
    val halfPopupHeight = dialogHeight * (1f - popupScale) / 2f
    popupRect.set(
            dialogX + (-dialogBackgroundPaddingStart / popupScale - halfPopupHeight).roundToInt(),
            popupTranslationY + dialogY + (halfPopupHeight - dialogBackgroundPaddingTop / popupScale).roundToInt(),
            dialogX + (dialogBackgroundPaddingEnd / popupScale + popupWidth * popupScale - halfPopupHeight).roundToInt(),
            popupTranslationY + dialogY + (halfPopupHeight + dialogBackgroundPaddingBottom / popupScale + popupHeight * popupScale).roundToInt()
    )
}

В функции drawPopup рисуем подложку и тень поп-апа с координатами, полученными в layoutPopup:

private fun drawPopup(canvas: Canvas) {
    val pivotX = dialogX + (-dialogBackgroundPaddingStart / popupScale + dialogBackgroundPaddingEnd / popupScale + dialogHeight) / 2f
    canvas.save()
    canvas.scale(popupScale, popupScale, pivotX, popupRect.centerY().toFloat())
    drawPopupShadow(canvas)
    drawPopupBackground(canvas)
    canvas.restore()
}

private fun drawPopupBackground(canvas: Canvas) {
    popupBackground?.bounds = popupRect
    popupBackground?.draw(canvas)
}

private fun drawPopupShadow(canvas: Canvas) {
    popupShadowBackground?.bounds = popupRect
    popupShadowBackground?.draw(canvas)
}

Обработка касаний

Перехватываем все тачи:

override fun onInterceptTouchEvent(ev: MotionEvent) = true

Все тачи передаются в ReactionsPopupTouchDelegate:

override fun onTouchEvent(event: MotionEvent): Boolean {
    touchDelegate.setCanSelect(animator.canSelect())
    return touchDelegate.onTouchEvent(event)
}

Зависимости:

  • ReactionsPopupViewAnimator — отвечает за анимации открытия, закрытия и выбора реакции.

  • ReactionsPopupTouchDelegate — в этот класс мы делегируем все тачи от вьюхи ReactionsPopupView.

  • ReactionsSet — набор реакций, описание каждой находится в ReactionMeta.

    Мы заложили возможность присвоить любой записи свой набор реакций: в объекте записи хранится идентификатор актуального набора. И в каждом таком наборе хранятся объекты реакций с названиями, ссылками на статические изображения и Lottie-анимации.

    В объекте реакции Reaction содержится ссылка на Lottie-анимацию, изображение, название. Для изображения передаётся массив с размерами и ссылками на нарезанные размеры реакций. Это необходимо, чтобы клиент сам выбирал оптимальное для устройства изображение — в зависимости от плотности пикселей экрана.

  • ReactionsScrollView — скролл-вью реакций.

  • ReactionsAccessibilityDelegate — делегат для TalkBack

  • ReactionsViewSettings.

  • ReactionsCallback.

Обработка тачей поп-апа реакций

ReactionsPopupTouchDelegate — в этот класс мы делегируем все тачи от вьюхи ReactionsPopupView

Обработка тачей происходит в ReactionsPopupTouchDelegate.

В методе onTouchEvent каждый метод отвечает за обработку определённого действия:

  • Действие MotionEvent.ACTION_DOWN — метод onTouchDown

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

private fun onTouchDown(event: MotionEvent): Boolean {
    if (this.activePointerId == INVALID_POINTER_ID) {
        this.activePointerId = event.getPointerId(FIRST_POINTER_INDEX)
    } else if (!checkPointer(event)) {
        return false
    }

    if (this.startTouchTime == null) {
        clearGesture()
        this.startTouchTime = System.currentTimeMillis()
    }

    selectDelegate.setContinuousTouch(false)

    callback.onStartTouch()

    val isIntersectedScrollView = popupView.isIntersectedScrollView(event.rawX, event.rawY)
    this.canScroll = isIntersectedScrollView
    this.downEventX = event.rawX
    scrollDetector.setEvent(event)
    this.lastEventX = event.rawX
    this.lastEventY = event.rawY
    if (isIntersectedScrollView) {
        scrollView.onTouchEvent(event)
        selectDelegate.cancel()
        selectDelegate.onTouchDown(event)
        autoScrollDelegate.scroll(event)
    } else {
        endTouchAndDismissIfNeeded()
    }
    return true
}

startTouchTime — время начала взаимодействия с поп-апом, запоминается в момент прикосновения к экрану (MotionEvent.ACTION_DOWN) и очищается, когда юзер отпускает палец или отменяет жест (MotionEvent.ACTION_UP или MotionEvent.ACTION_CANCEL).

  • Действие MotionEvent.ACTION_MOVE — метод onTouchMove

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

private fun onTouchMove(event: MotionEvent): Boolean {
    val pointerIndex = getPointerIndex(event) ?: return false

    this.lastEventX = event.getX(pointerIndex)
    this.lastEventY = event.getY(pointerIndex)

    return when {
        isScroll -> onTouchMoveScroll(event)
        else -> {
            isScroll = isScroll(event)
            if (!isScroll) {
                selectDelegate.onMove(event)
                autoScrollDelegate.scroll(event)
                true
            } else {
                onTouchMoveScroll(event)
            }
        }
    }
}
  • Действие MotionEvent.ACTION_UP — метод onTouchUp.

Необходимо для отслеживания момента, когда палец уже не касается экрана. Если до этого был инициирован автоскролл, то он останавливается.

private fun onTouchUp(event: MotionEvent): Boolean {
    if (!checkPointer(event)) return false

    this.lastEventX = null
    this.lastEventY = null
    this.activePointerId = INVALID_POINTER_ID
    return when {
        isScroll -> endScrollTouch(event)
        else -> {
            autoScrollDelegate.cancel()
            selectDelegate.select(event.rawX, event.rawY)
        }
    }
}
  1. Действие MotionEvent.ACTION_CANCEL — метод onTouchCancel

Необходим для понимания, когда жест отменён. Останавливается автоскролл, если он был инициирован, и закрывается поп-ап.

private fun onTouchCancel(event: MotionEvent): Boolean {
    if (!checkPointer(event)) return false

    this.lastEventX = null
    this.lastEventY = null
    this.activePointerId = INVALID_POINTER_ID
    return when {
        isScroll -> endScrollTouch(event)
        else -> {
            cancelGesture()
            popupView.dismiss()
            true
        }
    }
}
  1. onTouchMoveScroll используется для передачи события тача из onTouchMove — это нужно для скролла списка возможных реакций.

private fun onTouchMoveScroll(event: MotionEvent): Boolean {
    autoScrollDelegate.cancel()
    selectDelegate.idle()
    return if (canScroll) {
        scrollView.onTouchEvent(event)
    } else {
        true
    }
}
  1. onScroll используется для обновления положения реакций и подсказок с их названиями, когда происходит скролл общего списка. Обновлять положение нужно, так как скролл — это изменение свойства и он не вызывает переразмещение вьюх.

fun onScroll() {
    val lastEventX = lastEventX ?: return
    val lastEventY = lastEventY ?: return
    if (isScroll) return

    selectDelegate.onScroll(lastEventX, lastEventY)
}

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

Определение событий при разных взаимодействиях с вью

  • Событие нажатия на кнопку.

Если между ACTION_DOWN и ACTION_UP прошло меньше времени, чем ViewConfiguration.getLongPressTimeout(), то вызывается performClick. Это происходит у вьюхи, откуда был тач, и поп-ап закрывается. По ACTION_CANCEL поп-ап также закрывается. Также если было событие ACTION_MOVE и дистанция изменилась на ViewConfiguration.get(context).scaledTouchSlop, то это считается за скролл. В таком случае поп-ап не открывается.

  • Событие длительного нажатия.

По событию ACTION_DOWN запускается отложенный Runnable через ViewConfiguration.getLongPressTimeout() миллисекунд. Если любые события произошли за это время, то этот Runnable отменяется.

Как только поп-ап открывается, все тачи обрабатываются им.

val runnable = ShowReactionRunnable(v, reactionSet)
handler.postDelayed(runnable, longPressTimeout)

Для ответного отклика-вибрации при открытии поп-апа вызывается метод хаптика view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS).

Зависимости:

  • AutoScrollDelegate — делегат, необходимый для автоскролла в поп-апе реакций.

  • ScrollTouchDetector — делегат для определения скролла по событиям тача.

  • SelectDelegate — делегат, который содержит логику выбора реакции.

Выделение вьюхи происходит при выполнении SelectReactionRunnable через время tapTimeout

fun selectReactionDelayed(selectedId: Int) {
    selectReactionRunnable = SelectReactionRunnable(selectedId).apply {
        handler.postDelayed(this, tapTimeout)
    }
}

В SelectReactionRunnable находится запуск аниматоров — нужны при изменении состояния выделенной реакции.

private inner class SelectReactionRunnable(var reactionId: Int) : Runnable {
    override fun run() {
        popupView.setSelectedPosition(reactionId)
        selectReactionRunnable = null
        touchDelegate.setCanScroll(false)
    }
}

В коде выше reactionId — это id реакции; popupView.setSelectedPosition(reactionId) производит анимацию выбора реакции; touchDelegate.setCanScroll(false) исключает горизонтальный скролл реакций в логике. Актуально, когда все реакции не влезают в экран, и при выделении реакции нажатием на экран невозможно скроллить список жестами. 

  • ReactionsScrollView — вьюха с горизонтальным скроллом, в которой находятся реакции.

  • ReactionsCallback — коллбэк для получения событий об открытии и закрытии поп-апа, выборе и отправке реакции.

  • ReactionsPopupView — вьюха, которая занимает весь экран и в ней размещается поп-ап. Содержит вьюхи, аниматор и делегат тачей.

Бизнес-логика поп-апа

ReactionsPresenter содержит бизнес-логику и отправляет события обновления вьюх на экране.

Оптимистичное выставление реакций во вьюхе

Параллельно с отправкой запроса на добавление или удаление реакции в UI анимируется выставление выбранной реакции и обновляется состояние в объекте записи. Пока выполняется запрос на выставление реакции, повторные запросы не выполняются. Если происходит ошибка, то UI меняет состояние на то, что было до выставления реакции. Также на предыдущее откатывается состояние объекта. Пользователю показывается ошибка с дополнительной информацией.

  • ReactionsView — класс, который предоставляет взаимодействие с вьюхами для ReactionsPresenter.

  • ReactionsModel содержит информацию об объекте, которому можем поставить реакцию.

  • ReactionsCallback — коллбэк для получения событий об открытии и закрытии поп-апа, выборе и отправке реакции.

Анимации открытия, закрытия, выбора и отправки реакций

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

Этапы анимации

  • Анимация открытия поп-апа.

Реализована в ReactionsOpenAnimator. Для одновременного проигрывания аниматоров используем AnimatorSet, вызывая playTogether со списком аниматоров:

  • аниматор, который делает подложку и тень поп-апа непрозрачными, начиная с прозрачности 100%;

  • аниматор, который увеличивает скейл подложки поп-апа до 1, а также увеличивает ширину и высоту, начиная с круглой подложки и тени;

  • для каждого изображения реакции в поп-апе по два аниматора:

    • один отвечает за увеличение скейла с 0 до 1,

    • другой — за смещение реакции сверху вниз.

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

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

После проигрывания всех аниматоров открытия поп-апа начинается воспроизведение Lottie-анимаций для реакций.

  • Анимация закрытия поп-апа.

Анимация закрытия поп-апа реализована в ReactionsCloseAnimator.

Останавливаются все Lottie-анимации реакций. 

Проигрываем одновременно несколько аниматоров: 

  • перемещение вьюх реакций по оси Y от 0 до 36 dp кубической интерполяцией Безье;

  • поворот вьюх реакций от 0 до −35 градусов линейной интерполяцией;

  • прозрачность вьюх реакций от 1 до 0 линейной интерполяцией;

  • перемещение поп-апа реакций на их величину кубической интерполяцией Безье;

  • прозрачность подложки поп-апа реакций от 1 до 0 линейной интерполяцией.

После окончания всех аниматоров закрытия поп-апа освобождаются ресурсы, необходимые для Lottie-анимаций.  

  • Анимация наведения пальцем на реакцию.

За анимацию выбора реакции отвечает ReactionsSelectAnimator. В нём проигрываются одновременно несколько аниматоров: 

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

  • аниматор, отвечающий за вьюхи реакций и их подписи, когда на них указывает палец.

  • Анимация отправки реакции.

За эту анимацию отвечает ReactionsSendAnimator.

Проигрываем одновременно несколько аниматоров: 

  • перемещение контейнера реакций по кубической интерполяции Безье;

  • прозрачность контейнера реакций по линейной интерполяции;

  • масштаб вьюхи выбранной реакции по кубической интерполяции Безье;

  • перемещение вьюхи выбранной реакции по кубической интерполяции Безье.

Режим чтения с экрана (TalkBack)

Мы поддержали режим TalkBack — помогает людям с нарушениями зрения и в разных ситуациях, когда неудобно смотреть на экран.

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

Для поддержки TalkBack в кастомной вьюхе устанавливается реализация наследника класса AcessibiltyDelegateCompat. У нас она находится в классе ReactionsAccessibilityDelegate. В ReactionsAccessibilityDelegate происходит описание элементов интерфейса и их визуальных границ.

ReactionsAccessibilityDelegate — делегат для TalkBack

  • AccessibilityDelegateCompat — родительский класс для ReactionsAccessibilityDelegate.

  • ExploreByTouchHelperImpl — наследник ExploreByTouchHelper.

  • ReactionsPopupView — вью-группа, которая занимает весь экран и в ней размещается вьюха поп-апа.

Чтобы проще взаимодействовать со службой специальных возможностей, используем реализацию класса ExploreByTouchHelper. Ему передаём всю необходимую информацию.

ExploreByTouchHelper — это служебный класс для реализации поддержки специальных возможностей во вьюхе. Он предоставляет информацию о визуальных элементах, подобных вьюхам. ExploreByTouchHelper расширяет класс AccessibilityNodeProviderCompat и упрощает взаимодействие с механизмом специального доступа.

С помощью ViewCompat.setAccessibilityDelegate(View, AccessibilityDelegateCompat) устанавливаем ReactionsAccessibilityDelegate для вьюхи.

Для Talkback все вьюхи, с которыми можно взаимодействовать, описываются через виртуальные вьюхи.

Опишем методы специального доступа:

  • Функция getVisibleVirtualViews заполняет список идентификаторами видимых вьюх. Порядок идентификаторов в ids определяет последовательность обхода вьюх в режиме TalkBack.

private fun getVisibleVirtualViews(ids: MutableList<Int>) {
    for (i in 0..view.getReactions().size) {
        ids.add(i)
    }
}

Всего n + 1 виртуальных вьюх, где n — количество реакций. +1 — так как нажатие на область вне поп-апа реакций используется для его закрытия. 

  • getVirtualViewAt позволяет получить идентификатор вьюхи по координатам на экране. Все вьюхи реакций у нас будут иметь идентификаторы от 0 до n + 1, где n — количество реакций.

private fun getVirtualViewAt(x: Float, y: Float): Int {
    val position = view.findPositionByTouch(x, y)
    return if (position != WAITING_SELECTION && position >= 0) {
        position
    } else {
        view.getReactions().size
    }
}
  • В onPopulateEventForVirtualView заполняем событие AccessibilityEvent информацией об указанной виртуальной вьюхе.

private fun onPopulateEventForVirtualView(id: Int, event: AccessibilityEvent) {
    val reaction = view.getReaction(id)
    event.contentDescription = if (reaction != null) {
        view.resources.getString(R.string.reaction_add, reaction.title)
    } else {
        view.resources.getString(R.string.close)
    }
}
  • В onPopulateNodeForVirtualView заполняем AccessibilityNodeInfoCompat информацией о конкретном элементе. Указываем:

  • текст для людей с ограниченными возможностями;

  • фокусируемость вьюхи;

  • её кликабельность;

  • границы вьюхи;

  • возможные действия с ней (AccessibilityNodeInfoCompat.ACTION_CLICK).

private fun onPopulateNodeForVirtualView(id: Int, info: AccessibilityNodeInfoCompat) {
    val reaction = view.getReaction(id)
    info.contentDescription = if (reaction != null) {
        view.resources.getString(R.string.reaction_add, reaction.title)
    } else {
        view.resources.getString(R.string.close)
    }
    info.isFocusable = true
    info.isClickable = true
    view.getBoundsForView(id, rect)
    info.setBoundsInParent(rect)
    info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK)
}
  • В коллбэке onPerformActionForVirtualView выполняем указанное действие (простое или длительное нажатие) из параметра action — для элемента, связанного с идентификатором id виртуального представления. Например, так мы обрабатываем событие нажатия в режиме специального доступа:

private fun onPerformActionForVirtualView(id: Int, action: Int) = when (action) {
    AccessibilityNodeInfoCompat.ACTION_CLICK -> {
        val reaction = view.getReaction(id)
        if (reaction != null) {
            view.select(id)
        } else {
            view.close()
        }
        true
    }
    else -> false
}

Воспроизведение анимаций реакций

Анимированные картинки реакций выполнены в формате Lottie.

Использование Lottie в общем виде выглядит так:

  • Загрузка данных анимации с сервера в векторном формате Lottie.

  • Кеширование данных анимации в векторном формате.

  • Воспроизведение анимации.

Алгоритм воспроизведения анимации состоит из нескольких шагов (см. ниже).

  • Получаем начальную точку отсчёта времени: принимаем, что в этот момент будет первый кадр.

  • Для получения следующего номера кадра вычисляем, сколько времени прошло со старта. Если это первый кадр, приступаем к пункту Б.

deltaMs = timeNowMs - startPointTimeMs
startPointId + (deltaMs / renderData.frameDurationMs).roundToInt()

А. Если наступило время отрисовывать кадр анимации, то на следующем вызове onDraw нужно отрисовать битмапу на экран, а предыдущую поместить обратно в пул битмап.

Б. Параллельно с отрисовкой кадра из битмапы приступаем к переводу векторного Lottie в битмапу:

  • получаем битмапу из пула (битмапа берётся из пула, если конкретная битмапа указанного размера существует; если нет, создаётся новая);

  • запускаем алгоритм перевода векторного формата Lottie в растровый — в конкретную битмапу для указанного кадра на фоновом потоке.

    Шаги следует повторять, пока:

    — анимация запущена;

    — вьюха на экране;

    — количество раз полного воспроизведения анимации меньше максимального количества повторов воспроизведений (необходимо на случай, если надо показать анимацию ограниченное количество раз).

Формат Lottie 

Для перевода Lottie из векторного в растровый формат используется библиотека RLottie (github.com/Samsung/rlottie), она реализована на C++. В Android приложение подключается через NDK. В результате выполнения RLottie битмапа наполняется пикселями указанного кадра анимации.

В метаданных Lottie хранится фреймрейт и общее количество кадров.

Оптимизации

  • В приложении используется выделенный планировщик с отдельным пулом потоков специально для Lottie.

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

Предзагрузка реакций

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

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

 object ReactionsPreloader {
    // Максимальное количество возможных ошибок для одного url
    private const val ERROR_RETRY_MAX = 3

    private val downloading = ArraySet<String>()
    private val loaded = ArraySet<String>()
    private val errors = ArrayMap<String, Int>()

    fun preload(url: String) {
        if (loaded.contains(url)) return
        if (downloading.contains(url)) return
        var errorCounter = errors.getOrDefault(url, 0)
        if (errorCounter >= ERROR_RETRY_MAX) return
        downloading.add(url)

        // VKAnimationLoader кеширует json на продолжительное время
        VKAnimationLoader.load(url)
            .doOnNext {
                loaded.add(url)
            }
            .doOnError {
               errors[url] = ++errorCounter
            }
            .doFinally {
                downloading.remove(url)
            }
            .subscribeEmpty()
    }
}

Здесь downloading — множество url, которые предзагружаются прямо сейчас; loaded — множество успешно загруженных url; errors — словарь, где каждому url соответствует количество ошибок.

Где это пригодится

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

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

Описанная реализация режима чтения с экрана поможет создать доступный интерфейс для людей с нарушениями зрения — даже в случае полностью кастомных вьюх, когда простого интерфейса в виде View.setContentDescription недостаточно.

Простой механизм предзагрузки позволяет избавиться от заглушек загрузки реакций.

А подход к отрисовке Lottie-анимаций можно использовать и для других случаев, где каждый кадр вычислимо «тяжёлый» и его нужно предварительно рендерить на другом потоке.

Комментарии (9)


  1. embden
    12.04.2022 12:39
    +3

    О, а у меня к вам как раз вопрос: "Почему вы не доделываете нормально свои сервисы?" VK Donut реализован обрывками, пользоваться им нормально невозможно, администрирование бесед - тихий ужас, реализация реакций - хуже, чем в телеграмме.

    Серьезно, у вас там UX-команда присутствует? Реакции VK - они прямо раздражают. В телеграмме они сделаны нормально и не раздражают, каждая реакция выводится, как отдельная сущность, реакций много разных. В вк же ты заходишь в пост и видишь, что к посту поставлены сколько-то лайков без детализации по реакциям. Хочешь увидеть, какие реакции проставлены? Тебе недостаточно просто посмотреть на пост, недостаточно даже открыть пост, ты должен отдельно ещё и щелкнуть по мелкому значку реакций.

    Нет, серьезно, у вас там UX-команда работает? Почему в телеграмме ты можешь увидеть, какие реакции к посту уже проставлены и присоединиться к ним, а в вк ты не видишь чужих реакций, самих реакций мало, две еще и частично дублируют друг друга ("Ого!" и "Восторг!").

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


    1. kateridzhe
      12.04.2022 16:14
      +1

      ВК и Телеграм - это разные сервисы, которые реализовывают задачи так, как они считают нужным. Никто не должен под копирку брать решения других. Это здорово, что есть многообразие.

      А вас мне жаль, если вас действительно раздражают лайки. Возможно, вам стоит ограничить свое время в виртуальной реальности и начать наслаждаться жизнью. Хорошего дня =)


      1. fire_engel
        12.04.2022 16:42

        Никто не должен под копирку брать решения других

        очень странно, ведь вк скопировали это у фейсбука))


        1. kateridzhe
          12.04.2022 16:59
          +2

          Я думаю, вы говорите про дизайн. Но данная статья не про дизайн, а про техническую реализацию определенной задачи.

          Если вы утверждаете, что техническое решение скопировано, то аргументируйте, пожалуйста.


      1. namikiri
        12.04.2022 16:46
        +1

        Внимание, вопрос: где негативные реакции? Почему нельзя поставить дизлайк? "Злость" — это не то, посты, которым хочется поставить дизлайк, вызывают отвращение, а не злость.


        1. u007
          13.04.2022 22:24

          Нельзя ставить дизлайки. Котятки расстроятся.


          1. namikiri
            14.04.2022 10:17

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


  1. fire_engel
    12.04.2022 16:43
    +2

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


    1. mukhinid
      13.04.2022 10:06
      +4

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