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

Взаимодействие и состояние

Шаг 2 UI-трека. Экран из прошлого шага оживает: нажатие на привычку отмечает её выполненной, статистика считает себя сама, чипы фильтруют список, а кнопка внизу добавляет новую карточку. По дороге — вся модель состояния LeCodes UI: обработчики на кнопках, сигналы, реактивные привязки и классы стилей.

Как и раньше, каждый пример — законченная программа: телефон рядом показывает ровно тот код, который ты читаешь.

Первое нажатие

В LeCodes нажимаются ровно три вещи: UIButton, UIScreen и UIWidget. Хочешь кликабельную карточку, строку списка, иконку — оборачивай в UIButton. Обработчик вешается цепочкой — .onClick(...), а обновление экрана — обычная мутация: элементы — живые объекты, у UIText есть свойство .text, и его запись сразу перерисовывает элемент. Никакого виртуального DOM, никакого «ре-рендера».

Чтобы получить ссылку на вложенный элемент, присвой его переменной прямо в списке детей — присваивание и создаёт элемент, и запоминает его:

TypeScript
let label: UIText
let taps = 0

const screen = UIScreen(
  label = UIText("Нажатий: 0").style({ color: "white", fontSize: 32, fontWeight: 800 }),

  UIButton(
    UIText("Нажми меня").style({ color: "white", fontSize: 16, fontWeight: 700 }),
  )
    .style({ bgColor: "#FF4032", borderRadius: 16, p: 16, $pressed: { opacity: 0.7 } })
    .onClick(() => { label.text = `Нажатий: ${++taps}` }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)", gap: 16 })

screen.open()

Две детали, которые пригодятся везде:

  • Кнопка сама ничего не рисует — ни фона, ни рамки, ни реакции на нажатие. Фон и скругление — обычные стили, а $pressed: { opacity: 0.7 } — блок стилей, который система включает, пока палец лежит на кнопке. Без него нажатие никак не видно.
  • UIButton по умолчанию — ряд с центрированием по обеим осям, поэтому текст в ней и так стоит по центру, без alignItems.

Императивный способ честный и быстрый, но у него есть предел: как только одно и то же число нужно показать в трёх местах, ты начинаешь вручную синхронизировать три .text. Для этого есть сигналы.

Сигналы

signal(value) — контейнер с одним значением. Читаешь .value, пишешь .value. Всё волшебство — в чтении: если .value прочитан внутри функции-привязки (UIText(() => …), стиль-функция, computed), элемент подписывается на сигнал и перерисовывается сам, когда значение меняется. Запись — точечное обновление свойства в уже построенном дереве, ничего не пересоздаётся.

TypeScript
const count = signal(0)

const screen = UIScreen(
  UIText(() => `Нажатий: ${count.value}`).style({ color: "white", fontSize: 32, fontWeight: 800 }),
  UIText(() => (count.value === 0 ? "Пока ни одного" : count.value < 5 ? "Разогреваемся" : "Отлично!"))
    .style({ color: "#8a8a93", fontSize: 15 }),

  UIButton(UIText("Нажми меня").style({ color: "white", fontSize: 16, fontWeight: 700 }))
    .style({
      borderRadius: 16, p: 16, $pressed: { opacity: 0.7 },
      bgColor: () => (count.value < 5 ? "#FF4032" : "#34c759"),
    })
    .onClick(() => count.value++),

  UIButton(UIText("Сбросить").style({ color: "#8a8a93", fontSize: 15 }))
    .style({ p: 8, $pressed: { opacity: 0.6 } })
    .onClick(() => { count.value = 0 }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)", gap: 12 })

screen.open()

Один сигнал — три подписчика: заголовок, подпись и цвет кнопки. Ни одного .text = … в коде: обработчик меняет только состояние, а вид следует за ним.

  • Привязка — это () => значение там, где элемент ждёт строку или примитивный стиль: текст UIText, любой стиль вида bgColor, opacity, transform. Функция запускается сразу при создании (элемент получает конкретное значение) и снова после каждой записи в прочитанный сигнал.
  • Записи батчатся: несколько .value = … за один тик — одна перерисовка. Запись того же значения — no-op.
  • Производные значения — computed(() => …): ленивый кэш, пересчитывается только когда меняется то, что он прочитал. Дальше он понадобится для статистики.
  • Побочные эффекты (сохранить, отправить в сеть, залогировать) — effect(() => …); он вернёт функцию-отписку. В UI-привязках отписка не нужна — они живут и умирают вместе с элементом.
Заметка

Компилятор LeCodes умеет оборачивать привязки сам: UIText(`Нажатий: ${count.value}`) в коде проекта превращается в UIText(() => …). В гайде мы пишем стрелку явно — так видно, где именно живёт реактивность. Внутри своих функций-помощников (UIText(fmt())) автоматика не сработает — там стрелка обязательна.

Список из состояния

Список привычек — тоже сигнал: массив объектов. Контейнер принимает функцию вместо детейUIColumn(() => habits.value.map(HabitRow)) — и перестраивает себя, когда массив меняется. Перестройка минимальная: рантайм сравнивает элементы по ссылке — карточки, чьи объекты остались в массиве, не трогаются, новые вставляются, исчезнувшие удаляются.

Отсюда правило обновления: не мутируй массив на месте, пиши новый. Отметить привычку — значит заменить один объект в новом массиве; ровно эта карточка и перерисуется:

TypeScript
const CARD = "#1a1a20"
const MUTED = "#8a8a93"

type Habit = { id: number; title: string; time: string; color: string; done: boolean }

const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", time: "07:30", color: "#34c759", done: true },
  { id: 2, title: "Прочитать 20 страниц", time: "13:00", color: "#ff9f0a", done: true },
  { id: 3, title: "Позаниматься на пианино", time: "18:00", color: "#5e9eff", done: false },
  { id: 4, title: "Растяжка", time: "20:30", color: "#af6bff", done: false },
])
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 HabitRow = (h: Habit) =>
  UIButton(
    UIColumn(
      UIText(h.done ? "✓" : "").style({ color: "white", fontSize: 15, fontWeight: 700 }),
    ).style({
      width: 28, height: 28, borderRadius: 14,
      bgColor: h.done ? h.color : "transparent",
      border: h.done ? "0" : `2px solid ${h.color}`,
      alignItems: "center", justifyContent: "center",
    }),
    UIColumn(
      UIText(h.title).style({ color: "white", fontSize: 16, fontWeight: 600 }),
      UIText(h.time).style({ color: MUTED, fontSize: 13 }),
    ).style({ gap: 2, flexGrow: 1 }),
  )
    .style({ bgColor: CARD, borderRadius: 16, p: 14, gap: 12, opacity: h.done ? 0.75 : 1, $pressed: { opacity: 0.6 } })
    .onClick(() => toggle(h))

