Списки и данные
Шаг 4 UI-трека. Пока привычек четыре, всё помещается на экран. Но у настоящего трекера их дюжина, сверху хочется ленту дней, а история отметок — это сотни строк. Сегодня — три инструмента для контента, который не влезает: UIScrollable как тело экрана, горизонтальная лента и UIVirtualizedList для длинных данных.
Экран не прокручивается
Первое правило, которое ломает веб-привычки: UIScreen — неподвижный корень. У него нет позиции прокрутки, событий скролла и pull-to-refresh. Прокручивается всегда потомок — UIScrollable. Канонический экран — это неподвижная «рама» (заголовок, кнопки, таб-бар) плюс одно прокручиваемое тело с flexGrow: 1:
const CARD = "#1a1a20"
const MUTED = "#8a8a93"
const TITLES = ["Утренняя пробежка", "Стакан воды", "Прочитать 20 страниц", "Медитация", "Пианино",
"Растяжка", "Прогулка", "Английский", "Дневник", "Витамины", "Без сахара", "Спланировать завтра"]
const habits = signal(TITLES.map((title, i) => ({ id: i, title, time: `${String(7 + i).padStart(2, "0")}:00` })))
const HabitRow = (h: { title: string; time: string }) =>
UIRow(
UIText(h.title).style({ color: "white", fontSize: 16, fontWeight: 600, flexGrow: 1 }),
UIText(h.time).style({ color: MUTED, fontSize: 13 }),
).style({ bgColor: CARD, borderRadius: 16, p: 14, alignItems: "center" })
const screen = UIScreen(
UIText("Сегодня").style({ color: "white", fontSize: 32, fontWeight: 800 }),
UIText(() => `${habits.value.length} привычек`).style({ color: MUTED, fontSize: 15 }),
UIScrollable(() => habits.value.map(HabitRow))
.style({ flexGrow: 1, gap: 10, mt: 12, showScrollbar: false }),
UIButton(UIText("+ Новая привычка").style({ color: "white", fontSize: 16, fontWeight: 700 }))
.style({ bgColor: "#FF4032", borderRadius: 16, p: 16, mt: 12, $pressed: { opacity: 0.8 } })
.onClick(() => {
const n = habits.value.length
habits.value = [...habits.value, { id: n, title: `Привычка ${n + 1}`, time: "21:00" }]
}),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)" })
screen.open()Прокрути список в телефоне: заголовок и кнопка стоят на месте, двигается только середина. Что здесь важно:
flexGrow: 1отдаёт скроллу всё место между рамой и кнопкой.UIScrollable— единственный контейнер сflexShrink: 1по умолчанию, поэтому он ужимается и прокручивается, а не выталкивает кнопку за экран. Если между ним и экраном появится обёртка-UIColumn, ейflexShrink: 1придётся дать руками.- Функция-дети работает и здесь:
UIScrollable(() => habits.value.map(HabitRow))— тот же реактивный список, что и в шаге про состояние.UISpacerбольше не нужен: скролл сам занимает остаток. gapи отступы — свойства самого скролла;showScrollbar: falseпрячет полосу.- Pull-to-refresh —
.onRefresh(async () => …)— тоже живёт на скролле (в веб-симуляторе он не работает, на телефоне — да). Событие.onScroll(pos => …)даёт позицию в логических px.
У UIScrollable нет scrollTo — позицию нельзя задать из кода. Когда нужна программная прокрутка (к строке, в конец), бери UIVirtualizedList из раздела ниже.
Горизонтальная лента
Лента дней над списком — тот же UIScrollable с scrollDirection: "horizontal". Отдельного «карусель»-элемента нет: горизонтальный скролл — примитив, а snap при желании заставит его останавливаться на границах детей. Выбранный день — знакомый по прошлым шагам класс $active:
const ACCENT = "#FF4032"
const CARD = "#1a1a20"
const MUTED = "#8a8a93"
const days = Array.from({ length: 14 }, (_, i) => date().subtract(i, "day")) // сегодня — первый
const selected = signal(0)
const DayCard = (d: DateValue, i: number) =>
UIButton(
UIText(d.format("ddd")).style({ color: MUTED, fontSize: 12, $active: { color: "white" } }),
UIText(d.format("D")).style({ color: "white", fontSize: 18, fontWeight: 700 }),
)
.style({
width: 52, height: 64, borderRadius: 14, bgColor: CARD, flexDirection: "column", gap: 2,
$active: { bgColor: ACCENT, duration: 150 },
})
.class({ active: () => selected.value === i })
.onClick(() => { selected.value = i })
const screen = UIScreen(
UIText("Неделя").style({ color: "white", fontSize: 32, fontWeight: 800, px: 20 }),
UIScrollable(days.map(DayCard))
.style({ scrollDirection: "horizontal", showScrollbar: false, gap: 8, px: 20, py: 12, flexShrink: 0 }),
UIText(() => days[selected.value].format("dddd, D MMMM"))
.style({ color: MUTED, fontSize: 15, px: 20 }),
).style({ bgColor: "#0e0e12", pt: "max(safe-top, 24px)" })
screen.open()- Отступы у ленты — на самом скролле (
px: 20), а не на экране: так карточки уезжают под край экрана, а не обрезаются по рамке контента. Заголовок и подпись получаютpxпо отдельности. flexShrink: 0возвращает ленте обычное поведение — иначе вертикальная раскладка могла бы ужать её высоту.UIButtonсflexDirection: "column"— карточка-кнопка: два текста один над другим, центрирование по обеим осям уже встроено.- Пейджер из полноэкранных слайдов — тот же скролл с
snap: "start"и детьмиwidth: "100%"; текущая страница —onScrollделённый на ширину. Рецепт — в справочнике Контейнеры.
Длинные списки — UIVirtualizedList
Функция-дети и UIScrollable создают все строки сразу. Для дюжины карточек это правильно, для истории отметок за год — нет: сотни элементов в дереве стоят памяти и времени на старте. UIVirtualizedList монтирует только строки у видимой области (плюс небольшой запас) и переиспользует их при прокрутке.
Данные в него подаются императивно: список создаётся один раз, дальше — setData / append / update. Три обязательных поля конфигурации: стабильный ключ строки, оценка высоты и функция-рендер:
const MUTED = "#8a8a93"
type Entry = { id: number; day: string; title: string; done: boolean }
const TITLES = ["Утренняя пробежка", "Прочитать 20 страниц", "Пианино", "Растяжка"]
const makeEntries = (from: number, count: number): Entry[] =>
Array.from({ length: count }, (_, k) => {
const i = from + k
return { id: i, day: date().subtract(Math.floor(i / 4), "day").format("D MMMM"), title: TITLES[i % 4], done: i % 3 !== 0 }
})
const total = signal(200)
const list = UIVirtualizedList<Entry>({
keyOf: (e) => String(e.id), // стабильный ключ — не индекс
estimatedHeight: 56, // догадка до первого измерения
render: (e) => // чистая функция от элемента данных
UIButton(
UIText(e.done ? "✓" : "·").style({ color: e.done ? "#34c759" : MUTED, fontSize: 16, width: 20 }),
UIColumn(
UIText(e.title).style({ color: "white", fontSize: 15, fontWeight: 600 }),
UIText(e.day).style({ color: MUTED, fontSize: 12 }),
).style({ flexGrow: 1, gap: 2 }),
)
.style({ px: 20, height: 56, gap: 12, $pressed: { opacity: 0.6 } })
.onClick(() => list.update({ ...e, done: !e.done })), // менять строку — через update
})
.style({ flexGrow: 1 })
.onEndReached(600, () => { // подгрузка у нижнего края
list.append(...makeEntries(total.value, 100))
total.value += 100
})
list.setData(makeEntries(0, 200))
const screen = UIScreen(
UIRow(
UIText("История").style({ color: "white", fontSize: 32, fontWeight: 800, flexGrow: 1 }),
UIText(() => `${total.value} записей`).style({ color: MUTED, fontSize: 13 }),
).style({ px: 20, pb: 12, alignItems: "flex-end" }),
list,
).style({ bgColor: "#0e0e12", pt: "max(safe-top, 24px)" })
screen.open()Прокрути вниз — у конца список дольёт ещё сотню строк, а счётчик в заголовке обновится. Нажми на строку — галочка переключится через update. Правила, которые здесь работают:
keyOf— стабильный id, не индекс: по ключу список сопоставляет строки приsetData,updateиremoveByKey.render— чистая функция от элемента. Строку могут размонтировать и построить заново в любой момент, когда она уходит из окна и возвращается, — поэтому она не должна читать внешнее изменяемое состояние. Хочешь изменить смонтированную строку — измени данные и вызови.update(item).estimatedHeightнужен только до первого измерения; точная догадка лишь уменьшает дрожание при прокрутке.- У списка нет собственной высоты — без
flexGrow: 1(илиheight) он схлопнется в ноль и ничего не покажет. onEndReached(порог, cb)— порог в px идёт первым; колбэк срабатывает один раз при входе в зону и снова — только после того, как пользователь из неё вышел. Флаги «уже грузим» / «данные кончились» держи сами.- Есть и
prepend(история чата — без скачка прокрутки),inverted: true(чат: первая строка внизу),scrollTo/scrollToKey/scrollToEnd— то, чего нет уUIScrollable.
Экран целиком
Экран «Сегодня» трекера в новой раскладке: неподвижный заголовок и лента дней, прокручиваемый список привычек, прижатая кнопка. Тема и типографика — из прошлого шага:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const type = {
h1: { fontSize: 32, fontWeight: 800 } as Style<UIText>,
body: { fontSize: 16, fontWeight: 600 } as Style<UIText>,
small: { fontSize: 13, color: colors.muted } as Style<UIText>,
}
type Habit = { id: number; title: string; time: string; color: string; done: boolean }
const PALETTE = ["#34c759", "#ff9f0a", "#5e9eff", "#af6bff", "#ff6b81", "#2fd6c8"]
const TITLES = ["Утренняя пробежка", "Стакан воды", "Прочитать 20 страниц", "Медитация", "Пианино",
"Растяжка", "Прогулка", "Английский", "Дневник", "Витамины", "Без сахара", "Спланировать завтра"]
const habits = signal<Habit[]>(TITLES.map((title, i) => ({
id: i, title, time: `${String(7 + i).padStart(2, "0")}:00`, color: PALETTE[i % PALETTE.length], done: i < 3,
})))
const doneCount = computed(() => habits.value.filter((h) => h.done).length)
const toggle = (h: Habit) => {
habits.value = habits.value.map((x) => (x === h ? { ...x, done: !x.done } : x))
}
const days = Array.from({ length: 14 }, (_, i) => date().subtract(i, "day")) // сегодня — первый
const selectedDay = signal(0)
const DayCard = (d: DateValue, i: number) =>
UIButton(
UIText(d.format("ddd")).style({ ...type.small, fontSize: 12, $active: { color: "#ffffff" } }),
UIText(d.format("D")).style({ fontSize: 18, fontWeight: 700 }),
)
.style({ width: 52, height: 64, borderRadius: 14, bgColor: colors.card, flexDirection: "column", gap: 2,
$active: { bgColor: colors.accent, duration: 150 } })
.class({ active: () => selectedDay.value === i })
.onClick(() => { selectedDay.value = i })
const HabitRow = (h: Habit) =>
UIButton(
UIColumn(UIText(h.done ? "✓" : "").style({ fontSize: 15, fontWeight: 700, color: "#ffffff" })).style({
width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
bgColor: h.done ? h.color : "transparent", border: h.done ? "0" : `2px solid ${h.color}`,
}),
UIColumn(UIText(h.title).style(type.body), UIText(h.time).style(type.small)).style({ gap: 2, flexGrow: 1 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, opacity: h.done ? 0.75 : 1, $pressed: { opacity: 0.6 } })
.onClick(() => toggle(h))
const screen = UIScreen(
UIRow(
UIText("Сегодня").style({ ...type.h1, flexGrow: 1 }),
UIText(() => `${doneCount.value} / ${habits.value.length}`).style({ ...type.small, fontSize: 15 }),
).style({ px: 20, alignItems: "flex-end" }),
UIScrollable(days.map(DayCard))
.style({ scrollDirection: "horizontal", showScrollbar: false, gap: 8, px: 20, py: 12, flexShrink: 0 }),
UIScrollable(() => habits.value.map(HabitRow))
.style({ flexGrow: 1, gap: 10, px: 20, pb: 12, showScrollbar: false }),
UIButton(UIText("+ Новая привычка").style({ ...type.body, fontWeight: 700, color: "#ffffff" }))
.style({ bgColor: colors.accent, borderRadius: 16, p: 16, mx: 20, $pressed: { opacity: 0.8 } }),
).style({ bgColor: colors.bg, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)" })
screen.open()Горизонтальные отступы переехали с экрана на элементы (px: 20, mx: 20) — ради ленты, которой нужно уезжать под край. Кнопка пока снова без обработчика: в следующих шагах она откроет отдельный экран с формой — и для этого нам нужна навигация.
Что ты узнал
- Экран не прокручивается: рама + одно тело
UIScrollableсflexGrow: 1; он единственный ужимается сам (flexShrink: 1). - Горизонтальная лента — тот же скролл с
scrollDirection: "horizontal"; отступы кладутся на него,flexShrink: 0бережёт высоту,snapделает пейджер. UIVirtualizedList— для длинных и растущих данных: стабильныйkeyOf, чистыйrender,estimatedHeight, обязательный размер; данные — черезsetData/append/update; подгрузка —onEndReached(порог, cb).- Программная прокрутка есть только у виртуального списка.
Справочник: Контейнеры · Виртуальные списки · Экраны и роутер · Даты.
Дальше: экраны и навигация — вкладки «Сегодня» и «История», экран привычки поверх, кнопка «назад».