LeCodesdocs

Стилизация UI

Система стилей, общая для каждого UI-элемента: один метод .style(), один словарь свойств, раскладка только флексбокс (движок Yoga — без CSS-grid, без блочного потока), единицы в логических px. Эта страница — канонический список свойств стиля и форм значений; специфичные для элемента добавки (objectFit, onPressed, scrollDirection, …) живут на странице самого элемента. Два механизма поверх имеют собственные страницы: переменные темы (theme(), var(--x)) и классы стилей (блоки состояний $name).

Обзор

TypeScript
const card = UIColumn(
  UIText("Title").style({ color: "white", fontSize: 20, fontWeight: 700 }),
)
  .style({ bgColor: "#111", borderRadius: 16, p: 16, gap: 8 })   // слияние — чейнится
  .style({ onLandscape: { flexDirection: "row" } })              // адаптивное переопределение

card.style.opacity = 0.5                       // мутация одного свойства (горячие пути)
card.animateTo({ bgColor: "#333", duration: 200 })   // твин к новым значениям (мс)

Три способа задать стили

TypeScript
el.style({ ... }): this        // СЛИВАЕТ в текущий стиль (не заменяет); чейнится
el.style.prop = value          // прямая запись одного свойства после создания (горячие пути)
el.style.prop                  // прочитать последнее заданное тобой значение

Слияние .style() означает, что поздние вызовы переопределяют только упомянутые ключи:

TypeScript
UIText("Hi").style({ color: "white" }).style({ fontSize: 20 })   // применяются оба

Используй прямую мутацию для покадровых обновлений, напр. el.style.transform = translateY(${y}px)``.

Любое свойство с примитивным значением также принимает функцию () => valueреактивную привязку, которая переприменяет себя, когда меняется прочитанный ею сигнал:

TypeScript
el.style({ bgColor: () => (selected.value ? "#FF4032" : "#333") })

Анимация: animateTo / animateFrom

TypeScript
el.animateTo({ opacity: 0, bgColor: "#000", duration: 300, delay?: 0, commit?: true,
               loop?: false, loopMode?: "ping-pong" }): this
el.animateFrom({ opacity: 0, duration: 300, delay?: 0 }): this   // от заданных значений → текущие
  • duration / delayмиллисекунды.
  • animateTo твинит от текущих значений к заданным и сразу коммитит цели в стиль элемента (чтобы чтения и поздние слияния видели финальное состояние). Передай commit: false, чтобы анимировать без записи стиля — напр. затухание оверлея прямо перед .hide().
  • animateFrom — входная анимация: она прыгает к заданным значениям и анимирует обратно к текущему стилю элемента. Она никогда не меняет сохранённый стиль.

Зацикливание

TypeScript
badge.animateTo({ transform: "scale(1.15)", duration: 600, loop: true })   // пульс навсегда
spin.animateTo({ transform: "rotate(360deg)", duration: 900, loop: true, loopMode: "restart" })
alert.animateTo({ opacity: 0.4, duration: 300, loop: 3 })                  // 3 цикла, затем конец
  • loop: true повторяет бесконечно; loop: n прогоняет n циклов. delay применяется один раз, перед первым циклом.
  • loopMode: "ping-pong" (по умолчанию) каждый цикл анимирует к целям и обратно — без визуального скачка. "restart" прыгает назад и проигрывает вперёд заново — для спиннеров на полный оборот и шиммеров.
  • Зацикленная анимация никогда не коммитит — это эффект, а не смена состояния. Сохранённый стиль элемента не тронут, и элемент заканчивает в базовом виде, каким бы ни был режим или счётчик.
  • Остановка: более поздний animateTo / animateFrom на элементе заменяет цикл (это и есть идиома — анимируй к состоянию покоя, чтобы остановить пульс). Цикл также останавливается, когда элемент уходит с экрана; он не возобновляется, если экран возвращается из истории роутера.
Note

тип опций также допускает layer?: number — лазейку, нацеленную на внутренний слой стиля (механизм за стилями onPressed/ориентации). Не часть поддерживаемой поверхности.

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

Словарь свойств

Раскладка — только флексбокс. Каждый элемент — флекс-контейнер, position: relative, flexDirection: column по умолчанию. Два исключения: UIRow переключает на row, а UIButton по умолчанию row с центрированием детей по обеим осям (justifyContent + alignItems "center") — форма «иконка + подпись»; см. interactive.md.

Раскладка контейнера (UIRow / UIColumn / UIScrollable / экраны / кнопки / виджеты)

TypeScript
flexDirection:  "row" | "column"
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-evenly"
alignItems:     "flex-start" | "center" | "flex-end" | "stretch"     // по умолчанию "stretch"
gap:            UIValue
flexWrap:       "nowrap" | "wrap" | "wrap-reverse"