const screen = UIScreen(
  UIText(() => `Готово ${doneCount.value} из ${habits.value.length}`)
    .style({ color: "white", fontSize: 22, fontWeight: 800 }),

  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)" })

screen.open()

Обрати внимание, чего в HabitRow нет: ни одной привязки. Карточка читает h.done один раз при создании — это нормально, потому что при смене состояния объект заменяется и карточка строится заново. Реактивность живёт уровнем выше — в функции-детях колонки и в computed-счётчике.

  • habits.value.push(x) не сработает: ссылка на массив та же, подписчики не узнают. Пиши habits.value = [...habits.value, x], .filter(…), .map(…).
  • Ключ идентичности — ссылка на объект. Не пересоздавай все объекты при каждой записи (items.map(i => ({ ...i }))) — это полная перерисовка списка.
  • Контейнер с функцией-детьми принадлежит привязке — не зови на нём .append() / .remove() руками.
  • Для сотен строк функция-дети — не тот инструмент; там нужен UIVirtualizedList — им займётся шаг «Списки и данные».
Совет

Есть и второй способ: держать done не в объекте, а отдельным сигналом внутри него (done: signal(false)) и привязывать стили карточки к нему. Тогда карточка не пересоздаётся, а перекрашивается — это выгодно, когда у строки есть анимация или фокус, которые жалко терять. Мы вернёмся к этому в шаге «Полировка».

