Как мы сделали дизайн-систему для мобильных устройств и TV

от автора

Привет, Хабр! Меня зовут Вячеслав Таранников, я ведущий Android-разработчик в RuStore, и сегодня расскажу о нашей дизайн-системе, разобрав две ключевые темы: токены и компоненты.

Эта статья основана на моем совместном докладе с Дмитрием Смирновым, руководителем команды разработки, — «Как мы создали дизайн-систему для мобильных устройств и ТV на Jetpack Compose». Мы представили его на митапе «Coffee&Code ✕ RuStore | TechBrew» и теперь делимся основными идеями с вами.

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

Дизайн-система

Что такое дизайн-система? Система — это сложная структура, состоящая из разных элементов, работающих по определенным правилам и механизмам. Слово «Дизайн» подсказывает нам, в каком контексте используется и приносит пользу эта система.

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

Ключевые свойства

  • Единая концепция. У дизайн-системы должна быть идея, от которой выстраивается интерфейс. Это обеспечивает понятную и консистентную коммуникацию с пользователями.

  • Метаязык. Важно придерживаться общего языка для общения между платформами и дизайном. Например, слово «ячейка» понимается одинаково дизайнером, разработчиком и другими участниками процесса.

  • Библиотечность. Дизайн-система должна иметь понятную структуру, документацию и гайдлайны по применению с лучшими практиками. Также в приоритете — хорошее и понятное API, которое поддерживает обратную совместимость.

Почему не взять готовое?

Перед тем как разработчик начинает делать что-то сложное, он поищет готовые решения на рынке. Мы тоже пошли по этому пути и в первой итерации использовали дизайн-систему Material. Она позволила создать консистентный UI, сфокусироваться на бизнес-логике и запустить проект всего за три месяца.

Но у Material есть минусы:

  • Сам себя ограничивает. Расширить палитру и компоненты сложно. Например, не получится расширить палитру Material так, чтобы это расширение использовалось и в компонентах Material, поскольку сами компоненты не будут знать о ваших расширениях. 

  • Слишком «умный». Material часто принимает решения за разработчиков. Обнаружить это разработчики могут лишь тогда, когда откроют код Material. А дизайнеры вообще не смогут узнать об этом. Например, однажды нам попался такой скриншот, где темно-розовый текст сливается с черным фоном. Это произошло из-за того, что компонент Text был обернут в Surface от Material, который переопределил цвет компонента Text по умолчанию.

// Определение цвета Text в Material3 val textColor = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current } }
// Замена LocalContentColor в Material3 Surface fun Surface(     // <...>     color: Color = MaterialTheme.colorScheme.surface,     contentColor: Color = contentColorFor(color),     content: @Composable () -> Unit ) {     CompositionLocalProvider(         LocalContentColor provides contentColor,     ) {         // ...         content()     } }
Баг из-за переопределения LocalContentColor

Баг из-за переопределения LocalContentColor

О дизайн-системе RuStore

Рассмотрим несколько особенностей нашей дизайн-системы.

Имя

Мы присвоили дизайн-системе имя, чтобы подчеркнуть её значимость и наделить характером. Это не просто модуль в коде или платформа, а связка дизайна и платформы.

Наша дизайн-система носит имя известного художника Louis William Wain.

С чего всё началось

С чего всё началось

Архитектура

Общая схема архитектуры представлена ниже. Центральный модуль rustore-core содержит общие компоненты, которые используются во всех проектах RuStore и не зависят от платформы. Это палитры, иконки и иллюстрации. Модули web и client зависят от rustore-core. Клиентские модули делятся на mobile и tv, а web — на B2B и B2C.

Рассмотрим подробнее архитектуру мобильной имплементации. В центре находится core, который содержит общие для mobile и tv сущности – рутовую тему, палитру, общие компоненты и т.д. От core зависят mobile и tv модули, которые содержат элементы, необходимые для конкретной платформы – имплементации темы и типографики, платформенные компоненты (например, SwipeRefresh).

Все три модуля (сore, mobile и tv) содержат модули theme и components. Mobile и tv также содержат quarantine. Зависимости между модулями на схеме транзитивные: mobile-components имеет доступ и к mobile-theme, и к core-theme.

  • theme — включает базовые функцию темы и интерфейсы токенов;

  • components — переиспользуемые элементы интерфейса;

  • quarantine — содержит упрощенные компоненты, добавленные фича-командами для их задач и требующие стабилизации перед переводом в components.

Демо-приложение

