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

Оверлеи

Шаг 7 UI-трека. Не всё заслуживает отдельного экрана: подтверждение удаления, выбор «повторять по будням», меню из трёх пунктов у строки — это слои поверх текущей страницы. В LeCodes у них один корень — UIWidget — и три готовые формы: UIModal для диалогов, UIBottomSheet для шторок снизу и UIPopover для меню у кнопки. Плюс toast — самая дешёвая обратная связь.

Слой над страницей

UIWidget — плавающий элемент над текущей страницей, позиционируется в координатах экрана (top / left / right / bottom, безопасные зоны работают). Он невидим, пока не вызван show(), и создаётся один раз на уровне модуля — видимость императивна, пересоздавать виджет на каждый показ не нужно. Стиль overlayColor добавляет под ним подложку, которая перехватывает тапы, — так виджет становится модальным.

Для стандартного диалога собирать это руками не нужно: UIModal — тот же виджет, но уже с подложкой, анимацией появления и закрытием по тапу мимо и по кнопке «назад». Один диалог на всё приложение — а какую привычку удаляем, помнит сигнал:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })

type Habit = { id: number; title: string; color: string }
const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", color: "#34c759" },
  { id: 2, title: "Прочитать 20 страниц", color: "#ff9f0a" },
  { id: 3, title: "Растяжка", color: "#af6bff" },
])
const pending = signal<Habit | null>(null)          // что удаляем

const Btn = (label: string, bg: string, onTap: () => void) =>
  UIButton(UIText(label).style({ fontSize: 15, fontWeight: 600 }))
    .style({ flex: 1, height: 44, borderRadius: 12, bgColor: bg, $pressed: { opacity: 0.7 } })
    .onClick(onTap)

