Контейнеры UI
Структурные элементы: UIColumn стекает детей вертикально, UIRow — горизонтально, UIScrollable
делает область переполнения панируемой, UISpacer съедает свободное место. Экраны сами никогда не
прокручиваются — экран-один-длинный-поток это фиксированный хром плюс одно тело UIScrollable с
flexGrow: 1; см. screen-router.md.
Обзор
const screen = UIScreen(
UIRow(
UIText("Library").style({ fontSize: 24, fontWeight: 700, color: "white" }),
UISpacer(), // толкает счётчик к дальнему краю
UIText("12 items").style({ color: "#888" }),
).style({ px: 16, py: 12, alignItems: "center" }),
UIScrollable(
UIColumn(UIText("First").style({ color: "white" })).style({ p: 16 }),
UIColumn(UIText("Second").style({ color: "white" })).style({ p: 16 }),
).style({ flexGrow: 1, gap: 8 }) // flexGrow: 1 = занять место под шапкой
.onScroll(pos => console.log("scrolled to", pos)),
).style({ bgColor: "black", pt: "safe-top" })
screen.open()UIRow / UIColumn
UIColumn(...children: (UINodeChild | UINodeChild[])[]): UIColumn
UIRow(...children: (UINodeChild | UINodeChild[])[]): UIRowДети вариадические; аргумент-массив разворачивается на один уровень, так что
UIColumn(header, items.map(Row), footer) не нуждается в spread. Одинокий аргумент-функция —
реактивная привязка детей — см. сигналы. Единственная
разница между двумя контейнерами — дефолтный flexDirection (column против row) — оба принимают
полную поверхность стиля контейнера (UIContainerStyle = свойства элемента + рисуемого + раскладки
контейнера, см. styling.md). Дети null / undefined / false пропускаются — см.
overview.md.
UIButton — тоже полноценный контейнер, но с другими дефолтами: row плюс центрирование
детей по обеим осям — см. interactive.md.
Избегай контейнеров-обёрток, существующих только для выравнивания чего-то — выравнивание это свойство контейнера:
UIRow(UIColumn().style({ flexGrow: 1 }), label) // ✗ фантомный элемент-распорка
UIRow(label).style({ justifyContent: "flex-end" }) // ✓Управление детьми
Все контейнеры на этой странице делят один императивный API детей (работает до и после появления элемента на экране; без диффинга):
c.append(...nodes: UINodeChild[]): this
c.insert(index: number, ...nodes: UINodeChild[]): this // индекс в c.children
c.remove(...nodes: UINode[]): this // удаляет по идентичности
c.setContent(children: UINodeChild[]): this // заменить всё
c.children // readonly UINodeChild[]setContent — примитив «ре-рендера» — построй свежий массив (напр. items.map(Row)) и подмени его.
Для длинных или неограниченных данных используй UIVirtualizedList.
UIBox — устарел
UIBox(...children): UIBox // легаси — используй UIRow / UIColumnЛегаси-контейнер, чьё единственное отличие — дефолт justifyContent и alignItems в "center"
(дети центрированы по обеим осям). Оставлен для старых проектов; новый код пиши с UIRow/UIColumn
плюс явное выравнивание.
UIScrollable
Тот самый скролл-контейнер — каждая прокручиваемая область это он: тело длинного экрана (экраны сами никогда не прокручиваются — см. screen-router.md), список под закреплённой шапкой, горизонтальная карусель.
UIScrollable(...children: (UINodeChild | UINodeChild[])[]): UIScrollableДополнительные стили поверх поверхности контейнера:
scrollDirection: "horizontal" | "vertical" // по умолчанию vertical
showScrollbar: boolean
overscrollMode: "none" | "absorb" | "default" // поведение края при протягивании за контент
refreshControlColor: Color // подкрашивает спиннер pull-to-refresh
keyboardDismissMode: "interactive" | "scroll" | "none"
// как жест прокрутки в ЭТОМ scrollable прячет клавиатуру. "interactive" (по умолчанию):
// протягивание вниз над клавиатурой уводит её вслед за пальцем (ощущение чата). "scroll": любой
// драг прячет её в момент начала (ощущение результатов поиска). "none": прокрутка никогда не
// прячет. См. доктрину скрытия клавиатуры в interactive.md.
snap: "none" | "start" | "center" | "end" // по умолчанию none
// пейджинг: когда драг заканчивается, остановиться на границе прямого ребёнка вдоль оси прокрутки.
// Значение выбирает, где этот ребёнок встаёт во вьюпорте — "start" выравнивает по переднему краю,
// "center" центрирует, "end" — по заднему. Цели снапа — сами дети, поэтому их размеры могут
// различаться (карусель карточек с выглядывающими соседями — это `snap: "center"`).
// См. «Карусель / пейджер» ниже.События прокрутки (чейнятся, как все on*):
s.onScroll(cb: (scrollPosition: number) => void): UIScrollable // логические px от стартового края
// (горизонтальный сообщает смещение x)
s.onScrollRelease(cb: () => void): UIScrollable // палец поднят
s.onOverscroll(cb: (delta: number) => void): UIScrollable // протянут за край (px)Pull-to-refresh — onRefresh
s.onRefresh(cb: () => void | Promise<void>): UIScrollable // спиннер висит, пока промис не уляжетсяUIScrollable(rows)
.style({ flexGrow: 1, refreshControlColor: "#FF4032" })
.onRefresh(async () => { await load() })подключай onRefresh до монтирования элемента — хосты читают колбэк при создании узла.
Только вертикальные scrollable и только нативные хосты (веб-вьюеры — no-op).
У UIVirtualizedList те же onRefresh + refreshControlColor.
scrollTo нет — позицию UIScrollable нельзя задать программно. Это текущее ограничение. Если
нужна программная прокрутка (scrollTo / scrollToEnd / scrollToKey), используй
UIVirtualizedList.
UIScrollable по умолчанию имеет flexShrink: 1 (единственное исключение из глобального дефолта
flexShrink: 0), поэтому вертикальный scrollable в колонке сжимается и прокручивается вместо
переполнения. Оборачивающие контейнеры между ним и экраном по-прежнему дефолтятся в 0 и требуют
flexShrink: 1 вручную; верни scrollable flexShrink: 0, чтобы отказаться. flexGrow: 1 для
«занять оставшееся место» всё ещё задаёшь ты сам.
Горизонтальная карусель:
UIScrollable(items.map(Card))
.style({ scrollDirection: "horizontal", showScrollbar: false, gap: 12, px: 16 })Карусель / пейджер — snap
snap заставляет scrollable останавливаться на границах детей. Обёртки UISlider/UICarousel
нет — snap и есть примитив, а пейджер — это горизонтальный scrollable с детьми во всю ширину плюс
onScroll для отслеживания активной страницы:
UIScrollable(slides.map(s => Slide(s).style({ width: "100%" })))
.style({ scrollDirection: "horizontal", showScrollbar: false, snap: "start" })Текущая страница. События страницы нет — индекс это onScroll, делённый на ширину страницы.
Меряй шаг через onLayout (никогда не предполагай ширину экрана: паддинги, инсеты и split view её
меняют) и веди сигнал, чтобы точки/подписи к нему привязались:
const page = signal(0)
let step = 1
const track = UIScrollable(slides.map(Slide))
.style({ scrollDirection: "horizontal", snap: "start" })
.onLayout(({ width }) => { if (width > 0) step = width })
.onScroll(x => { page.value = Math.max(0, Math.min(slides.length - 1, Math.round(x / step))) })page обновляется непрерывно во время драга — он переключается, когда слайд проходит середину, что
и должен делать индикатор-точки. Колбэка «устаканился на странице n» нет; onScrollRelease
срабатывает при отрыве пальца, до конца снап-анимации.
Карусель карточек с выглядывающими соседями использует более узких детей и snap: "center". Цели
снапа — прямые дети, поэтому карточки могут быть разной ширины (счётчик по неровным детям требует их
смещений, а не одного step).
start и end учитывают собственный паддинг scrollable: при paddingHorizontal: 20 ребёнок
останавливается у 20px-жёлоба, а не вплотную к краю экрана — так позиции снапа согласуются с
естественной позицией покоя контейнера. center не зависит от паддинга по построению.
snap доводит только жесты; хосты без поддержки деградируют до свободной прокрутки (web-lite —
полностью, wasm-вьюер — пока никак). Комбинируй с onScroll для индекса — программного scrollTo
на UIScrollable по-прежнему нет (см. заметку выше), поэтому навигация тапом по точкам требует
UIVirtualizedList или ждёт закрытия этого пробела.
Перетаскиваемым детям внутри скроллера нужно claim их направление жеста, иначе прокрутка украдёт
указатель — см. touch.md.
UISpacer
UISpacer(): UISpacer // без детей; его поверхность стиля — только ElementStyle — без фонаГибкое пустое место: по умолчанию flexGrow: 1, съедает свободное место вдоль главной оси родителя.
Тянись к нему, только когда обычное выравнивание не выражает раскладку — один элемент, толкнутый к
дальнему краю, пока остальные стоят:
UIRow(title, UISpacer(), closeButton)Если все дети двигаются вместе, justifyContent ("space-between", "flex-end", …) делает ту же
работу без лишнего элемента.
Фаст-пасы компилятора — __UIColumn и компания
__UIColumn, __UIRow, __UIBox, __UIButton, __UIScreen, __UIScrollable, __UIWidget —
пути конструирования без диспетчеризации, в которые проход chisel flatten_ui опускает вызовы
фабрик, когда доказал, что аргументы — обычные дети (напр. UIColumn(a, b) → __UIColumn([a, b])).
Это вывод компилятора — никогда не вызывай их руками; API — публичные фабрики.
Смотрите также
- Модель элементов UI — дети, условный рендер, ссылки.
- Стилизация — словарь стиля контейнера (
gap,alignItems, …). - Экраны и навигация — экраны никогда не прокручиваются; фиксированный хром + прокручиваемое тело.
- Виртуализированные списки — оконный рендер + программная прокрутка.