Привет! Меня зовут Денис Загуменнов, я из команды ленты и рекомендаций ВКонтакте. Мы занимаемся новостной лентой, стеной, рекомендациями, комментариями, 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 — класс, который предоставляет взаимодействие с вьюхами для ReactionsFacade. Cоздание и показ поп-апа ReactionsPopupWindow происходит в ShowReactionRunnable. ReactionsPopupWindow в качестве контентной вьюхи использует кастомную вьюху ReactionsPopupView. В ней размещается бабл поп-апа и все анимации реакций. После создания поп-апа тачи передаются во вьюху ReactionsPopupView — чтобы после длинного нажатия можно было выбирать реакции, не отрывая палец от экрана. Зависимости: ReactionsPopupWindow — наследник PopupWindow, необходим для присоединения вьюхи поп-апа выбора реакций ReactionsPopupView к окну. Ссылка на ReactionsPresenter (выполняет роль классического презентера) — она содержит бизнес-логику и управляет интерфейсом. ReactionsButtonTouchDelegate — делегат, необходимый только для обработки жестов с кнопки.  Определяет жесты: Скролл — если пользователь скроллит, то отклоняет обработку тачей, чтобы тачи обрабатывались RecyclerView. Одиночное и долгое нажатие — в случае длительного нажатия открывает поп-ап и последующие тачи делегирует уже в его вьюху. Ниже показываю, как выглядят onTouch в ReactionsButtonTouchDelegate. на действие MotionEvent.ACTION_DOWN — метод onTouchDown: MotionEvent.ACTION_UP — onTouchUp: MotionEvent.ACTION_CANCEL — onTouchCancel: MotionEvent.ACTION_MOVE — onTouchMove: И ещё зависимости: ReactionsStateController — управляет логикой переходов состояний реакций в ответ на действия с UI. ReactionsViewSettings — содержит информацию с настройками отображения поп-апа.  Что можно настроить: с какой стороны и с каким отступом от якорной вьюхи будет отображаться поп-ап; отступы от экрана для отображения поп-апа реакций; размер каждой реакции в выделенном и невыделенном состояниях; цвет подсказки с названием реакции; размер шрифта подсказки названия реакции. ReactionsCallback — коллбэк для получения событий об открытии и закрытии поп-апа, выбора реакции и её отправки. В ReactionsView хранится слабая ссылка на ReactionViewHolder (позволяет анимированно обновлять интерфейс). ReactionViewHolder — интерфейс вьюхолдера, который хранит внутри себя вьюху с отображением реакции. Все вьюхолдеры с кнопкой реакций — это наследники ReactionViewHolder. ReactionsPopupWindow — наследник PopupWindow. Необходим, чтобы к окну вьюхи присоединялся поп-ап выбора реакций ReactionsPopupView.  ReactionsPopupView — вьюха, которая занимает весь экран. В onLayout размещаем поп-ап реакций и сами реакции. Поп-ап реализован через контейнер со скроллом — так поддерживаем маленькие экраны. Он содержит вьюхи, аниматор ReactionsPopupViewAnimator и делегат тачей ReactionsPopupTouchDelegate. ReactionsViewSettings — здесь находятся настройки визуального отображения.  В onMeasure измеряем все текстовые вьюхи названий реакций и размер контейнера с реакциями. В методе measureTitles измеряются вьюхи, которые используются для надписей реакций. Функция measurePopupWidth измеряет ширину поп-апа. Если она меньше родительской ширины с учётом больших отступов, то итоговая будет без изменений. В calcPopupCoordinates рассчитываются координаты места, где будет показан поп-ап, с учётом границ экрана. Про функцию измерения поговорили, теперь — о размещении контейнера. Всё это происходит в onLayout.   Подробнее опишем, что означает каждая функция. В checkAnchor проверяем, видна ли кнопка реакций и присоединена ли она к окну: Функция layoutChildLeft размещает view там, где левый верхний угол в координатах (x, y): В функции layoutReactions размещаем вьюхи реакций, входящих в набор: В layoutReactionView размещаем вьюху конкретной реакции: layoutTitle размещает titleView по центру над реакцией с границами reactionRect в границах вью-группы thisRect: Вычисляем левый отступ для надписи над реакцией, учитывая границы экрана: Вот так выглядит метод dispatchDraw в ReactionsPopupView: В функции layoutPopup вычисляем координаты и границы поп-апа: В функции drawPopup рисуем подложку и тень поп-апа с координатами, полученными в layoutPopup: Перехватываем все тачи: Все тачи передаются в ReactionsPopupTouchDelegate: Зависимости: ReactionsPopupViewAnimator — отвечает за анимации открытия, закрытия и выбора реакции. ReactionsPopupTouchDelegate — в этот класс мы делегируем все тачи от вьюхи ReactionsPopupView. ReactionsSet — набор реакций, описание каждой находится в ReactionMeta. Мы заложили возможность присвоить любой записи свой набор реакций: в объекте записи хранится идентификатор актуального набора. И в каждом таком наборе хранятся объекты реакций с названиями, ссылками на статические изображения и Lottie-анимации. В объекте реакции Reaction содержится ссылка на Lottie-анимацию, изображение, название. Для изображения передаётся массив с размерами и ссылками на нарезанные размеры реакций. Это необходимо, чтобы клиент сам выбирал оптимальное для устройства изображение — в зависимости от плотности пикселей экрана. ReactionsScrollView — скролл-вью реакций. ReactionsAccessibilityDelegate — делегат для TalkBack.  ReactionsViewSettings. ReactionsCallback. ReactionsPopupTouchDelegate — в этот класс мы делегируем все тачи от вьюхи ReactionsPopupView.  Обработка тачей происходит в ReactionsPopupTouchDelegate. В методе onTouchEvent каждый метод отвечает за обработку определённого действия: Действие MotionEvent.ACTION_DOWN — метод onTouchDown.  Необходим для того, чтобы понимать, как произошло нажатие. С этого момента наблюдается, на какую вьюху указывает тач. Когда тач находится на вьюхе, соответствующей реакции, то эта вьюха выделяется, увеличиваясь в размерах. Также если тач находится на левом и правом краю поп-апа и не все реакции поместились на экран, то автоскролл сработает в ту сторону, откуда произошло событие тача. startTouchTime — время начала взаимодействия с поп-апом, запоминается в момент прикосновения к экрану (MotionEvent.ACTION_DOWN) и очищается, когда юзер отпускает палец или отменяет жест (MotionEvent.ACTION_UP или MotionEvent.ACTION_CANCEL). Действие MotionEvent.ACTION_MOVE — метод onTouchMove.  Используется для отслеживания событий наведения на реакции или для автоскролла, если все реакции не поместились на экран и можно прокручивать возможные варианты. Автоскролл в этом случае происходит при наведении на край поп-апа. Действие MotionEvent.ACTION_UP — метод onTouchUp. Необходимо для отслеживания момента, когда палец уже не касается экрана. Если до этого был инициирован автоскролл, то он останавливается. Действие MotionEvent.ACTION_CANCEL — метод onTouchCancel.  Необходим для понимания, когда жест отменён. Останавливается автоскролл, если он был инициирован, и закрывается поп-ап. onTouchMoveScroll используется для передачи события тача из onTouchMove — это нужно для скролла списка возможных реакций. onScroll используется для обновления положения реакций и подсказок с их названиями, когда происходит скролл общего списка. Обновлять положение нужно, так как скролл — это изменение свойства и он не вызывает переразмещение вьюх. После выбора реакции происходит её «оптимистичное» выставление. То есть реакция выставляется объекту заранее, счётчик обновляется с её учётом — и всё это до получения ответа от сервера об успешности действия. По паттерну «издатель-подписчик» прокидывается событие обновления реакции у всех вьюх, относящихся к объекту, к которому выставляется реакция. Это происходит на всех экранах, которые находятся в стеке навигации. Также при открытии поп-апа мы передавали ссылку на вьюхолдер и вьюху, откуда произошло действие. В этой вьюхе воспроизводится анимация выбранной реакции. Событие нажатия на кнопку. Если между ACTION_DOWN и ACTION_UP прошло меньше времени, чем ViewConfiguration.getLongPressTimeout(), то вызывается performClick. Это происходит у вьюхи, откуда был тач, и поп-ап закрывается. По ACTION_CANCEL поп-ап также закрывается. Также если было событие ACTION_MOVE и дистанция изменилась на ViewConfiguration.get(context).scaledTouchSlop, то это считается за скролл. В таком случае поп-ап не открывается. Событие длительного нажатия. По событию ACTION_DOWN запускается отложенный Runnable через ViewConfiguration.getLongPressTimeout() миллисекунд. Если любые события произошли за это время, то этот Runnable отменяется. Как только поп-ап открывается, все тачи обрабатываются им. Для ответного отклика-вибрации при открытии поп-апа вызывается метод хаптика view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS). Зависимости: AutoScrollDelegate — делегат, необходимый для автоскролла в поп-апе реакций. ScrollTouchDetector — делегат для определения скролла по событиям тача. SelectDelegate — делегат, который содержит логику выбора реакции. Выделение вьюхи происходит при выполнении SelectReactionRunnable через время tapTimeout.  В SelectReactionRunnable находится запуск аниматоров — нужны при изменении состояния выделенной реакции. В коде выше 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 — помогает людям с нарушениями зрения и в разных ситуациях, когда неудобно смотреть на экран. В этом режиме 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. Всего n + 1 виртуальных вьюх, где n — количество реакций. +1 — так как нажатие на область вне поп-апа реакций используется для его закрытия.  getVirtualViewAt позволяет получить идентификатор вьюхи по координатам на экране. Все вьюхи реакций у нас будут иметь идентификаторы от 0 до n + 1, где n — количество реакций. В onPopulateEventForVirtualView заполняем событие AccessibilityEvent информацией об указанной виртуальной вьюхе. В onPopulateNodeForVirtualView заполняем AccessibilityNodeInfoCompat информацией о конкретном элементе. Указываем: текст для людей с ограниченными возможностями; фокусируемость вьюхи; её кликабельность; границы вьюхи; возможные действия с ней (AccessibilityNodeInfoCompat.ACTION_CLICK). В коллбэке onPerformActionForVirtualView выполняем указанное действие (простое или длительное нажатие) из параметра action — для элемента, связанного с идентификатором id виртуального представления. Например, так мы обрабатываем событие нажатия в режиме специального доступа: Анимированные картинки реакций выполнены в формате Lottie. Использование Lottie в общем виде выглядит так: Загрузка данных анимации с сервера в векторном формате Lottie. Кеширование данных анимации в векторном формате. Воспроизведение анимации. Алгоритм воспроизведения анимации состоит из нескольких шагов (см. ниже). Получаем начальную точку отсчёта времени: принимаем, что в этот момент будет первый кадр. Для получения следующего номера кадра вычисляем, сколько времени прошло со старта. Если это первый кадр, приступаем к пункту Б.        А. Если наступило время отрисовывать кадр анимации, то на следующем вызове onDraw нужно отрисовать битмапу на экран, а предыдущую поместить обратно в пул битмап.        Б. Параллельно с отрисовкой кадра из битмапы приступаем к переводу векторного Lottie в битмапу: получаем битмапу из пула (битмапа берётся из пула, если конкретная битмапа указанного размера существует; если нет, создаётся новая); запускаем алгоритм перевода векторного формата Lottie в растровый — в конкретную битмапу для указанного кадра на фоновом потоке. Шаги следует повторять, пока: — анимация запущена; — вьюха на экране; — количество раз полного воспроизведения анимации меньше максимального количества повторов воспроизведений (необходимо на случай, если надо показать анимацию ограниченное количество раз). Для перевода Lottie из векторного в растровый формат используется библиотека RLottie (github.com/Samsung/rlottie), она реализована на C++. В Android приложение подключается через NDK. В результате выполнения RLottie битмапа наполняется пикселями указанного кадра анимации. В метаданных Lottie хранится фреймрейт и общее количество кадров. В приложении используется выделенный планировщик с отдельным пулом потоков специально для Lottie. Чтобы не пересоздавать каждый раз битмапы, используется пул битмап. Чтобы слишком часто не показывать пользователям заглушки, мы используем предзагрузку изображений в кеш: на несколько элементов списка вперёд, по колбэку скролла. Когда карточка появляется на экране, изображение уже заранее прогружено и берётся из кеша. Чтобы избегать случаев, когда открывается поп-ап и ещё прогружаются анимации, мы предзагружаем также анимации реакций для постов. Так как у нас технически заложено, что у каждого поста свой набор реакций, то событие для загрузки реакций вызывается на каждый пост. Но там стоят проверки: была ли уже загружена анимация реакции ранее, или она не прогрузилась из-за ошибки, или она сейчас всё ещё загружается. Здесь downloading — множество url, которые предзагружаются прямо сейчас; loaded — множество успешно загруженных url; errors — словарь, где каждому url соответствует количество ошибок. В статье описана схема взаимодействия компонентов реакций, их поп-апов, обработка тачей, работа с режимом чтения с экрана, а также механизм предзагрузки и воспроизведения Lottie-анимаций. Используя логику показа поп-апа и работы с тачами, вы можете реализовывать простые всплывающие окошки с собственным дизайном и интуитивно понятным интерфейсом взаимодействия. Описанная реализация режима чтения с экрана поможет создать доступный интерфейс для людей с нарушениями зрения — даже в случае полностью кастомных вьюх, когда простого интерфейса в виде View.setContentDescription недостаточно. Простой механизм предзагрузки позволяет избавиться от заглушек загрузки реакций. А подход к отрисовке Lottie-анимаций можно использовать и для других случаев, где каждый кадр вычислимо «тяжёлый» и его нужно предварительно рендерить на другом потоке.  Программист-разработчик