Важной частью дизайн-системы является демо-приложение. Его главное назначение – это реализация подхода «код как документация», при котором разработчик на примерах может посмотреть варианты применения компонента. Также демо-приложение выполняет роль каталога и позволяет посмотреть приложения так, как они будут выглядеть на проде, исключая возможные отличия из-за разных рендерных движков у Figma и Android.

Токены

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

Как токены упрощают разработку?

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

// ❌ Don't Colors: pink700, green300  // ✅ Do Colors: accent, backgroundPositive

Токены в цветовой палитре

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

Типографика

Когда мы абстрагируемся от понятиий вроде «шрифт Roboto, кегль 22, курсив» и поразмышляем над смыслом различных стилей текста, приходим к выводу, что существует некий набор шрифтов, каждый из которых несёт определенный смысл. Семантика в виде токенов позволяет нам перейти к понятиям «заголовок», «основной текст», «подпись» и оперировать ими. На смартфоне и телевизоре заголовки будут иметь разные кегли, и об этом не нужно задумываться при вёрстке.

Другие варианты токенов

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

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

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

А как в коде?

О локальной композиции

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

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

// Передаём палитру в аргументах @Composable fun SomeScreen(palette: Palette, state: ScreenState) {     Text(         text = "Hello, habr",         color = palette.text.primary,     )     SomeWidget(palette, state.content) }  @Composable fun SomeWidget(palette: Palette, content: Content) {     // ... }

А теперь представьте, что, помимо палитры, нам также нужно передать типографику, иконки, отступы и ещё десяток параметров.

Локальная композиция решает эту проблему, передавая данные неявно (без аргументов).

Передача котиков в локальной композиции

Передача котиков в локальной композиции

Давайте рассмотрим на примере диаграммы с котиками, как работает локальная композиция. Родительский Composable предоставляет бело-синего котика, который доступен во всех наследниках. Один из наследников предоставляет рыжего котика, который доступен только уже в его наследниках, но недоступен для родителя или брата.

// Передаём палитру через локальную композицию (Material) @Composable fun MaterialTheme(colorScheme: ColorScheme) {     CompositionLocalProvider(LocalColorScheme provides colorScheme) }  object MaterialTheme {     val colorScheme: @Composable get() = LocalColorScheme.current }  // Обращаемся к локальной композиции в коде @Composable fun Icon(...) {     val iconContentColor = MaterialTheme.colorScheme.onSurfaceVariant     // ... } 

Узнать подробнее о CompositionLocal можно в официальной документации

Базовые токены на примере палитры

Первый тип токенов, который мы используем в RuStore, — это базовые токены. Они существуют на уровне темы, например, для палитры, типографики, отступов и форм. Рассмотрим пример для палитры.

Класс LouisColors содержит подгруппы цветов RuStore.

// Палитра цветов RuStore public data class LouisColors(     val Background: BackgroundColors,     val Text: TextColors,     val Icon: IconColors, )

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

// Подгруппа цветов public data class BackgroundColors(     val primary: Color,     val secondary: Color,     val accent: Color, )

Создаём светлый и темный вариант палитры, которые отличаются значением цветов, но имеют одинаковый набор значений.

// Светлый вариант палитры public val LightRuStorePalette: LouisColors = LouisColors(     Background = BackgroundColors(         primary = Color(0xFFF2F3F5),         secondary = Color(0xFFFFFFFF),         accent = Color(0xFF0077FF),     ),     Text = TextColors(         primary = Color(0xFF222222),         secondary = Color(0xFF6D7885),         accent = Color(0xFF0077FF),     ), )
// Тёмный вариант палитры public val DarkRuStorePalette: LouisColors = LouisColors(     Background = BackgroundColors(         primary = Color(0xFF000000),         secondary = Color(0xFF1A1C20),         accent = Color(0xFF0072E5),     ),     Text = TextColors(         primary = Color(0xFFEBF1F6),         secondary = Color(0xFF96A6B1),         accent = Color(0xFF2994FF),     ), )

На уровне темы решаем, какой из вариантов добавить в локальную композицию.

// Добавляем токены через композицию private val LocalColors = staticCompositionLocalOf<LouisColors> {     error("No LouisColors palette provided!") }  @Composable public fun LouisTheme(     // isSystemInDarkTheme() проверяет, какой цветовой режим установлен на устройстве пользователя     isDark: Boolean = isSystemInDarkTheme(),     content: @Composable () -> Unit, ) {     val palette = if (isDark) DarkRuStorePalette else LightRuStorePalette     CompositionLocalProvider(       LocalColors provides palette,     ) {         content()     } }

