LeCodesdocs

Модель элементов UI

Как работает UI-слой в целом: элементы — это обычные объекты, создаваемые глобальными фабричными функциями, компонуемые передачей детей как аргументов, настраиваемые цепочкой и обновляемые изменением свойств — без JSX, без virtual DOM, без цикла ре-рендера. Раскладка — флексбокс (Yoga) в логических px, экранное пространство Y вниз. Эта страница про модель; остальная часть раздела:

Система стилей

  • Стилизация.style(), словарь свойств, единицы, безопасные зоны, animateTo
  • Тема — переменные theme() на всё приложение; значения var(); живая смена темы (тёмный режим — это второй вызов)
  • Классы стилей — блоки состояний $name, переключаемые через el.class; каскадируют на потомков; $pressed/$focused
  • Шрифты — макрос font(), registerFont

Элементы

Навигация и оверлеи

  • Экраны и RouterUIScreen, Router
  • UIPager и UITabs — свайпаемые вкладки, стеки на вкладку, стандартная оболочка вкладок
  • Виджеты — оверлеи UIWidget, UIModal, UIPopover, UIBottomSheet
  • NativeView — платформенные вью, зарегистрированные хостом (карта, камера, …)

Форма приложения

Паттерн, который предполагает справочник и которому следуют реальные проекты: модуль токенов один раз вызывает theme() и экспортирует аксессоры; экраны — фабричные функции в своих файлах; main.ts собирает оболочку UITabs и отдаёт её роутеру.

TypeScript
// theme.ts
theme({ color: "#131A17", primaryColor: "#15A34A" })         // текст по умолчанию + акцент UI-кита
export const colors = theme({ bg: "#F4F6F5", card: "#FFFFFF", muted: "#606B65" })

// screens/home.ts
import { colors } from '../theme'
export const homeScreen = UIScreen(
  UIScrollable(/* контент */).style({ flexGrow: 1, px: "comfort-x" }),
).style({ bgColor: colors.bg, pt: "comfort-top" })

// main.ts
import { homeScreen } from './screens/home'
import { profileScreen } from './screens/profile'
Router.init(UITabs({
  home:    { label: "Home",    icon: assetIcon("lucide:house"), screen: homeScreen },
  profile: { label: "Profile", icon: assetIcon("lucide:user"),  screen: profileScreen },
}))

Приложение с одним экраном пропускает оболочку: screen.open() — и всё.

Обзор

TypeScript
let counter = 0
let label: UIText

const screen = UIScreen(
  label = UIText("Taps: 0").style({ color: "white", fontSize: 24, fontWeight: 700 }),
  counter > 0 ? UIText("already tapped") : null,        // null-дети пропускаются
  UIButton(UIText("Tap").style({ color: "white" }))
    .style({ bgColor: "#FF4032", borderRadius: 12, p: 16, alignSelf: "flex-start" })
    .onClick(() => { label.text = `Taps: ${++counter}` }),   // меняем контент напрямую
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)", gap: 16 })

screen.open()

Фабрики, не конструкторы

Каждый элемент создаётся вызовом глобальной функции — никогда не new:

TypeScript
UIColumn(...children)            // контейнеры: дети — обычные аргументы
UIText(text)                     // элементы контента: сам контент
UIImage(src)

Фабрика принимает только контент элемента; всё остальное настраивается цепочкой — .style(), .onClick(), .append(), .animateTo() — каждый возвращает сам элемент, поэтому конструирование читается одной цепочкой.

Legacy

каждая фабрика также принимает объект стиля необязательным первым аргументом (UIText({ fontSize: 20 }, "Hi")). Он всё ещё работает и встречается в старых проектах, но новый код задаёт стили через .style().

Элементы несут строку readonly type ("column", "text", …), определяющую, что они такое.

Дети и условный рендер

Контейнеры принимают детей обычными аргументами. Аргумент-массив разворачивается на один уровень — поэтому размапленный список вставляется напрямую, без spread:

TypeScript
UIColumn(
  header,
  items.map(Row),                 // аргумент-массив — разворачивается в детей
  footer,
)

Ребёнок null, undefined или false пропускается целиком — ни элемента, ни слота раскладки — это и есть идиома условного рендера (и работает для целых блоков: falsy-аргумент тоже пропускается):

TypeScript
UIColumn(
  header,
  isLoading ? spinner : null,     // тернарник с null
  showFooter && footer,           // && с коротким замыканием
  showList && items.map(Row),     // условный блок
)

Контейнер, чей единственный аргумент — функция, трактует её как реактивную привязку детей — см. сигналы.