Классы состояний

Чипы фильтра «Все / Осталось / Готово» — типичный составной элемент с именованным состоянием: активный чип меняет и фон, и цвет текста. Можно навесить две привязки, а можно объявить класс стилей — блок $active: { … } внутри .style() — и включать его через el.class. У класса два преимущества перед россыпью привязок: у него есть duration (переключение анимируется), и он каскадирует — класс, включённый на кнопке, зажигает одноимённые $active-блоки у всех потомков. Один флаг на контейнере — и подпись внутри тоже реагирует:

TypeScript
const ACCENT = "#FF4032"
const CARD = "#1a1a20"
const MUTED = "#8a8a93"

type Habit = { id: number; title: string; time: string; color: string; done: boolean }
type Filter = "all" | "todo" | "done"

const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", time: "07:30", color: "#34c759", done: true },
  { id: 2, title: "Прочитать 20 страниц", time: "13:00", color: "#ff9f0a", done: true },
  { id: 3, title: "Позаниматься на пианино", time: "18:00", color: "#5e9eff", done: false },
  { id: 4, title: "Растяжка", time: "20:30", color: "#af6bff", done: false },
])
const filter = signal<Filter>("all")
const visible = computed(() =>
  habits.value.filter((h) => filter.value === "all" || (filter.value === "done") === h.done),
)

const Chip = (label: string, value: Filter) =>
  UIButton(
    UIText(label).style({ color: MUTED, fontSize: 13, fontWeight: 600, $active: { color: "white" } }),
  )
    .style({ height: 32, px: 14, borderRadius: 16, bgColor: CARD, $active: { bgColor: ACCENT, duration: 150 } })
    .class({ active: () => filter.value === value })
    .onClick(() => { filter.value = value })

const HabitRow = (h: Habit) =>
  UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color, opacity: h.done ? 1 : 0.35 }),
    UIText(h.title).style({ color: "white", fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIText(h.done ? "готово" : h.time).style({ color: MUTED, fontSize: 13 }),
  ).style({ bgColor: CARD, borderRadius: 16, p: 14, gap: 12, alignItems: "center" })

const screen = UIScreen(
  UIRow(Chip("Все", "all"), Chip("Осталось", "todo"), Chip("Готово", "done")).style({ gap: 8 }),
  UIColumn(() => visible.value.map(HabitRow)).style({ gap: 10, mt: 16 }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)" })

screen.open()

.class({ active: () => filter.value === value }) — реактивная форма: класс следит за сигналом, как и любая привязка. Ручная форма — chip.class.active = true — тот же механизм; читать своё состояние — chip.class.active. Два имени зарезервированы и переключаются системой: $pressed (палец на элементе) и $focused (поле ввода в фокусе) — они тоже каскадируют, поэтому иконка внутри кнопки может по-своему реагировать на её нажатие.

Порядок приоритета, если состояния спорят за одно свойство: базовый стиль → $классы$pressed/$focusedonPressed/onFocused. Полный контракт — в справочнике Классы стилей.

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

Всё вместе — экран из прошлого шага, но живой: статистика считается через computed, чипы фильтруют, нажатие на карточку отмечает привычку, кнопка внизу добавляет новую (пока — из готового списка идей; настоящую форму соберём в шаге «Формы»):

TypeScript
const ACCENT = "#FF4032"
const BG = "#0e0e12"
const CARD = "#1a1a20"
const MUTED = "#8a8a93"

type Habit = { id: number; title: string; time: string; color: string; done: boolean }
type Filter = "all" | "todo" | "done"

const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", time: "07:30", color: "#34c759", done: true },
  { id: 2, title: "Прочитать 20 страниц", time: "13:00", color: "#ff9f0a", done: true },
  { id: 3, title: "Позаниматься на пианино", time: "18:00", color: "#5e9eff", done: false },
  { id: 4, title: "Растяжка", time: "20:30", color: "#af6bff", done: false },
])
const filter = signal<Filter>("all")

const doneCount = computed(() => habits.value.filter((h) => h.done).length)
const visible = computed(() =>
  habits.value.filter((h) => filter.value === "all" || (filter.value === "done") === h.done),
)

