Взаимодействие и состояние
Шаг 2 UI-трека. Экран из прошлого шага оживает: нажатие на привычку отмечает её выполненной, статистика считает себя сама, чипы фильтруют список, а кнопка внизу добавляет новую карточку. По дороге — вся модель состояния LeCodes UI: обработчики на кнопках, сигналы, реактивные привязки и классы стилей.
Как и раньше, каждый пример — законченная программа: телефон рядом показывает ровно тот код, который ты читаешь.
Первое нажатие
В LeCodes нажимаются ровно три вещи: UIButton, UIScreen и UIWidget. Хочешь кликабельную карточку, строку списка, иконку — оборачивай в UIButton. Обработчик вешается цепочкой — .onClick(...), а обновление экрана — обычная мутация: элементы — живые объекты, у UIText есть свойство .text, и его запись сразу перерисовывает элемент. Никакого виртуального DOM, никакого «ре-рендера».
Чтобы получить ссылку на вложенный элемент, присвой его переменной прямо в списке детей — присваивание и создаёт элемент, и запоминает его:
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), элемент подписывается на сигнал и перерисовывается сам, когда значение меняется. Запись — точечное обновление свойства в уже построенном дереве, ничего не пересоздаётся.
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)) — и перестраивает себя, когда массив меняется. Перестройка минимальная: рантайм сравнивает элементы по ссылке — карточки, чьи объекты остались в массиве, не трогаются, новые вставляются, исчезнувшие удаляются.
Отсюда правило обновления: не мутируй массив на месте, пиши новый. Отметить привычку — значит заменить один объект в новом массиве; ровно эта карточка и перерисуется:
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-блоки у всех потомков. Один флаг на контейнере — и подпись внутри тоже реагирует:
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/$focused → onPressed/onFocused. Полный контракт — в справочнике Классы стилей.
Экран целиком
Всё вместе — экран из прошлого шага, но живой: статистика считается через computed, чипы фильтруют, нажатие на карточку отмечает привычку, кнопка внизу добавляет новую (пока — из готового списка идей; настоящую форму соберём в шаге «Формы»):
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(), добавим переключатель тёмной и светлой темы и шрифт на всё приложение.