Legacy

довариадическая форма — один массив детей, UIColumn([a, b]) — по-прежнему работает везде (это просто правило разворачивания, применённое к одному аргументу). Новый код пиши с детьми-аргументами.

TypeScript
// ✗ пустой контейнер как заглушка (веб-привычка) — он всё равно занимает флекс-слот
UIRow(isGroup ? button : UIColumn())
// ✓ null пропускается — без фантомного элемента
UIRow(isGroup ? button : null)

Захват ссылок

Чтобы держать хэндл на вложенный элемент, используй выражение присваивания прямо среди детей — оно и задаёт переменную, и добавляет элемент:

TypeScript
let label: UIText
let input: UIInput

UIColumn(
  label = UIText("Hello"),
  input = UIInput(),
)

label.text = "Updated"       // позже
console.log(input.value)

let — это оператор, а не выражение — объявляй снаружи, присваивай внутри:

TypeScript
UIRow(let input = UIInput())   // ✗ синтаксическая ошибка

Предпочитай захваченные ссылки индексации container.children — записи children не типизированы (UINodeChild), поэтому их обратное чтение требует приведения типа.

Свойства контента против стилей

Изменяемый контент живёт прямо на элементе, не в стиле. Его установка сразу перерисовывает, смонтирован он или нет:

TypeScript
text.text = "Updated"        // UIText
input.value = ""             // UIInput / UITextArea
image.src = newUrl           // UIImage
video.player                 // UIVideo — только чтение, ссылка на его VideoPlayer

Стили несут только внешний вид и раскладку. В частности, никогда не клади контент изображения в bgImage — это фон контейнера. См. content.md.

Императивное обновление детей

Контейнеры (UIRow, UIColumn, UIScrollable, …) предоставляют прямую манипуляцию детьми — диффинга нет; ты заявляешь изменение:

TypeScript
list.setContent(items.map(Row))       // заменить всех детей
list.append(row1, row2)               // добавить в конец
list.insert(0, banner)                // добавить по индексу (в .children)
list.remove(row1)                     // удалить по идентичности
list.children                         // текущий массив (только чтение)

Все четыре работают и до, и после появления элемента на экране. Для длинных или неограниченных данных используй UIVirtualizedList вместо setContent по большому массиву.

Чтение измеренного размера — onLayout и getBoundingClientRect

Размеры существуют только после раскладки. Чтобы реагировать на бокс элемента, используй onLayout:

TypeScript
el.onLayout(({ left, top, width, height }) => { ... })   // логические px; top/left относительно родителя

Он срабатывает, когда элемент впервые получает раскладку, и снова при каждом изменении его бокса (ресайз, смена контента). Можно зарегистрировать несколько колбэков; каждый вызов чейнится. Его координаты относительны родителю — и устаревают, когда предок прокручивается (прокрутка двигает элементы без пересчёта раскладки).

Чтобы прочитать абсолютную позицию элемента по требованию — как одноимённый веб-API — используй:

TypeScript
el.getBoundingClientRect()
// { x, y, left, top, right, bottom, width, height } — пространство устройства, логические px,
// включает смещения прокрутки; читается вживую в момент вызова. null до монтирования элемента
// (и на хостах, где чтение ещё не реализовано).

Прямоугольник — в том же пространстве, в котором позиционируется UIWidget и в котором события касания сообщают clientX/clientY — что делает его примитивом якорения для дропдаунов, поповеров и тултипов: прочитай rect триггера на тапе, поставь виджет в rect.left/rect.bottom (см. UIWidget и UIModal). Якори позицией один раз при открытии; не опрашивай каждый кадр.

Переиспользуемые компоненты

«Компонент» — это просто фабричная функция, возвращающая элемент — чейнься по результату как по любому элементу. Общие стили — обычные объекты, типизированные глобальным хелпером Style<T> (только тип — нечего импортировать или инстанцировать):

TypeScript
const heading: Style<UIText> = { color: "white", fontSize: 24, fontWeight: 700 }

const Card = (title: string, subtitle: string) => UIButton(
  UIText(title).style({ fontWeight: 700, color: "white" }),
  UIText(subtitle).style({ color: "#888", fontSize: 13 }),
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })

Card("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => { ... })

В приложении с модулем токенов hex-литералы выше становятся аксессорами темы (color: colors.muted) — компоненты тогда автоматически следуют за сменой темы.

Note

каждый элемент принимает строку name в объекте стиля — семантическую метку, не стиль. Она извлекается при конструировании и выставляется на узле (el.name) как стабильный селектор для тестов и инструментов ревью.

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