LeCodesdocs

UIVirtualizedList

Оконный список для длинных или неограниченных данных — ленты, чаты, результаты поиска. В любой момент смонтированы только строки рядом с вьюпортом (плюс буфер overscan). Используй его вместо UIScrollable + items.map(Row), когда число элементов велико, растёт со временем или неизвестно; для дюжины статичных строк проще обычный UIScrollable.

Данные управляются императивно — диффинга нет. Создай список один раз, затем вызывай setData / append / update на нём; никогда не пересобирай список, чтобы сменить его содержимое.

Обзор

TypeScript
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[]>())
  })

Конфигурация

TypeScript
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, чтобы он занял доступное место и прокручивался, а не переполнялся.

Операции с данными

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

update перерендеривает строку, только если она сейчас смонтирована; строки вне окна подхватывают новый элемент естественно, когда снова входят. prepend — примитив загрузки истории — старые сообщения появляются сверху, не сдвигая то, что читает пользователь.

Прокрутка

TypeScript
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

Краевые колбэки — пагинация

TypeScript
list.onEndReached(thresholdPx: number, callback: () => void): this     // рядом с НИЗОМ контента
list.onStartReached(thresholdPx: number, callback: () => void): this   // рядом с ВЕРХОМ (история чата)

Порог (px от края) идёт первым и обязателен. Колбэк срабатывает один раз, когда прокрутка входит в зону порога, и защёлкивается — он не сработает снова, пока пользователь не выкрутится из зоны. Держи свои флаги busy / end для запросов в полёте и исчерпанной пагинации.

TypeScript
list.onEndReached(600, loadNextPage)      // ✓
list.onEndReached(loadNextPage)           // ✗ неверный порядок — порог это первый аргумент

Подводные камни

TypeScript
// ✗ пересоздание списка (или вызов 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 })   // ✓

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