const confirm = UIModal(
  UIText("Удалить привычку?").style({ fontSize: 18, fontWeight: 700 }),
  UIText(() => ${pending.value?.title ?? ""}» и вся её история исчезнут.`).style({ color: colors.muted, fontSize: 14 }),
  UIRow(
    Btn("Отмена", colors.line, () => confirm.hide()),
    Btn("Удалить", colors.danger, () => {
      habits.value = habits.value.filter((h) => h !== pending.value)
      confirm.hide()
    }),
  ).style({ gap: 10, mt: 8 }),
).style({ left: 24, right: 24, top: "36%", p: 20, gap: 8, borderRadius: 20, bgColor: colors.card })

const HabitRow = (h: Habit) =>
  UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
    UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIButton(UIText("Удалить").style({ color: colors.danger, fontSize: 13, fontWeight: 600 }))
      .style({ height: 32, px: 12, borderRadius: 16, bgColor: colors.bg, $pressed: { opacity: 0.6 } })
      .onClick(() => { pending.value = h; confirm.show() }),
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 10, gap: 12, alignItems: "center" })

const screen = UIScreen(
  UIText("Привычки").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
  UIText(() => (habits.value.length === 0 ? "Всё удалено — чистый лист" : "")).style({ color: colors.muted, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

screen.open()

Нажми «Удалить» у любой строки: подложка затемняет экран, диалог появляется с плавным появлением, тап мимо или кнопка «назад» закрывают его. Ничего из этого писать не пришлось.

  • show() открывает виджет глобальным слоем над всеми экранами: он переживает навигацию, пока ты не вызовешь hide(). Чтобы слой принадлежал странице (HUD над сценой, подсказка на конкретном экране) — widget.attachTo(owner) до show().
  • Текст диалога — привязка к сигналу pending: один экземпляр диалога, содержимое меняется перед показом.
  • modal.dismissible(false) выключает закрытие по тапу мимо и по «назад» — для обязательного выбора. modal.onOpen / modal.onClose — хуки, modal.isOpen — состояние.
  • Своя анимация — modal.transition({ transform: "translateY(480px)", duration: 250 }): показ идёт из этой позы, скрытие — в неё.
  • У «голого» UIWidget подложка — overlayColor, тап по ней — onOverlayTap, а исчезновение с анимацией — animateTo({ opacity: 0, commit: false }) + hide() по таймеру. UIModal делает всё это за тебя.

Шторка снизу

Выбор из нескольких вариантов на телефоне — это шторка от нижнего края. UIBottomSheet без настроек — как раз она: высота по содержимому, подложка, свайп вниз закрывает (на нативных хостах — с настоящей физикой пальца; в веб-симуляторе шторка статична). Всё модальное — подложка, «назад», dismissible — как у UIModal:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })

const OPTIONS = ["Каждый день", "По будням", "По выходным", "Три раза в неделю"]
const repeat = signal(OPTIONS[0])

const Option = (label: string) =>
  UIButton(
    UIText(label).style({ fontSize: 16, flexGrow: 1, color: () => (repeat.value === label ? colors.accent : "#ffffff") }),
    UIText("✓").style({ color: colors.accent, fontSize: 16, opacity: () => (repeat.value === label ? 1 : 0) }),
  )
    .style({ height: 52, px: 16, borderRadius: 12, $pressed: { bgColor: colors.line } })
    .onClick(() => { repeat.value = label; sheet.hide() })

const sheet = UIBottomSheet(
  UIColumn().style({ width: 44, height: 5, borderRadius: 3, bgColor: colors.line, alignSelf: "center", mb: 10 }),
  UIText("Повторять").style({ fontSize: 16, fontWeight: 700, textAlign: "center", mb: 6 }),
  OPTIONS.map(Option),
).style({ bgColor: colors.card, borderRadius: 20, pt: 12, px: 12, pb: "max(safe-bottom, 20px)" })

const screen = UIScreen(
  UIText("Растяжка").style({ fontSize: 32, fontWeight: 800 }),
  UIText("Настройки привычки").style({ color: colors.muted, fontSize: 15 }),

  UIButton(
    UIText("Повторять").style({ fontSize: 16, flexGrow: 1 }),
    UIText(() => repeat.value).style({ color: colors.muted, fontSize: 15 }),
    UIText("›").style({ color: colors.muted, fontSize: 22, ml: 8 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, mt: 16, $pressed: { opacity: 0.6 } })
    .onClick(() => sheet.show()),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })

screen.open()
  • Без detents() шторка ростом с содержимое (не выше экрана — длинный список заверни в UIScrollable внутри). С .detents([0.3, 0.6, 1]) — карта-стайл: позиции в долях высоты экрана, палец переключает между ними, setDetent(i) — из кода, onDetentChange — событие.
  • Свайп ниже нижней позиции закрывает шторку; с dismissible(false) она вместо этого схлопывается до нижней — постоянная панель как в картах.
  • Строка-«хэндл» сверху — просто узкая UIColumn, а не элемент SDK.
  • Как и любой виджет — один экземпляр на уровне модуля; строки внутри привязаны к сигналу repeat, поэтому галочка переезжает сама.

Меню у кнопки

Три действия у строки — не повод для шторки на пол-экрана. UIPopover — модалка с прозрачной подложкой (тап мимо закрывает, под ней ничего не прокручивается) и позицией от якоря: menu.show(button) ставит меню под кнопкой, а если внизу нет места — над ней, и всегда в пределах экрана. Из-за длинного нажатия можно открыть в точке пальца: menu.show({ x: ev.clientX, y: ev.clientY }).

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })

type Habit = { id: number; title: string; color: string }
const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", color: "#34c759" },
  { id: 2, title: "Прочитать 20 страниц", color: "#ff9f0a" },
  { id: 3, title: "Растяжка", color: "#af6bff" },
])
let target: Habit | null = null                       // строка, для которой открыто меню

const Item = (label: string, color: string, action: (h: Habit) => void) =>
  UIButton(UIText(label).style({ fontSize: 15, color }))
    .style({ height: 44, px: 14, justifyContent: "flex-start", $pressed: { bgColor: colors.line } })
    .onClick(() => { menu.hide(); if (target) action(target) })

const menu = UIPopover(
  Item("Дублировать", "#ffffff", (h) => {
    habits.value = [...habits.value, { ...h, id: Date.now(), title: `${h.title} (копия)` }]
  }),
  Item("Наверх", "#ffffff", (h) => {
    habits.value = [h, ...habits.value.filter((x) => x !== h)]
  }),
  Item("Удалить", colors.danger, (h) => {
    habits.value = habits.value.filter((x) => x !== h)
    toast("Привычка удалена")
  }),
).style({ width: 200, borderRadius: 12, bgColor: colors.card, border: `1px solid ${colors.line}`, py: 4 })

const HabitRow = (h: Habit) => {
  const more = UIButton(UIText("⋯").style({ fontSize: 22, color: colors.muted }))
    .style({ width: 36, height: 36, borderRadius: 18, $pressed: { bgColor: colors.line } })
    .onClick(() => { target = h; menu.show(more) })     // якорь — сама кнопка
  return UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
    UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    more,
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 8, gap: 12, alignItems: "center" })
}

const screen = UIScreen(
  UIText("Привычки").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

screen.open()
  • Позиция вычисляется один раз при показе — меню не следует за якорем; это платформенная норма (и причина, по которой подложка блокирует прокрутку).
  • Кнопка «⋯» захвачена в переменную more, чтобы передать её в show(more); target — обычная переменная: меню одно, а строка меняется перед каждым показом.
  • toast("…") — нативное всплывающее сообщение хоста: одна строка, без стилей, исчезает само. Для «отменить удаление» уже нужен виджет с кнопкой.
  • Длинное нажатие — button.onLongPress(ev => menu.show({ x: ev.clientX, y: ev.clientY })); на реальных хостах оно глотает последующий клик, так что onClick и onLongPress уживаются на одной кнопке.

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

Три оверлея в одном экране трекера: меню «⋯» у строки, из него — шторка «Повторять» и диалог удаления, тост после действий:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })

type Habit = { id: number; title: string; color: string; repeat: string }
const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", color: "#34c759", repeat: "Каждый день" },
  { id: 2, title: "Прочитать 20 страниц", color: "#ff9f0a", repeat: "По будням" },
  { id: 3, title: "Растяжка", color: "#af6bff", repeat: "Каждый день" },
])
const target = signal<Habit | null>(null)
const patch = (h: Habit, changes: Partial<Habit>) => {
  habits.value = habits.value.map((x) => (x === h ? { ...x, ...changes } : x))
}

const OPTIONS = ["Каждый день", "По будням", "По выходным"]
const sheet = UIBottomSheet(
  UIColumn().style({ width: 44, height: 5, borderRadius: 3, bgColor: colors.line, alignSelf: "center", mb: 10 }),
  UIText("Повторять").style({ fontSize: 16, fontWeight: 700, textAlign: "center", mb: 6 }),
  OPTIONS.map((label) =>
    UIButton(UIText(label).style({ fontSize: 16, flexGrow: 1, color: () => (target.value?.repeat === label ? colors.accent : "#ffffff") }))
      .style({ height: 52, px: 16, borderRadius: 12, $pressed: { bgColor: colors.line } })
      .onClick(() => { if (target.value) patch(target.value, { repeat: label }); sheet.hide() }),
  ),
).style({ bgColor: colors.card, borderRadius: 20, pt: 12, px: 12, pb: "max(safe-bottom, 20px)" })

const confirm = UIModal(
  UIText("Удалить привычку?").style({ fontSize: 18, fontWeight: 700 }),
  UIText(() => ${target.value?.title ?? ""}» исчезнет вместе с историей.`).style({ color: colors.muted, fontSize: 14 }),
  UIRow(
    UIButton(UIText("Отмена").style({ fontWeight: 600 })).style({ flex: 1, height: 44, borderRadius: 12, bgColor: colors.line }).onClick(() => confirm.hide()),
    UIButton(UIText("Удалить").style({ fontWeight: 600 })).style({ flex: 1, height: 44, borderRadius: 12, bgColor: colors.danger }).onClick(() => {
      habits.value = habits.value.filter((h) => h !== target.value)
      confirm.hide()
      toast("Привычка удалена")
    }),
  ).style({ gap: 10, mt: 8 }),
).style({ left: 24, right: 24, top: "36%", p: 20, gap: 8, borderRadius: 20, bgColor: colors.card })

