Экраны и навигация
Шаг 5 UI-трека. До сих пор трекер был одним экраном с screen.open(). Сегодня он становится приложением: вкладки «Сегодня» и «История» внизу, экран привычки поверх списка, кнопка «назад» — экранная и аппаратная. Три инструмента: Router для стека экранов, onOpen / onClose для жизненного цикла и UITabs — готовая оболочка с вкладками.
Стек экранов — Router
Router держит стек экранов. Router.init(home) вместо home.open() — и дальше Router.push(screen) кладёт экран сверху, Router.pop() снимает. Экраны под верхним остаются в памяти со всем своим состоянием — они просто не рисуются; снятый со стека экран уничтожается.
Экран с данными — это функция-фабрика: HabitScreen(h) строит новый экран для конкретной привычки в момент перехода:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
type Habit = { title: string; time: string; streak: number }
const habits: Habit[] = [
{ title: "Утренняя пробежка", time: "07:30", streak: 12 },
{ title: "Прочитать 20 страниц", time: "13:00", streak: 5 },
{ title: "Растяжка", time: "20:30", streak: 31 },
]
const BackButton = () =>
UIButton(UIText("← Назад").style({ color: colors.muted, fontSize: 15 }))
.style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
.onClick(() => Router.pop())
const HabitScreen = (h: Habit) =>
UIScreen(
BackButton(),
UIText(h.title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
UIText(`Каждый день в ${h.time}`).style({ color: colors.muted, fontSize: 15 }),
UIColumn(
UIText(`${h.streak}`).style({ fontSize: 40, fontWeight: 800 }),
UIText("дней подряд").style({ color: colors.muted, fontSize: 13 }),
).style({ bgColor: colors.card, borderRadius: 16, p: 16, mt: 16, alignItems: "center" }),
)
.style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
.onBackPressed(() => Router.pop()) // аппаратная «назад» на Android
const HabitRow = (h: Habit) =>
UIButton(
UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
UIText("›").style({ color: colors.muted, fontSize: 22 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
.onClick(() => Router.push(HabitScreen(h)))
const home = UIScreen(
UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(habits.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
Router.init(home)Router.init(home)вызывается один раз в точке входа.Router.push/Router.pop— глобальны: их можно звать из любого файла, ссылку на роутер прокидывать не нужно.pop()по умолчанию снимает один экран;pop(-2)— два,pop(0)— до самого первого. Снятые экраны уничтожаются, но объектUIScreenостаётся рабочим — его можно запушить снова.Router.replace(screen)меняет верхний экран без роста стека — так делают «войти → главная». Переход настраивается:theme({ replaceTransition: "fade" })или опцией вызова.- Кнопка «назад» на Android —
screen.onBackPressed(cb). Цепочка разбора нажатия: открытый виджет → текущий экран → вкладка с глубиной → роутер → глобальныйapp.onBackPressed. Если никто не обработал — нажатие игнорируется, приложение не закрывается. - «Назад» на экране — обычная кнопка. Системную можно включить:
Router.init(home, { showDefaultBackButton: true }).
Жизненный цикл экрана
У экрана два события: onOpen — стал активным, onClose — перестал. Они срабатывают при каждой активации, не только при первой: push поверх вызывает onClose нижнего экрана, pop обратно — снова его onOpen. Правило одно и без исключений: всё, что запущено в onOpen, останавливается в onClose — циклы, интервалы, сокеты, — иначе оно продолжит работать за спиной у следующего экрана.
Экран привычки с таймером фокуса — минута тикает только пока он открыт:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const TimerScreen = (title: string, seconds: number) => {
const left = signal(seconds)
let timer: number | null = null
return UIScreen(
UIButton(UIText("← Назад").style({ color: colors.muted, fontSize: 15 }))
.style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
.onClick(() => Router.pop()),
UIText(title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
UIText(() => `${left.value} с`).style({ fontSize: 64, fontWeight: 800, mt: 24 }),
UIText(() => (left.value > 0 ? "Таймер идёт, пока экран открыт" : "Готово!"))
.style({ color: colors.muted, fontSize: 15 }),
)
.style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
.onOpen(() => {
timer = setInterval(() => { if (left.value > 0) left.value-- }, 1000)
})
.onClose(() => {
if (timer !== null) { clearInterval(timer); timer = null }
})
.onBackPressed(() => Router.pop())
}
const Row = (title: string, seconds: number) =>
UIButton(
UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
UIText(`${seconds} с`).style({ color: colors.muted, fontSize: 13 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
.onClick(() => Router.push(TimerScreen(title, seconds)))
const home = UIScreen(
UIText("Фокус").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(Row("Растяжка", 60), Row("Медитация", 120), Row("Планка", 45)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
Router.init(home)Открой таймер, подожди пару секунд, вернись и открой снова — он стартует заново с новым экраном, а старый интервал остановлен в onClose. Обрати внимание, что left и timer объявлены внутри фабрики: у каждого открытого экрана своё состояние, и оно умирает вместе с ним.
Тот же паттерн — для setLoop в анимациях, setInterval для опроса сервера, WebSocket в чате. Полные правила — в соглашениях SDK.
Вкладки — UITabs
Нижняя панель вкладок — самая частая оболочка мобильного приложения, и в LeCodes она готовая: UITabs — это UIScreen из UIPager (вкладки, свайпающиеся нативно, у каждой свой стек) и таб-бара, который красится через переменные темы. Ключи объекта — id вкладок, в порядке следования:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", primaryColor: colors.accent, mutedColor: colors.muted, tabbarBg: "#15151a", tabbarBorder: "#26262e" })
const HabitScreen = (title: string) =>
UIScreen(
UIButton(UIText("← Назад").style({ color: colors.muted, fontSize: 15 }))
.style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
.onClick(() => UIPager.pop()),
UIText(title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
UIText("Экран открыт внутри вкладки — таб-бар остался").style({ color: colors.muted, fontSize: 15 }),
)
.style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
.onBackPressed(() => UIPager.pop())
const Row = (title: string) =>
UIButton(UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }), UIText("›").style({ color: colors.muted, fontSize: 22 }))
.style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
.onClick(() => UIPager.push(HabitScreen(title))) // push ВНУТРИ текущей вкладки
const today = UIScreen(
UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(Row("Утренняя пробежка"), Row("Прочитать 20 страниц"), Row("Растяжка")).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
const history = UIScreen(
UIText("История").style({ fontSize: 32, fontWeight: 800 }),
UIText("Здесь будет список отметок").style({ color: colors.muted, fontSize: 15 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
const tabs = UITabs({
today: { label: "Сегодня", icon: assetIcon("lucide:circle-check"), screen: today },
history: { label: "История", icon: assetIcon("lucide:calendar"), screen: history },
})
tabs.badge("history", 3)
Router.init(tabs)Нажми на привычку — экран откроется внутри вкладки, таб-бар останется на месте; свайп между вкладками работает у корня каждой вкладки. Что стоит запомнить:
- Два глагола навигации.
UIPager.push(screen)— открыть экран в текущей вкладке (бар виден, «назад» жестом с края экрана — нативный).Router.push(screen)— открыть поверх всего, включая бар: авторизация, полноэкранный плеер. Оба глобальны — из любого экрана, без ссылок. - Бар красится темой:
primaryColor— активная вкладка,mutedColor— неактивные,tabbarBg/tabbarBorder— поверхность и волосяная линия. У неокрашенного приложения тёмные дефолты. assetIcon("lucide:calendar")— макрос компиляции: иконка из реестра инлайнится в бандл на этапе сборки, id — строковый литерал.tabs.select("history"),tabs.tab,tabs.onSelect((id) => …),tabs.badge(id, 3 | true | 0)— программное управление. Каждая вкладка помнит свой стек: ушёл со вкладки на глубине три — вернулся ровно туда же.- Нужен свой бар — опускайся на уровень ниже:
UIPager(a, b)плюс собственныйUIRowкнопок;pager.onSelectсинхронизирует подсветку при свайпе.
Как это раскладывается по файлам
Один файл на экран, точка входа собирает оболочку — это стандартная форма приложения LeCodes:
// screens/today.ts
import { colors } from "../theme"
export const todayScreen = UIScreen(/* … */).style({ bgColor: colors.bg })
// screens/habit.ts
export const HabitScreen = (h: Habit) => UIScreen(/* … */).onBackPressed(() => UIPager.pop())
// main.ts
import { todayScreen } from "./screens/today"
import { historyScreen } from "./screens/history"
Router.init(UITabs({
today: { label: "Сегодня", icon: assetIcon("lucide:circle-check"), screen: todayScreen },
history: { label: "История", icon: assetIcon("lucide:calendar"), screen: historyScreen },
}))Экран, который открывается с данными, — экспортируемая фабрика; экран-вкладка — готовый объект. Ни один из них не импортирует роутер: Router и UIPager — глобалы.
Приложение целиком
Трекер с двумя вкладками, экраном привычки и живым бейджем — сколько привычек осталось на сегодня:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", primaryColor: colors.accent, mutedColor: colors.muted, tabbarBg: "#15151a", tabbarBorder: "#26262e" })
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; streak: number }
const habits = signal<Habit[]>([
{ id: 1, title: "Утренняя пробежка", time: "07:30", color: "#34c759", done: true, streak: 12 },
{ id: 2, title: "Прочитать 20 страниц", time: "13:00", color: "#ff9f0a", done: true, streak: 5 },
{ id: 3, title: "Позаниматься на пианино", time: "18:00", color: "#5e9eff", done: false, streak: 2 },
{ id: 4, title: "Растяжка", time: "20:30", color: "#af6bff", done: false, streak: 31 },
])
const leftCount = 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 BackButton = () =>
UIButton(UIText("← Назад").style({ color: colors.muted, fontSize: 15 }))
.style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
.onClick(() => UIPager.pop())
const HabitScreen = (h: Habit) =>
UIScreen(
BackButton(),
UIText(h.title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
UIText(`Каждый день в ${h.time}`).style({ ...type.small, fontSize: 15 }),
UIColumn(UIText(`${h.streak}`).style({ fontSize: 40, fontWeight: 800 }), UIText("дней подряд").style(type.small))
.style({ bgColor: colors.card, borderRadius: 16, p: 16, mt: 16, alignItems: "center" }),
UIButton(UIText(() => (habits.value.find((x) => x.id === h.id)?.done ? "Снять отметку" : "Отметить")).style({ ...type.body, color: "#ffffff" }))
.style({ bgColor: h.color, borderRadius: 16, p: 16, mt: 12, $pressed: { opacity: 0.8 } })
.onClick(() => { const cur = habits.value.find((x) => x.id === h.id); if (cur) toggle(cur) }),
)
.style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
.onBackPressed(() => UIPager.pop())
const HabitRow = (h: Habit) =>
UIButton(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color, opacity: h.done ? 1 : 0.35 }),
UIColumn(UIText(h.title).style(type.body), UIText(h.done ? "готово" : h.time).style(type.small)).style({ gap: 2, flexGrow: 1 }),
UIText("›").style({ color: colors.muted, fontSize: 22 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, $pressed: { opacity: 0.6 } })
.onClick(() => UIPager.push(HabitScreen(h)))
const today = UIScreen(
UIText("Сегодня").style(type.h1),
UIText(() => `Осталось ${leftCount.value} из ${habits.value.length}`).style({ ...type.small, fontSize: 15 }),
UIScrollable(() => habits.value.map(HabitRow)).style({ flexGrow: 1, gap: 10, mt: 12, showScrollbar: false }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
const history = UIScreen(
UIText("История").style(type.h1),
UIColumn(
[0, 1, 2, 3, 4, 5, 6].map((d) =>
UIRow(
UIText(date().subtract(d, "day").format("dddd, D MMMM")).style({ ...type.body, flexGrow: 1 }),
UIText(`${4 - (d % 3)} / 4`).style(type.small),
).style({ bgColor: colors.card, borderRadius: 16, p: 14 }),
),
).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
const tabs = UITabs({
today: { label: "Сегодня", icon: assetIcon("lucide:circle-check"), screen: today },
history: { label: "История", icon: assetIcon("lucide:calendar"), screen: history },
})
effect(() => tabs.badge("today", leftCount.value)) // бейдж следует за состоянием
Router.init(tabs)Две детали, которые легко пропустить. Во-первых, effect — первый раз в треке: бейдж на вкладке — не привязка внутри элемента, а вызов метода, поэтому его нужно повторять руками при каждой смене состояния — ровно то, что делает effect. Во-вторых, экран привычки получает снимок h в момент открытия, поэтому кнопка «Отметить» ищет актуальную запись по id в сигнале — так экран не устаревает, если привычку отметили из списка.
Что ты узнал
Router.initодин раз в точке входа;Router.push/pop/replace— глобальны. Экраны в стеке живут со своим состоянием, снятые — уничтожаются.- Экран с данными — функция-фабрика; его состояние объявляется внутри неё.
onOpen/onCloseсрабатывают при каждой активации; всё запущенное вonOpenостанавливается вonClose.UITabs— готовая оболочка: вкладки со своими стеками, бар из переменных темы,badge.UIPager.push— внутри вкладки,Router.push— поверх всего.onBackPressedна экране — для аппаратной «назад»;assetIcon("pack:name")— иконки из реестра на этапе сборки.
Справочник: Экраны и роутер · UIPager и UITabs · Соглашения — жизненный цикл · Контент: иконки.
Дальше: формы и клавиатура — экран «Новая привычка»: поля ввода, валидация, цепочка фокуса и всё, что нужно знать о клавиатуре.