По аналогии с Material, у нас есть объект Louis для доступа к токенам композиции, к которым мы обращаемся, чтобы получить нужное значение.

//Даём доступ к токенам public object Louis {     public val Colors: LouisColors         @Composable @ReadOnlyComposable get() = LocalColors.current }

И, наконец, используем токен. Тема вызывается в рутовом активити. Внутри дочерних Composable мы вызываем объекты Louis и обращаемся к палитре. Это позволяет нам в точках применения не задумываться о том, какая тема используется: светлая или тёмная. Мы просто обращаемся по названию токена.

// В рутовом активити вызываем функцию темы LouisTheme {     // <...> }
// В фиче-экране обращаемся к токену Text(     text = "Привет, habr",     color = Louis.Colors.Text.primary, )

Базовые токены на примере типографики

Рассмотрим еще один пример для типографики. У нас есть класс LouisTypography, который содержит разные текстовые стили. Хочется, чтобы один и тот же стиль по-разному выглядел на мобильных устройствах и TV.

// Текстовые стили RuStore public data class LouisTypography(     val Headline: TextStyle,     val Body: TextStyle,     val Caption: TextStyle, )

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

// Токены типографики для мобильных устройств val mobileTypography = LouisTypography(     Headline = TextStyle(         fontSize = 22.sp,         lineHeight = 28.sp,     ),     // ... )
// Токены типографики для TV val tvTypography = LouisTypography(     Headline = TextStyle(         fontSize = 36.sp,         lineHeight = 44.sp,     ),     // ... )

  Добавляем локальную композицию для хранения типографики.

//Добавляем типографику в базовую тему  @Composable public fun LouisTheme(     typography: LouisTypography,     isDark: Boolean,     content: @Composable () -> Unit, ) {     val palette = if (isDark) DarkRuStorePalette else LightRuStorePalette     CompositionLocalProvider(         LocalColors provides palette,         LocalTypography provides typography,     ) {         content()     } }

Чтобы учесть разделение на две платформы, создаём две отдельные темы для мобильных устройств и TV в отдельных модулях. Эти темы будут вызывать рутовую тему с платформенным имплементациями токена.

// Создаем тему для мобильных устройств @Composable public fun MobileTheme(     isDark: Boolean = isSystemInDarkTheme(),     content: @Composable () -> Unit, ) {     LouisTheme(         typography = MobileTypography,         isDark = isDark,         content = content,     ) }
// Создаем тему для TV @Composable public fun TvTheme(     isDark: Boolean = isSystemInDarkTheme(),     content: @Composable () -> Unit, ) {     LouisTheme(         typography = TvTypography,         isDark = isDark,         content = content,     ) }

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

// В рутовом активити для платформы вызываем соответствующую тему MobileTheme {     // ... }  // Или TvTheme {     // ... }
//Добавляем обращение к токену типографики Text(     text = "Привет, habr!",     color = Louis.Colors.Text.primary,     style = Louis.Typography.Headline, )

Обе платформенные темы оборачивают передаваемый контент в core-тему, поэтому у нас остаётся доступ к токенам, которые выставляются на уровне core-темы.

Платформенные токены на примере мобильной палитры

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

Если вы пользовались RuStore, то наверняка видели баннер с просьбой разрешить присылать уведомления. Этот компонент баннера существует только на мобильных устройствах, и для него мы сделали отдельную мобильную палитру, доступ к которой есть только в скоупе мобильной темы. Сделали мы это при помощи платформенных базовых токенов.

Платформенные токены существуют только в скоупе платформенной темы. Рассмотрим пример для палитры, доступной только для мобильных устройств.

Создаем новый класс палитры в модуле mobile-theme и описываем в нём две реализации для светлой и тёмной темы.