const Item = (label: string, color: string, action: () => void) =>
  UIButton(UIText(label).style({ fontSize: 15, color }))
    .style({ height: 44, px: 14, justifyContent: "flex-start", $pressed: { bgColor: colors.line } })
    .onClick(() => { menu.hide(); action() })
const menu = UIPopover(
  Item("Повторять…", "#ffffff", () => sheet.show()),
  Item("Дублировать", "#ffffff", () => {
    const h = target.value
    if (h) { habits.value = [...habits.value, { ...h, id: Date.now(), title: `${h.title} (копия)` }]; toast("Скопировано") }
  }),
  Item("Удалить", colors.danger, () => confirm.show()),
).style({ width: 200, borderRadius: 12, bgColor: colors.card, border: `1px solid ${colors.line}`, py: 4 })

const HabitRow = (h: Habit) => {
  const more = UIButton(UIText("⋯").style({ fontSize: 22, color: colors.muted }))
    .style({ width: 36, height: 36, borderRadius: 18, $pressed: { bgColor: colors.line } })
    .onClick(() => { target.value = h; menu.show(more) })
  return UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
    UIColumn(
      UIText(h.title).style({ fontSize: 16, fontWeight: 600 }),
      UIText(h.repeat).style({ color: colors.muted, fontSize: 13 }),
    ).style({ gap: 2, flexGrow: 1 }),
    more,
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 8, gap: 12, alignItems: "center" })
}

const screen = UIScreen(
  UIText("Привычки").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

screen.open()

Все три оверлея созданы один раз и делят один сигнал target — «строка, над которой сейчас работаем». Меню открывает шторку или диалог, закрывшись само: у каждого слоя своя подложка, и на экране всегда ровно один. Обрати внимание, что здесь target — сигнал, а не переменная, потому что к нему привязаны тексты диалога и галочка в шторке.

Что ты узнал

  • UIWidget — слой над страницей: создаётся один раз, show() / hide(), overlayColor делает его модальным, attachTo(owner) привязывает к странице.
  • UIModal — диалог с подложкой, анимацией и закрытием по тапу мимо / «назад» из коробки; dismissible(false) для обязательного выбора, transition() для своей позы.
  • UIBottomSheet — шторка по содержимому или с detents(); свайп закрывает.
  • UIPopover — меню от якоря: show(button) или show({ x, y }); позиция считается один раз.
  • toast() — нативное сообщение без стилей; содержимое оверлеев привязывай к сигналу «текущая цель», а не пересоздавай виджеты.

Справочник: UIWidget, UIModal, UIPopover, UIBottomSheet · Тост · События указателя.

Дальше: полировка — анимации появления, отклик на нажатие, безопасные зоны и всё, что отличает «работает» от «приятно».