LeCodesdocs

Контейнеры UI

Структурные элементы: UIColumn стекает детей вертикально, UIRow — горизонтально, UIScrollable делает область переполнения панируемой, UISpacer съедает свободное место. Экраны сами никогда не прокручиваются — экран-один-длинный-поток это фиксированный хром плюс одно тело UIScrollable с flexGrow: 1; см. screen-router.md.

Обзор

TypeScript
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

TypeScript
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.

Избегай контейнеров-обёрток, существующих только для выравнивания чего-то — выравнивание это свойство контейнера:

TypeScript
UIRow(UIColumn().style({ flexGrow: 1 }), label)      // ✗ фантомный элемент-распорка
UIRow(label).style({ justifyContent: "flex-end" })     // ✓

Управление детьми

Все контейнеры на этой странице делят один императивный API детей (работает до и после появления элемента на экране; без диффинга):

TypeScript
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 — устарел

TypeScript
UIBox(...children): UIBox   // легаси — используй UIRow / UIColumn

Легаси-контейнер, чьё единственное отличие — дефолт justifyContent и alignItems в "center" (дети центрированы по обеим осям). Оставлен для старых проектов; новый код пиши с UIRow/UIColumn плюс явное выравнивание.

UIScrollable

Тот самый скролл-контейнер — каждая прокручиваемая область это он: тело длинного экрана (экраны сами никогда не прокручиваются — см. screen-router.md), список под закреплённой шапкой, горизонтальная карусель.

TypeScript
UIScrollable(...children: (UINodeChild | UINodeChild[])[]): UIScrollable

Дополнительные стили поверх поверхности контейнера:

TypeScript
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*):

TypeScript
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

TypeScript
s.onRefresh(cb: () => void | Promise<void>): UIScrollable   // спиннер висит, пока промис не уляжется
TypeScript
UIScrollable(rows)
  .style({ flexGrow: 1, refreshControlColor: "#FF4032" })
  .onRefresh(async () => { await load() })
Note

подключай onRefresh до монтирования элемента — хосты читают колбэк при создании узла. Только вертикальные scrollable и только нативные хосты (веб-вьюеры — no-op). У UIVirtualizedList те же onRefresh + refreshControlColor.

Note

scrollTo нет — позицию UIScrollable нельзя задать программно. Это текущее ограничение. Если нужна программная прокрутка (scrollTo / scrollToEnd / scrollToKey), используй UIVirtualizedList.

Note

UIScrollable по умолчанию имеет flexShrink: 1 (единственное исключение из глобального дефолта flexShrink: 0), поэтому вертикальный scrollable в колонке сжимается и прокручивается вместо переполнения. Оборачивающие контейнеры между ним и экраном по-прежнему дефолтятся в 0 и требуют flexShrink: 1 вручную; верни scrollable flexShrink: 0, чтобы отказаться. flexGrow: 1 для «занять оставшееся место» всё ещё задаёшь ты сам.

Горизонтальная карусель:

TypeScript
UIScrollable(items.map(Card))
  .style({ scrollDirection: "horizontal", showScrollbar: false, gap: 12, px: 16 })

Карусель / пейджер — snap

snap заставляет scrollable останавливаться на границах детей. Обёртки UISlider/UICarousel нет — snap и есть примитив, а пейджер — это горизонтальный scrollable с детьми во всю ширину плюс onScroll для отслеживания активной страницы:

TypeScript
UIScrollable(slides.map(s => Slide(s).style({ width: "100%" })))
  .style({ scrollDirection: "horizontal", showScrollbar: false, snap: "start" })

Текущая страница. События страницы нет — индекс это onScroll, делённый на ширину страницы. Меряй шаг через onLayout (никогда не предполагай ширину экрана: паддинги, инсеты и split view её меняют) и веди сигнал, чтобы точки/подписи к нему привязались:

TypeScript
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 не зависит от паддинга по построению.

Note

snap доводит только жесты; хосты без поддержки деградируют до свободной прокрутки (web-lite — полностью, wasm-вьюер — пока никак). Комбинируй с onScroll для индекса — программного scrollTo на UIScrollable по-прежнему нет (см. заметку выше), поэтому навигация тапом по точкам требует UIVirtualizedList или ждёт закрытия этого пробела.

Перетаскиваемым детям внутри скроллера нужно claim их направление жеста, иначе прокрутка украдёт указатель — см. touch.md.

UISpacer

TypeScript
UISpacer(): UISpacer      // без детей; его поверхность стиля — только ElementStyle — без фона

Гибкое пустое место: по умолчанию flexGrow: 1, съедает свободное место вдоль главной оси родителя. Тянись к нему, только когда обычное выравнивание не выражает раскладку — один элемент, толкнутый к дальнему краю, пока остальные стоят:

TypeScript
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 — публичные фабрики.

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