public data class LouisMobileColors(     val infoBanner: Color,     // ... )  val LightMobilePalette = LouisMobileColors(...) val DarkMobilePalette = LouisMobileColors(...)
// Добавляем платформенные токены в тему @Composable public fun MobileTheme(isDark: Boolean) {     val extendedPalette = if (isDark) DarkMobilePalette else LightMobilePalette     CompositionLocalProvider(         LocalMobileColors provides extendedPalette,     ) {         LouisTheme(...)     } }
// Используем платформенный токен в коде MobileTheme {     Box(         modifier = Modifier.background(LouisMobile.ExtendedColors.infoBanner)     ) }

Эти токены будут доступны только в скоупе мобильной темы, и если попытаться обратиться к ним из TV-темы, то произойдёт рантайм краш из-за пустой композиции.

Компоненты UI

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

Ранее я рассказывал о базовых токенах, а теперь рассмотрим токены компонентов.

Токены компонентов на примере кнопок

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

Рассмотрим создание токенов компонентов на примере кнопок. Ниже представлены разные варианты состояния кнопок.

Все возможные состояния базовой кнопки в RuStore

Все возможные состояния базовой кнопки в RuStore

Можно выделить следующие токены кнопки:

  • форма M и L;

  • палитры Accent и Critical, у каждой есть имплементации Primary, Secondary и Tertiary;

  • также есть состояния Default, Progress и Disabled, которые мы не считаем токенами.

Попробуем при помощи этих свойств определить одну из кнопок.

Эта кнопка определяется следующей комбинацией свойств:

  • форма M;

  • палитра Critical.Secondary;

  • состояние Default.

Токены компонента

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

Наиболее популярные токены компонента — это палитра и форма.

Палитра содержит (но не ограничивается) такие токены, как фон, цвет текста, цвет иконок, рипл.

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

Токены формы кнопки

Рассмотрим на примере, как выглядят токены формы кнопки

//Cоздаём токены формы кнопки: L и M interface ButtonForm {     internal val textStyle: TextStyle @Composable get     internal val minWidth: Dp @Composable get     internal val minHeight: Dp @Composable get      public data object Large : ButtonForm {         override val textStyle: TextStyle @Composable get() = Louis.Typography.Headline10         override val minWidth: Dp @Composable get() = 48.dp         override val minHeight: Dp @Composable get() = 48.dp     }      public data object Medium : ButtonForm {         override val textStyle: TextStyle @Composable get() = Louis.Typography.Body10         // ...     } }

Токены палитры кнопки

// Создаём токены палитры кнопки: Accent и Critical и для каждой из них имплементации Primary, Secondary и Tertiary interface ButtonPalette {     internal val background: Color @Composable get     internal val text: Color @Composable get      public sealed class Accent {         public data object Primary : ButtonPalette {             override val background: Color @Composable get() = Louis.Colors.Background.Accent             override val text: Color @Composable get() = Louis.Colors.Text.ConstantLight         }         public data object Secondary : ButtonPalette {...}         public data object Tertiary : ButtonPalette {...}     }      public sealed class Critical {         public data object Primary : ButtonPalette {...}         public data object Secondary : ButtonPalette {...}         public data object Tertiary : ButtonPalette {...}     } } 

Создаём компонент кнопки

// Создаём упрощенный компонент кнопки @Composable public fun ButtonComponent(     palette: ButtonPalette,     form: ButtonForm,     onClick: () -> Unit,     text: String, ) {     Box(         modifier = Modifier             .defaultMinWidth(minWidth = form.minWidth, minHeight = form.minHeight)             .background(palette.background)             .clickable(onClick = onClick),     ) {         Text(             text = text,             color = palette.text,             style = form.textStyle,         )     } }
// Используем кнопку ButtonComponent(     form = ButtonForm.Large,     palette = ButtonPalette.Accent.Primary,     onClick = { ... },     text = "Hello, habr", ) // Или с другой комбинацией палитры и формы ButtonComponent(     form = ButtonForm.Medium,     palette = ButtonPalette.Critical.Primary,     onClick = { ... },     text = "Hello, habr", )
Результат применения разных токенов к кнопке

Результат применения разных токенов к кнопке

Упрощаем использование

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

Значения по умолчанию

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

// Задаём значения по умолчанию для кнопки @Composable fun ButtonComponent(     palette: ButtonPalette = ButtonPalette.Accent.Primary,     form: ButtonForm = ButtonForm.Large,     onClick: () -> Unit,     text: String, )  // Используем кнопку ButtonComponent(     onClick = { ... },     text = "Hello, habr", )

Slot API

Дизайнеры любят что-то точечно закастомить, например, добавить несколько иконок рядом с текстом кнопки. Чтобы упростить кастомизацию контента, мы делаем компоненты расширяемыми с помощью Slot API. Это паттерн Compose, позволяющий передать любой контент внутрь Composable-функции.

Пример компонента TopAppBar с использованием Slot API

Пример компонента TopAppBar с использованием Slot API

Чтобы поддержать этот подход, берем старую реализацию и меняем тип входящего параметра content со String на Composable-функцию:

// Применяем Slot API  @Composable fun ButtonComponent(     palette: ButtonPalette = ButtonPalette.Accent.Primary,     form: ButtonForm = ButtonForm.Large,     onClick: () -> Unit,     content: @Composable () -> Unit, ) {     Box(     ) {         content()     } }  // Используем кнопку со Slot API ButtonComponent(     onClick = { ... } ) {     Text(...) }

Хотя теперь в компонент можно передать что угодно, мы усложнили самый популярный вариант использования — отображение текста внутри кнопки. Чтобы вернуть его, давайте доработаем компонент. Для этого мы оставим реализацию со Slot API и добавим override функцию, которая будет принимать String вместо @Composable как параметр.

@Composable fun ButtonComponent(     text: String,     palette: ButtonPalette = ButtonPalette.Accent.Primary,     form: ButtonForm = ButtonForm.Large,     onClick: () -> Unit, ) {     ButtonComponent(         palette = palette,         form = form,         onClick = onClick,     ) {         Text(             text = text,             style = form.textStyle,             // ...         )     } }

Теперь текст можно задать как через Slot API, так и через override-функцию, что позволяет кастомизировать контент.

//Slot API ButtonComponent(     onClick = { ... } ) {     Text(...) }  //Override ButtonComponent(     text = "Hello, habr",     onClick = { ... } )

Custom токены

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

Для этого мы добавим в токены компонента класс Custom, который принимает в качестве параметров класс токена, который будем изменять, а также набор нулабельных параметров для замены. Если переданный параметр null, то мы берём значение из основного класса.

// Создаём custom-класс, который наследуется от токена компонента interface ButtonPalette {     internal val background: Color @Composable get     internal val text: Color @Composable get      sealed class Accent {         data object Primary : ButtonPalette { ... }     }      //...      class Custom(         private val base: ButtonPalette = Accent.Primary,         private val customBackground: Color? = null,         private val customText: Color? = null,     ) : ButtonPalette {         override val background: Color @Composable get() = customBackground ?: base.background         override val text: Color @Composable get() = customText ?: base.text     } }

Объединим кастомные токены с использованием Slot API, и получим полностью кастомизированную кнопку.

// Обычная кнопка ButtonComponent(     onClick = { ... },     text = "Hello, habr", ) // Кастомизированная кнопка ButtonComponent(     palette = ButtonPalette.Custom(         background = Louis.Colors.Background.Positive     ),     onClick = { ... } ) {     Row {         Icon(...)         Text(...)         Icon(...)     } }

Увеличиваем абстракцию с адаптивными компонентами

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

AdaptiveDialog на телефоне и планшете

AdaptiveDialog на телефоне и планшете

Для адаптивных компонентов мы так же выделяем токены, которые матчатся с токенами используемых компонентов. Мы на проекте решили, что адаптивные компоненты не должны иметь кастомизации, чтобы избежать попыток сматчить значения токенов двух не связанных компонентов. При желании, можно выделить какие-то общие токены: например, стили текста или отступы от края компонента до контента.

public interface AdaptiveDialogForm {     data object Form1 : AdaptiveDialogForm { ... }     data object Form2 : AdaptiveDialogForm { ... } }

Напишем упрощённый код компонента.

@Composable public fun AdaptiveDialogComponent(     header: String,     body: String,     form: AdaptiveDialogForm = AdaptiveDialogForm.Form1, ) {     if (isTablet()) {         DialogComponent(             form = form.toDialogForm(),             header = header,             body = body,             // ...         )     } else {         BottomSheetComponent(             form = form.toBottomSheetForm(),             header = header,             body = body,             // ...          )     } }  private fun AdaptiveDialogForm.toDialogForm() = ... private fun AdaptiveDialogForm.toBottomSheetForm() = ...

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

Проверка нового компонента перед созданием Pull Request

Для проверки компонентов мы используем следующий чек-лист:

  • Использование базовых токенов без кастома.

  • Все вариации можно привести к заданным токенам компонента без Custom.

  • Понятно поведение компонента и его стейты.

  • Для картинок описано правило скейлинга.

  • Есть размеры компонента.

  • Понятно поведение эджкейсов.

  • Прописаны требования для невизуальной доступности.

  • Нет фиксированной высоты у компонента с текстом.

Итоги

Описанный подход к созданию дизайн-системы дает нам следующие преимущества:

  • Верстка один в один: разработчик видит в Figma и коде одно и то же, без необходимости думать о деталях.

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

  • Легкость добавления новых фич: например, новый набор иконок или дополнительная тема, новый стиль. Мы уверены, что новые фичи не поломают существующий код.

Если у вас есть дополнения или релевантный опыт по этой теме — обязательно делитесь в комментариях!


ссылка на оригинал статьи https://habr.com/ru/articles/896134/