Реальные истории:Как тренер футбольной школы создал приложения за 1 неделю скоро

Списки и данные

Шаг 4 UI-трека. Пока привычек четыре, всё помещается на экран. Но у настоящего трекера их дюжина, сверху хочется ленту дней, а история отметок — это сотни строк. Сегодня — три инструмента для контента, который не влезает: UIScrollable как тело экрана, горизонтальная лента и UIVirtualizedList для длинных данных.

Экран не прокручивается

Первое правило, которое ломает веб-привычки: UIScreen — неподвижный корень. У него нет позиции прокрутки, событий скролла и pull-to-refresh. Прокручивается всегда потомок — UIScrollable. Канонический экран — это неподвижная «рама» (заголовок, кнопки, таб-бар) плюс одно прокручиваемое тело с flexGrow: 1:

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

TypeScript
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. Три обязательных поля конфигурации: стабильный ключ строки, оценка высоты и функция-рендер:

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

Экран целиком

Экран «Сегодня» трекера в новой раскладке: неподвижный заголовок и лента дней, прокручиваемый список привычек, прижатая кнопка. Тема и типографика — из прошлого шага:

TypeScript
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).
  • Программная прокрутка есть только у виртуального списка.

Справочник: Контейнеры · Виртуальные списки · Экраны и роутер · Даты.

Дальше: экраны и навигация — вкладки «Сегодня» и «История», экран привычки поверх, кнопка «назад».