Привет, Хабр! Меня зовут Вячеслав Таранников, я ведущий 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 и коде одно и то же, без необходимости думать о деталях.

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

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

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