Модель элементов UI
Как работает UI-слой в целом: элементы — это обычные объекты, создаваемые глобальными фабричными функциями, компонуемые передачей детей как аргументов, настраиваемые цепочкой и обновляемые изменением свойств — без JSX, без virtual DOM, без цикла ре-рендера. Раскладка — флексбокс (Yoga) в логических px, экранное пространство Y вниз. Эта страница про модель; остальная часть раздела:
Система стилей
- Стилизация —
.style(), словарь свойств, единицы, безопасные зоны,animateTo - Тема — переменные
theme()на всё приложение; значенияvar(); живая смена темы (тёмный режим — это второй вызов) - Классы стилей — блоки состояний
$name, переключаемые черезel.class; каскадируют на потомков;$pressed/$focused - Шрифты — макрос
font(),registerFont
Элементы
- Контейнеры —
UIRow,UIColumn,UIScrollable,UISpacer - Элементы контента —
UIText,UIImage,UIVideo,assetIcon - Интерактивные элементы —
UIButton,UIInput,UITextArea - Виртуализированные списки —
UIVirtualizedListдля длинных лент и чатов
Навигация и оверлеи
- Экраны и Router —
UIScreen,Router - UIPager и UITabs — свайпаемые вкладки, стеки на вкладку, стандартная оболочка вкладок
- Виджеты — оверлеи
UIWidget,UIModal,UIPopover,UIBottomSheet - NativeView — платформенные вью, зарегистрированные хостом (карта, камера, …)
Форма приложения
Паттерн, который предполагает справочник и которому следуют реальные проекты: модуль токенов один
раз вызывает theme() и экспортирует аксессоры; экраны — фабричные функции в своих
файлах; main.ts собирает оболочку UITabs и отдаёт её роутеру.
// 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() — и всё.
Обзор
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:
UIColumn(...children) // контейнеры: дети — обычные аргументы
UIText(text) // элементы контента: сам контент
UIImage(src)Фабрика принимает только контент элемента; всё остальное настраивается цепочкой — .style(),
.onClick(), .append(), .animateTo() — каждый возвращает сам элемент, поэтому конструирование
читается одной цепочкой.
каждая фабрика также принимает объект стиля необязательным первым аргументом
(UIText({ fontSize: 20 }, "Hi")). Он всё ещё работает и встречается в старых проектах, но новый
код задаёт стили через .style().
Элементы несут строку readonly type ("column", "text", …), определяющую, что они такое.
Дети и условный рендер
Контейнеры принимают детей обычными аргументами. Аргумент-массив разворачивается на один уровень — поэтому размапленный список вставляется напрямую, без spread:
UIColumn(
header,
items.map(Row), // аргумент-массив — разворачивается в детей
footer,
)Ребёнок null, undefined или false пропускается целиком — ни элемента, ни слота раскладки —
это и есть идиома условного рендера (и работает для целых блоков: falsy-аргумент тоже
пропускается):
UIColumn(
header,
isLoading ? spinner : null, // тернарник с null
showFooter && footer, // && с коротким замыканием
showList && items.map(Row), // условный блок
)Контейнер, чей единственный аргумент — функция, трактует её как реактивную привязку детей — см. сигналы.
довариадическая форма — один массив детей, UIColumn([a, b]) — по-прежнему работает
везде (это просто правило разворачивания, применённое к одному аргументу). Новый код пиши с
детьми-аргументами.
// ✗ пустой контейнер как заглушка (веб-привычка) — он всё равно занимает флекс-слот
UIRow(isGroup ? button : UIColumn())
// ✓ null пропускается — без фантомного элемента
UIRow(isGroup ? button : null)Захват ссылок
Чтобы держать хэндл на вложенный элемент, используй выражение присваивания прямо среди детей — оно и задаёт переменную, и добавляет элемент:
let label: UIText
let input: UIInput
UIColumn(
label = UIText("Hello"),
input = UIInput(),
)
label.text = "Updated" // позже
console.log(input.value)let — это оператор, а не выражение — объявляй снаружи, присваивай внутри:
UIRow(let input = UIInput()) // ✗ синтаксическая ошибкаПредпочитай захваченные ссылки индексации container.children — записи children не типизированы
(UINodeChild), поэтому их обратное чтение требует приведения типа.
Свойства контента против стилей
Изменяемый контент живёт прямо на элементе, не в стиле. Его установка сразу перерисовывает, смонтирован он или нет:
text.text = "Updated" // UIText
input.value = "" // UIInput / UITextArea
image.src = newUrl // UIImage
video.player // UIVideo — только чтение, ссылка на его VideoPlayerСтили несут только внешний вид и раскладку. В частности, никогда не клади контент изображения в
bgImage — это фон контейнера. См. content.md.
Императивное обновление детей
Контейнеры (UIRow, UIColumn, UIScrollable, …) предоставляют прямую манипуляцию детьми —
диффинга нет; ты заявляешь изменение:
list.setContent(items.map(Row)) // заменить всех детей
list.append(row1, row2) // добавить в конец
list.insert(0, banner) // добавить по индексу (в .children)
list.remove(row1) // удалить по идентичности
list.children // текущий массив (только чтение)Все четыре работают и до, и после появления элемента на экране. Для длинных или неограниченных данных
используй UIVirtualizedList вместо setContent по большому
массиву.
Чтение измеренного размера — onLayout и getBoundingClientRect
Размеры существуют только после раскладки. Чтобы реагировать на бокс элемента, используй
onLayout:
el.onLayout(({ left, top, width, height }) => { ... }) // логические px; top/left относительно родителяОн срабатывает, когда элемент впервые получает раскладку, и снова при каждом изменении его бокса (ресайз, смена контента). Можно зарегистрировать несколько колбэков; каждый вызов чейнится. Его координаты относительны родителю — и устаревают, когда предок прокручивается (прокрутка двигает элементы без пересчёта раскладки).
Чтобы прочитать абсолютную позицию элемента по требованию — как одноимённый веб-API — используй:
el.getBoundingClientRect()
// { x, y, left, top, right, bottom, width, height } — пространство устройства, логические px,
// включает смещения прокрутки; читается вживую в момент вызова. null до монтирования элемента
// (и на хостах, где чтение ещё не реализовано).Прямоугольник — в том же пространстве, в котором позиционируется UIWidget и в котором события
касания сообщают clientX/clientY — что делает его примитивом якорения для дропдаунов, поповеров
и тултипов: прочитай rect триггера на тапе, поставь виджет в rect.left/rect.bottom
(см. UIWidget и UIModal). Якори позицией один раз при открытии; не опрашивай
каждый кадр.
Переиспользуемые компоненты
«Компонент» — это просто фабричная функция, возвращающая элемент — чейнься по результату как по
любому элементу. Общие стили — обычные объекты, типизированные глобальным хелпером Style<T> (только
тип — нечего импортировать или инстанцировать):
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) — компоненты тогда автоматически следуют за сменой темы.
каждый элемент принимает строку name в объекте стиля — семантическую метку, не стиль. Она
извлекается при конструировании и выставляется на узле (el.name) как стабильный селектор для тестов
и инструментов ревью.
Смотрите также
- Стилизация — модель
.style(), которую предполагает эта страница. - Тема — переменные на всё приложение и живая смена темы.
- Классы стилей — именованные состояния стилей, каскад.
- События указателя и жесты — объекты событий
onClick/onTouchStart. - Соглашения — логические px, цепочки, правила жизненного цикла.