const toggle = (h: Habit) => {
  habits.value = habits.value.map((x) => (x === h ? { ...x, done: !x.done } : x))
}

const IDEAS = ["Стакан воды", "Медитация 10 минут", "Прогулка", "Без телефона перед сном"]
const addHabit = () => {
  const n = habits.value.length
  habits.value = [...habits.value, { id: n + 1, title: IDEAS[n % IDEAS.length], time: "21:00", color: ACCENT, done: false }]
}

const Stat = (value: () => string, label: string) =>
  UIColumn(
    UIText(value).style({ color: "white", fontSize: 22, fontWeight: 800 }),
    UIText(label).style({ color: MUTED, fontSize: 13 }),
  ).style({ bgColor: CARD, borderRadius: 14, p: 12, gap: 2, flex: 1, alignItems: "center" })

const Chip = (label: string, value: Filter) =>
  UIButton(UIText(label).style({ color: MUTED, fontSize: 13, fontWeight: 600, $active: { color: "white" } }))
    .style({ height: 32, px: 14, borderRadius: 16, bgColor: CARD, $active: { bgColor: ACCENT, duration: 150 } })
    .class({ active: () => filter.value === value })
    .onClick(() => { filter.value = value })

const HabitRow = (h: Habit) =>
  UIButton(
    UIColumn(UIText(h.done ? "✓" : "").style({ color: "white", fontSize: 15, fontWeight: 700 })).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({ color: "white", fontSize: 16, fontWeight: 600 }),
      UIText(h.time).style({ color: MUTED, fontSize: 13 }),
    ).style({ gap: 2, flexGrow: 1 }),
  )
    .style({ bgColor: CARD, borderRadius: 16, p: 14, gap: 12, opacity: h.done ? 0.75 : 1, $pressed: { opacity: 0.6 } })
    .onClick(() => toggle(h))

const screen = UIScreen(
  UIText("Сегодня").style({ color: "white", fontSize: 32, fontWeight: 800 }),
  UIText(date().format("dddd, DD MMMM")).style({ color: MUTED, fontSize: 15 }),

  UIRow(
    Stat(() => `${doneCount.value}`, "готово"),
    Stat(() => `${habits.value.length - doneCount.value}`, "осталось"),
    Stat(() => `${Math.round((doneCount.value / habits.value.length) * 100)}%`, "за сегодня"),
  ).style({ gap: 10, mt: 8 }),

  UIRow(Chip("Все", "all"), Chip("Осталось", "todo"), Chip("Готово", "done")).style({ gap: 8, mt: 12 }),
  UIColumn(() => visible.value.map(HabitRow)).style({ gap: 10, mt: 12 }),

  UISpacer(),

  UIButton(UIText("+ Новая привычка").style({ color: "white", fontSize: 16, fontWeight: 700 }))
    .style({ bgColor: ACCENT, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } })
    .onClick(addHabit),
).style({ bgColor: BG, p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)", gap: 4 })

screen.open()

Заметь, как разделились роли: состояние — три строки вверху (habits, filter, два computed), действия — две функции, которые пишут только в сигналы, а весь остальной файл — чистая вёрстка, которая просто читает состояние. Так будет устроен каждый следующий шаг трека — сколько бы экранов ни прибавилось.

Что ты узнал

  • Нажимаются только UIButton, UIScreen и UIWidget; кнопка ничего не рисует сама — фон, скругление и $pressed задаёшь ты.
  • Ссылка на вложенный элемент — присваивание прямо в списке детей; содержимое меняется свойствами (.text, .value, .src), а не стилями.
  • signal / computed / effect — состояние приложения; () => значение в тексте или стиле — привязка, которая обновляется сама.
  • Список из состояния — функция вместо детей контейнера; массивы обновляй новой ссылкой, идентичность строки — объект.
  • $имя: { … } в стиле + el.class — именованные состояния с анимацией переключения и каскадом на потомков.

Справочник: Сигналы · Кнопки и поля · Классы стилей · События указателя.

Дальше: тема и токены — вынесем цвета из констант в theme(), добавим переключатель тёмной и светлой темы и шрифт на всё приложение.