UIVirtualizedList
Оконный список для длинных или неограниченных данных — ленты, чаты, результаты поиска. В любой момент
смонтированы только строки рядом с вьюпортом (плюс буфер overscan). Используй его вместо
UIScrollable + items.map(Row), когда число элементов велико, растёт со временем или неизвестно;
для дюжины статичных строк проще обычный UIScrollable.
Данные управляются императивно — диффинга нет. Создай список один раз, затем вызывай setData /
append / update на нём; никогда не пересобирай список, чтобы сменить его содержимое.
Обзор
type Post = { id: number; title: string; body: string }
let page = 1
const list = UIVirtualizedList<Post>({
keyOf: p => String(p.id),
estimatedHeight: p => 72 + Math.ceil(p.body.length / 38) * 20,
render: p => UIColumn([
UIText(p.title).style({ fontWeight: 700, color: "white", fontSize: 16 }),
UIText(p.body).style({ color: "#bbb", fontSize: 14 }),
]).style({ gap: 4, px: 16, py: 14 }),
}).style({ flexGrow: 1, flexShrink: 1 })
.onEndReached(600, async () => {
const res = await fetch(`https://example.com/posts?page=${page++}`)
list.append(...res.json<Post[]>())
})Конфигурация
UIVirtualizedList<T>(config: {
keyOf: (item: T) => string // СТАБИЛЬНЫЙ уникальный id (никогда не индекс массива)
render: (item: T) => UINodeChild // строит одну строку, вызывается при входе в окно
estimatedHeight: number | ((item: T) => number) // догадка в px до измерения строки
overscan?: number // px, удерживаемые смонтированными вокруг вьюпорта; по умолчанию: высота одного вьюпорта
inverted?: boolean // режим чата, по умолчанию false
})keyOf— ключи идентифицируют строки черезsetData/update/removeByKey. Индекс не стабилен, как только элементы вставляются или удаляются.renderдолжен быть чистым над элементом: строка может быть размонтирована и перерендерена в любой момент, когда покидает и снова входит в окно, поэтому её вывод может зависеть только от переданного элемента — не от внешнего изменяемого состояния и не от захваченного элемента, который ты меняешь позже. Чтобы изменить смонтированную строку, поменяй элемент и вызови.update(item).estimatedHeightзадаёт размер строки до её первого реального измерения (вычисляется один раз на элемент, при добавлении). Достаточно, чтобы было близко — лучшие догадки лишь снижают дрожание прокрутки.inverted: true— режим чата: первая раскладка стартует прокрученной к концу, аappend, пока внизу, авто-скроллит к новой строке.
Стилизация: те же стили элемента, что у других контейнеров. Дай ему flexGrow: 1, flexShrink: 1,
чтобы он занял доступное место и прокручивался, а не переполнялся.
Операции с данными
list.setData(items: T[]): this // заменить всё (диффится по ключу)
list.append(...items: T[]): this // добавить в конец (чат: новейшее сообщение)
list.prepend(...items: T[]): this // добавить в начало БЕЗ скачка прокрутки (компенсируется)
list.update(...items: T[]): this // перерендерить строки с тем же keyOf(); неизвестные ключи игнорируются
list.removeByKey(...keys: string[]): this
list.itemCount: number // элементов сейчас в наличии (геттер)
list.getItem(key: string): T | undefinedupdate перерендеривает строку, только если она сейчас смонтирована; строки вне окна подхватывают
новый элемент естественно, когда снова входят. prepend — примитив загрузки истории — старые
сообщения появляются сверху, не сдвигая то, что читает пользователь.
Прокрутка
list.scrollTo(offset: number, animated?: boolean): void // px-смещение; animated по умолчанию true
list.scrollToKey(key: string, animated?: boolean): void
list.scrollToEnd(animated?: boolean): void
list.onScroll(cb: (scrollPosition: number) => void): thisКраевые колбэки — пагинация
list.onEndReached(thresholdPx: number, callback: () => void): this // рядом с НИЗОМ контента
list.onStartReached(thresholdPx: number, callback: () => void): this // рядом с ВЕРХОМ (история чата)Порог (px от края) идёт первым и обязателен. Колбэк срабатывает один раз, когда прокрутка входит
в зону порога, и защёлкивается — он не сработает снова, пока пользователь не выкрутится из зоны.
Держи свои флаги busy / end для запросов в полёте и исчерпанной пагинации.
list.onEndReached(600, loadNextPage) // ✓
list.onEndReached(loadNextPage) // ✗ неверный порядок — порог это первый аргументПодводные камни
// ✗ пересоздание списка (или вызов setData с готовыми элементами) для «ре-рендера»
screenBody.setContent([ UIVirtualizedList({...}) ]) // теряет прокрутку, перемонтирует всё
// ✓ держи один экземпляр, меняй через setData/append/update
// ✗ строка читает внешнее изменяемое состояние — устаревает после цикла размонтирования/перерендера
render: m => UIText(selectedId === m.id ? "✓ " + m.text : m.text)
// ✓ положи состояние в элемент и вызови update()
list.update({ ...m, selected: true })
// ✗ нет flexGrow/flexShrink — список переполняет экран вместо прокрутки
list.style({ flexGrow: 1, flexShrink: 1 }) // ✓Смотрите также
- Контейнеры —
UIScrollableдля короткого статичного контента. - UIScreen и Router —
makeScrollable()против дочерних скроллеров. - Кнопки и поля ввода — строки
UIButtonвнутри списка.