Флекс-ребёнок (все элементы)

TypeScript
flex:        number | string
flexGrow:    number            // по умолчанию 0 — без него ничего не растёт
flexShrink:  number            // по умолчанию 0 — и ничего не сжимается
flexBase:    UIValue | "auto"  // внимание: flexBase, не flexBasis
alignSelf:   "flex-start" | "center" | "flex-end" | "stretch"
aspectRatio: number

flex: 1 разворачивается в flexGrow: 1, flexShrink: 1, flexBase: 0 — нулевой базис и есть суть. Для детей равной ширины (таб-бары, пары кнопок) ставь flex: 1 на каждого: они делят всю ось поровну. Один flexGrow: 1 распределяет только оставшееся место поверх базисов по контенту, поэтому ребёнок с более длинной подписью остаётся шире.

Размер и позиция (все элементы)

TypeScript
width, height:            UIValue | "auto"
minWidth, maxWidth,
minHeight, maxHeight:     UIValue | "auto"
position:                 "relative" | "absolute" | "static"    // по умолчанию "relative"
top, left, bottom, right: UIValue | safe-area keyword           // смещения; % разрешён
inset:                    UIValue | "auto"                      // все четыре сразу; % разрешён
boxSizing:                "border-box" | "content-box"          // по умолчанию "border-box"

inset — сокращение для top/right/bottom/left одним значением — обычный способ растянуть абсолютно позиционированного ребёнка на родителя. Полная форма всегда побеждает его, какая бы ни была объявлена последней, так что { inset: 0, top: 20 } значит «заполни, но на 20 вниз от верха»:

TypeScript
UIView().style({ position: "absolute", inset: 0 })         // оверлей во весь родитель
UIView().style({ position: "absolute", inset: "10%" })     // отступ 10% с каждого края
Note

в отличие от CSS, inset принимает одно значение — многозначной формы inset: "0 10" нет, в согласии с padding/margin в этом SDK. Для смещений по сторонам используй полные формы.

Отступы и поля (все элементы)

Сокращения и полные формы взаимозаменяемы (ppadding, ptpaddingTop, …):

TypeScript
p  (padding)                          px (paddingHorizontal)   py (paddingVertical)
pt pb pl pr                           // на сторону
m  (margin)  — also accepts "auto"    mx (marginHorizontal)    my (marginVertical)
mt mb ml mr  — also accept "auto"

mx: "auto" центрирует элемент фиксированной ширины; одиночное поле "auto" толкает его к дальней стороне.

Фон и рамка (рисуемые элементы)

Доступны на контейнерах, UIScreen, UIButton, UIInput, UIVideo. У UIText, UIImage и UISpacer есть только bgColor (плюс собственный числовой borderRadius у UIImage).

TypeScript
bgColor:    Color                                       // ≡ backgroundColor; все элементы
bgImage:    string | FetchResponse | File               // ≡ backgroundImage; только декор
bgSize:     "cover" | "contain" | "tile"                // ≡ backgroundSize
bgGradient: string                                      // один+ слоёв linear-gradient()/radial-gradient() через запятую

border:       string | number                           // "1px solid #333" или голая ширина
borderWidth:  number
borderColor:  Color
borderTop / borderRight / borderBottom / borderLeft     // сокращения на сторону
borderTopWidth / borderTopColor / …                     // полные формы на сторону

borderRadius: UIValue | string                          // строка = на угол "0 0 20 20"
borderTopLeftRadius / borderTopRightRadius /
borderBottomLeftRadius / borderBottomRightRadius: UIValue

Слои фона рисуются снизу вверх: bgColorbgImagebgGradient — поэтому градиент поверх изображения делает привычный затемняющий scrim для защиты текста:

TypeScript
.style({ bgImage: photo, bgSize: "cover",
         bgGradient: "linear-gradient(to top, rgba(0,0,0,0.7), transparent)" })

Градиенты (bgGradient)

bgGradient принимает CSS-строку градиента — linear-gradient() или radial-gradient(). Цветовые стопы принимают любой Color (hex, rgb()/rgba(), именованный, transparent) с необязательными позициями %.

Линейный — необязательное направление (to <side> / <angle>deg, по умолчанию to bottom = 180deg), затем ≥2 стопа:

TypeScript
.style({ bgGradient: "linear-gradient(135deg, #FF4032, #7B2FF7)" })
.style({ bgGradient: "linear-gradient(to right, #000 0%, #333 60%, #fff 100%)" })

Радиальный — необязательный префикс [<shape> || <extent>]? [at <position>]?, затем ≥2 стопа:

