Когда мы впервые представили Reddit Recap нашим пользователям в конце 2021 года, он завоевал огромную популярность, и мы знали, что он вернется в 2022 году. И хотя между выпусками прошел всего год, способ создания мобильных приложений в Reddit кардинально изменился, что заставило нас перестроить работу с Recap с нуля, сделав его более ярким, с богатой анимацией и расширенными возможностями шаринга.
Одним из самых значительных изменений стало внедрение Jetpack Compose и нашей архитектуры презентаций на основе композиций. Чтобы полностью использовать нашу реактивную UI-архитектуру, мы решили переписать весь пользовательский интерфейс с нуля на Compose. Мы посчитали, что это стоит того, поскольку Compose позволит нам выразить наш пользовательский интерфейс с помощью простых, многократно используемых компонентов.
В этом посте мы расскажем о том, как мы использовали Jetpack Compose для создания нового блестящего опыта Reddit Recap для наших пользователей, как создавали многократно используемые UI-компоненты, использовали декларативные анимации и делали их плавными. Надеемся, что вы будете так же увлечены Compose, как и мы, после того, как узнаете о нашем опыте.
Многократно используемые макеты
Для тех из вас, кто еще не успел воспользоваться Reddit Recap, объясним, что это коллекция различных карточек, которые причудливо описывают, как пользователь использовал Reddit за последний год. С точки зрения пользовательского интерфейса, большинство этих карточек похожи — состоят из верхней части с графикой или инфографикой, заголовка, подзаголовка и общих элементов, таких как кнопки «закрыть» и «поделиться».
Учитывая эту структуру, Compose сделал для нас очень удобным создание шаблонной основы для каждой карточки. Такие шаблоны могут обрабатывать общие для всех карточек операции, такие как позиционирование каждого компонента, обработка отступов для разных размеров экранов, управление базовой анимацией и многое другое. Для примера, наша общая карточка, отображающая иллюстрацию, заголовок и текст, может быть объявлена следующим образом:
Затем мы можем создать Composable функцию для каждого типа карточки, которая использует шаблон, передавая данные для различных стилей карт с помощью слотов содержимого.
Декларативные анимации
В 2022 году мы хотели повысить уровень Recap и сделать его более восхитительным, более интерактивным с помощью анимаций. Compose сделал создание анимаций и трансформаций интуитивно понятным, позволив нам просто объявлять, как должна выглядеть анимация, вместо того чтобы заниматься внутренними вопросами.
Мы использовали анимации входа и выхода, которые были общими для всех карточек, а также некоторые собственные анимации для уникальной карты способностей пользователя (блестящая серебряная карта в приведенном выше примере). Когда мы впервые обсуждали добавление этих анимаций, возникли некоторые сомнения по поводу сложности. В прошлом нам приходилось решать некоторые проблемы при работе с анимациями в Android View System в плане управления ими, отмены и работе с состоянием представления.
К счастью, Compose абстрагируется от этого, поскольку анимации выражаются декларативно, в отличие от View. Фреймворк отвечает за отмену, возобновление и обеспечение корректных состояний. Это было особенно важно для Recap, где состояние анимации привязано к прокрутке, и ручное управление анимациями было бы громоздким.
Мы начали встраивать анимации входа и выхода в наш шаблон, обернув каждый анимированный компонент в композит AnimatedVisibility. Этот компонент принимает булево значение, которое используется для запуска анимации. Мы добавили отслеживание видимости в наш вертикальный пейджер содержимого верхнего уровня (который прокручивает все карточки Recap), который передает флаг видимости каждой composable карточке в Recap. Затем каждая карточка может передать флаг видимости в компоновочный каркас или использовать его напрямую для добавления пользовательской анимации. AnimatedVisibility поддерживает большинство необходимых нам функций, таких как тип перехода, задержки, длительность. Однако мы столкнулись с одной проблемой — обрезанием анимированного содержимого. Чтобы решить эту проблему, мы обернули некоторые анимированные composable элементы в Box с дополнительным паддингом для предотвращения обрезания.
Чтобы упростить добавление этих анимаций, мы создали набор composable элементов, которые мы обернули вокруг наших анимированных макетов следующим образом:
Создание уникальной карточки способностей пользователя
Особенностью Reddit Recap является то, что каждый пользователь получает уникальную карточку способностей, которая подводит итоги того, рассказывает о том, как он провел свой год на Reddit. Когда мы только запустили Recap, мы заметили, что пользователям нравится делиться этими карточками в социальных сетях, поэтому в этом году мы хотели создать что-то действительно особенное.
Сложность создания карточки способностей заключалась в том, что нам нужно было уместить на относительно небольшом пространстве большое количество настраиваемого контента, который отличается для каждого пользователя и языка. Для достижения этой цели мы сначала хотели использовать ConstraintLayout, но решили не идти этим путем, так как он делает код более трудным для понимания и не дает преимущества в производительности по сравнению с использованием вложенных composable-элементов. Вместо этого мы использовали Box, который позволил нам выровнять дочерние элементы и добиться относительного позиционирования с помощью модификатора padding, принимающего процентные значения. Это сработало достаточно хорошо. Однако размер текста стал проблемой, особенно когда мы начали тестировать эти карточки на разных языках. Чтобы смягчить проблемы с масштабированием текста и обеспечить единообразие восприятия при различных размерах и плотности экрана, мы решили использовать фиксированный масштаб текста и его динамическое масштабирование (уменьшение масштаба текста по мере увеличения его длинны).
Как только макет был завершен, мы начали искать способы превратить эту статичную карту в забавный интерактивный опыт. Наш моушн-дизайнер поделился анимацией Pokemon Card Holo Effect в качестве вдохновения для того, чего мы хотели достичь. Несмотря на наши опасения по поводу сложности макета, мы обнаружили, что Compose позволяет легко создать такую анимацию в виде одного модификатора макета, который мы можем просто применить к корневому композиту нашего макета карты способностей. В частности, мы создали новый stateful модификатор с помощью composed функции (примечание: это можно изменить на использование Modifier.Node, что обеспечивает лучшую производительность), в котором мы наблюдали состояние вращения устройства (используя SensorManager API) и применяли вращение к макету с помощью модификатора graphicsLayer с (затухающим) шагом в зависимости от наклона устройства rotationX и rotationY. Используя DisposableEffect, мы можем управлять подпиской SensorManager без необходимости явной очистки подписки в пользовательском интерфейсе.
Это выглядит примерно так:
Применение модификатора graphicsLayer к корневому composable карты способностей позволило нам получить изящный эффект, который следует за вращением устройства, одновременно выполняя очистку ресурсов Sensor после завершения композиции. Чтобы сделать эту функцию действительно яркой, мы добавили голографический эффект.
Мы обнаружили, что этот эффект можно создать с помощью анимации градиента, который накладывается поверх макета карты, и использования смешивания цветов с помощью BlendMode.ColorDodge при рисовании градиента. Смешивание цветов — это процесс рисования элементов на холсте, который по умолчанию использует BlendMode.SrcOver, который просто рисует поверх существующего содержимого. Для гало-эффекта мы используем BlendMode.ColorDodge, который делит цель на инверсию исходника. Удивительно, но в Compose это довольно просто:
Для градиента мы создали класс AngledLinearGradient, который расширяет ShaderBrush и определяет начальные и конечные координаты линейного градиента с помощью угла и смещения при наклоне. Чтобы нарисовать градиент поверх содержимого, мы можем использовать модификатор drawWithContent и установить режим смешивания цветов для создания эффекта гало.
Теперь у нас есть возможность применить эффект гало к любому составному элементу, просто добавив Modifier.applyHoloAndRotationEffect(). В научных целях мы проверили это на корневом макете нашего приложения, и поверьте мне, это до смешного красиво.
Как по маслу
Однако после добавления анимации мы столкнулись с проблемами производительности. Причина проста: большинство анимаций вызывают частые рекомпозиции, что означает, что любая анимация верхнего уровня (например, анимация цвета фона) потенциально может вызвать рекомпозиции всех несвязанных элементов пользовательского интерфейса. Поэтому важно сделать так, чтобы наши composable пропускались (то есть композиция может быть пропущена, если все параметры равны их предыдущему значению). Мы также убедились, что все параметры, которые мы передаем в composable, такие как UiModels, immutable или стабильны, что является требованием для пропуска элементов.
Для диагностики того, соответствуют ли наши composable и модели этим критериям, мы использовали Compose Compiler Metrics. Они дали нам информацию о стабильности параметров composable и позволили нам обновить наши UiModel и композиции, чтобы убедиться, что их можно пропустить. Мы столкнулись с несколькими трудностями. Сначала мы не использовали immutable коллекции, что означало, что наши параметры списка были изменяемыми и, следовательно, composable, использующие эти параметры, не могли быть пропущены. Это было легко исправить. Другая неожиданная проблема, с которой мы столкнулись, заключалась в том, что, хотя наши composable можно было пропускать, мы обнаружили, что при повторном создании лямбд они не считались равными предыдущим экземплярам, поэтому мы обернули обработчик события в вызов remember, как показано ниже:
Как только мы сделали все наши composable пропускаемыми и обновили наши UiModel, мы сразу же заметили большой прирост производительности, что привело к действительно плавной прокрутке. Еще одной лучшей практикой, которой мы следовали, было откладывание чтения состояния на тот момент, когда оно действительно необходимо, что в некоторых случаях устраняло необходимость перекомпоновки. В результате анимация работала плавно, и мы были уверены, что рекомпозиция произойдет только тогда, когда это действительно необходимо.
Делиться — значит заботиться
Нашим новым потрясающим опытом стоило поделиться с друзьями, и мы заметили это еще во время игрового тестирования: люди с удовольствием показывали свои карточки способностей и статистику. Это сделало работу над функцией обмена очень важной. Чтобы сделать шаринг плавным и беспроблемным, с последовательными изображениями, мы вложили много сил в создание этой функции. Наши цели — дать возможность поделиться любой карточкой на других социальных платформах или скачать ее, и при этом сделать так, чтобы карточки выглядели одинаково на разных платформах и типах устройств. Кроме того, мы хотели иметь различные соотношения сторон для общего контента для таких приложений, как Twitter или истории Instagram*, и настраивать фон карточки в зависимости от ее типа.
Хотя это звучит устрашающе, Compose упростил нам задачу, потому что мы смогли использовать те же composable, которые мы использовали в основном UI для рендеринга нашего шаринга. Чтобы карточки выглядели единообразно, мы использовали фиксированный размер, соотношение сторон, плотность экрана и масштаб шрифта, и все это можно было сделать с помощью CompositionLocals и Modifiers. К сожалению, мы не смогли найти способ сделать снепшот composable, поэтому мы использовали AndroidView, в котором размещался composable, чтобы сделать снимок.
Наш код для захвата карточки выглядела примерно так:
Мы можем легко переопределять масштабы шрифтов, плотность верстки и использовать фиксированный размер, обернув наш контент в набор composable. Одна оговорка заключается в том, что нам пришлось дважды применить переопределение плотности, поскольку мы переходим от composable к Views и обратно к composable. Под капотом для рендеринга контента используется RedditComposeView, он ожидает рендеринга изображений из кэша и создания снимка экрана с помощью view.drawToBitmap(). Мы интегрировали эту логику рендеринга в наш поток шаринга, который вызывает рендеринг для создания предварительного просмотра карточки, которой мы затем делимся с другими приложениями. На этом путешествие пользователя через Recap завершается, и все это происходит без проблем с помощью Compose.
Recap (подведение итогов)
Мы были очень рады предоставить нашим пользователям восхитительный опыт с богатой анимацией и возможностью поделиться с друзьями своим годом на Reddit. По сравнению с предыдущим годом, Compose позволил нам сделать гораздо больше вещей с меньшим количеством строк кода, большим количеством повторно используемых компонентов пользовательского интерфейса и более быстрой итерацией. Добавление анимации было интуитивно понятным, а возможность создания пользовательских модификаторов состояния, как мы это сделали для голографического эффекта, показывает, насколько мощным является Compose.