TypeScript
.style({ bgGradient: "radial-gradient(#7B2FF7, #0a0a1a)" })                        // по умолчанию: ellipse, farthest-corner, at center
.style({ bgGradient: "radial-gradient(circle at 50% 40%, #7B2FF7, #0a0a1a)" })     // circle, вне центра
.style({ bgGradient: "radial-gradient(ellipse closest-side at top left, #fff, #101014)" })
.style({ bgGradient: "radial-gradient(circle 80px at center, #fff, #101014)" })    // явный радиус

Размер радиального — это ключевое слово extent или явные радиусы. Оба, плюс форма и позиция, учитываются на каждой поверхности (браузерный предпросмотр, iOS, headless-рендерер):

Поле Значения По умолчанию
shape circle | ellipse ellipse
extent closest-side | closest-corner | farthest-side | farthest-corner farthest-corner
явные радиусы circle <len> или ellipse <len-or-%>{2} — длины это px/em/vw/calc() или % от оси бокса
at <position> ключевые слова (center, top, left, bottom right, …) или % (20% 80%) center
Точность радиального

браузерный предпросмотр рендерит каждую CSS-радиальную форму точно (это сырой CSS). На устройстве (iOS) и в headless-рендерере поля выше разрешаются 1:1. Более редкие CSS-формы вне этой таблицы (напр. позиции из 4 значений at left 10% top 20%) откатываются там к центрированному дефолту, тогда как предпросмотр остаётся точным. conic-gradient() не поддерживается.

Наслоение нескольких градиентов. Раздели несколько градиентов запятыми (как CSS background-image), чтобы сложить их — первый в списке рисуется сверху, поэтому веди теми, у кого есть transparent-области:

TypeScript
.style({ bgColor: "#0a0a1a", bgGradient:
  "radial-gradient(circle at 15% 20%, #7B2FF7, transparent 60%), " +
  "radial-gradient(circle at 85% 80%, #2FB0F7, transparent 55%)" })

Линейные и радиальные слои можно смешивать, каждый учитывает полный синтаксис выше, а стек композится на каждой поверхности (браузерный предпросмотр, iOS, headless-рендерер) — обычный способ строить мягкие мульти-свечения или «авроровые» фоны.

Note

bgImage — декор за детьми. Изображение, которое является контентом, — это UIImage — см. content.md.

Всё остальное (все элементы)

TypeScript
opacity:       number | string          // 0..1
transform:     string                   // строка в стиле CSS, напр. `translateY(12px)` — отлично для жестов
display:       "none" | "flex"          // "none" убирает из раскладки
overflow:      "visible" | "hidden"     // по умолчанию "hidden" — дети обрезаются
pointerEvents: "all" | "none"           // рисуемые элементы; "none" пропускает тапы насквозь

Значения и единицы (UIValue)

TypeScript
16                  // голое число = логические px (единица по умолчанию везде)
"50%"               // процент от родителя
"50vw" "50vh"       // ширина/высота вьюпорта
"50vmin" "50vmax"
"1.5em"             // × СОБСТВЕННЫЙ fontSize элемента (по умолчанию 14) — наследования стиля НЕТ
"calc(100vw - 32px)"
"min(...)" "max(...)" "clamp(min, val, max)"
"var(--name)"       // переменная темы, необязательный фолбэк "var(--name, 16px)" — см. theme.md
  • em разрешается относительно собственного fontSize элемента, никогда родительского — задай fontSize на том же элементе, иначе em значит «относительно 14px».
  • calc() / min() / max() / clamp() комбинируют px, единицы вьюпорта, em и ключевые слова safe-area и вкладываются свободно — но не поддерживают %.
TypeScript
width: "calc(100% - 20px)"   // ✗ % не может быть внутри calc
width: "calc(100vw - 20px)"  // ✓ используй единицы вьюпорта

Цвета (только UI-стили)

Строки цвета UI парсятся UI-движком и принимают больше, чем ColorInput со стороны движка (соглашения):

TypeScript
"#f33" "#f33c" "#ff3333" "#ff3333cc"      // hex, 3/4/6/8 цифр
"rgb(255, 51, 51)" "rgba(255, 51, 51, 0.8)"
0xff3333                                   // упакованный int
// именованные — ровно этот набор, ничего больше:
"white" "black" "red" "green" "blue" "yellow" "orange" "purple"
"gray" "cyan" "magenta" "brown" "transparent" "clear"
Note

этот парсер существует только для UI-стилей. API 2D/3D (Sprite.color, Material, фон Scene2D) принимают только hex-строки и упакованные int — rgba(...) и именованные цвета там молча становятся непрозрачным чёрным.

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

Два семейства ключевых слов краёв, разделённые по владельцу:

  • safe-top / safe-bottom / safe-left / safe-rightсырые отступы устройства (вырез, home-индикатор). Факты о железе; 0 на устройствах без них.
  • comfort-top / comfort-bottom / comfort-left / comfort-rightгде контенту комфортно начинаться. На отступе-зазоре (вырез/home-индикатор iOS, жестовая навигация — запас уже встроен) это отступ с нижней границей из настраиваемой в приложении ручки, max(safe-edge, knob). На системной панели точной высоты (статус-бар Android; трёхкнопочная панель навигации — контент на границе отступа касается хрома) ручка добавляется сверх него, safe-edge + knob. Хост объявляет, какие края — панели, поэтому один и тот же экран везде разрешается в правильное для платформы значение: таб-бар с pb: "comfort-bottom" сидит вплотную над home-индикатором iOS (34), но держит зазор над кнопочной панелью Android (48 + 4). Дефолты ручек: 12 логических px сверху, 4 снизу, 16 по горизонтали — так что comfort-left/comfort-right заодно служат гаттером страницы, а в ландшафте вырез автоматически побеждает.

Парные и всесторонние формы (каждая сторона разрешается независимо — вырез в ландшафте различает лево и право): comfort-x на px/mx, comfort-y на py/my, comfort-all и safe-all — только на сокращениях p/m.

Где они принимаются: padding и margin на сторону, смещения позиции (top/left/bottom/ right) и внутри calc()/min()/max():

TypeScript
bar.style({ pt: 8, pb: "comfort-bottom" })        // таб-бар: 34 над home-индикатором, 52 над кнопочной панелью Android, 4 на устройствах без отступов
page.style({ px: "comfort-x" })                   // гаттер страницы, который в ландшафте ещё и обходит вырез
list.style({ pb: "calc(comfort-bottom + 56px)" }) // прокручиваемый контент, освобождающий место под плавающую панель 56px

Настраивай ручки (или любой край) через тему:

TypeScript
theme({ "comfort-left": 20, "comfort-right": 20 })   // гаттер этого приложения — 20

Переменные темы — theme()

Любое значение стиля может быть переменной темы"var(--accent)" — разрешаемой вживую из единой таблицы приложения, которую ведёт theme(); повторный вызов theme() перестилизует работающий UI на месте (тёмная тема — это второй вызов). Каноническая страница — theme.md: определение переменных, паттерн аксессоров, системные ключи (color, fontFamily, primaryColor, панель UITabs, ручки комфорта), живая смена темы и replaceTransition.

TypeScript
const T = theme({ accent: "#15A34A" })     // T.accent === "var(--accent)"
label.style({ color: T.accent })

Адаптивность: onLandscape / onPortrait

Любой объект стиля может вкладывать переопределения ориентации; они сливаются сверху, когда устройство в этой ориентации, и снимаются, когда она меняется:

TypeScript
screen.style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })

Классы стилей — $name

Ключ с префиксом $ внутри .style() объявляет именованное состояние стиля — как onPressed, но с любым именем и управляемое тобой через прокси el.class. Классы каскадируют на потомков (один переключатель перестилизует целый составной контрол), а два имени переключает система: $pressed и $focused. Каноническая страница — classes.md: объявление, контракт el.class, правила каскада, зарезервированные классы и приоритет.

TypeScript
const toggle = UIButton(UIText("Dark mode")).style({
  bgColor: "#222",
  $checked: { bgColor: "#FF4032", duration: 150 },
})
toggle.onClick(() => toggle.class.checked = !toggle.class.checked)

Дефолты, которые удивляют

  • flexShrink: 0 — элементы не сжимаются под размер. Исключения — UIScrollable, UIVirtualizedList и UIPager (у них по умолчанию flexShrink: 1, поэтому они сжимаются/прокручиваются вместо переполнения) — но оборачивающим контейнерам между ними и экраном всё ещё нужен flexShrink: 1 руками.
  • flexGrow: 0 — ничего не растёт вдоль главной оси без спроса; неявных мин-размеров тоже нет.
  • alignItems: "stretch" — дети заполняют поперечную ось по умолчанию; задай размер или alignSelf, чтобы отказаться.
  • overflow: "hidden" — дети обрезаются по боксу родителя.
  • position: "relative", boxSizing: "border-box" (width/height включают отступ + рамку).
  • Фон экрана чёрный, а fontSize по умолчанию 14 — всегда задавай bgColor и color текста явно.

Логические px и дизайн-канвас

Все единицы — логические px (iOS pt / Android dp), никогда физические — fontSize: 16 выглядит одинаково на каждом устройстве. Проектируй под канвас телефона шириной ~360–430 (390 — хороший дефолт) и высотой ~670–930; вертикальное пространство скудно на экранах класса SE, поэтому длинный контент — в теле UIScrollable (сами экраны никогда не прокручиваются).

Подводные камни

TypeScript
display: "grid"                    // ✗ только флексбокс (Yoga)
lineHeight: 1.5                    // ✗ число это px (= 1.5px); множитель это "1.5em"
node.style({ width: "calc(100% - 20px)" })   // ✗ без % внутри calc — используй 100vw

Смотрите также