LeCodesdocs

UIScreen и Router

UIScreen — корень каждого UI-дерева — он всегда заполняет устройство и ведёт себя как UIColumn. Экран никогда не прокручивается; прокрутка живёт в ребёнке (см. ниже). Одноэкранное приложение вызывает screen.open(); многостраничное отдаёт свои экраны Router и навигирует через push / pop / replace.

Обзор

TypeScript
const detail = UIScreen(
  UIText("Detail").style({ fontSize: 24, fontWeight: 700, color: "white" }),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
  .onBackPressed(() => Router.pop())

const home = UIScreen(
  UIText("Home").style({ fontSize: 24, fontWeight: 700, color: "white" }),
  UIButton(UIText("Open detail").style({ color: "white" }))
    .style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
    .onClick(() => Router.push(detail)),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)", gap: 16 })

Router.init(home)

UIScreen

TypeScript
UIScreen(...children: (UINodeChild | UINodeChild[])[]): UIScreen

screen.open(): void     // показать этот экран напрямую (одноэкранные приложения; прячет активный Router)
screen.close(): void    // закрыть напрямую открытый экран

Полная поверхность контейнера: .append / .insert / .remove / .setContent, .style, .animateTo / .animateFrom, .onLayout.

Note

экран всегда заполняет устройство — стили размера на нём (width, height, flexGrow, flexShrink, position) — no-op. Стилизуй его отступы, фон и раскладку детей. Фон по умолчанию чёрный; всегда задавай bgColor и цвета текста явно.

Жизненный цикл — onOpen / onClose

TypeScript
screen.onOpen(cb: () => void): this    // экран стал активным
screen.onClose(cb: () => void): this   // экран перестал быть активным

Они срабатывают на каждую активацию, не только на первую: уход с экрана вызывает его onClose, возврат к нему — снова onOpen. Всё запущенное в onOpen должно останавливаться в onClose — циклы, интервалы, сокеты — иначе продолжит работать за другими экранами. См. Жизненный цикл.

TypeScript
let loopId: number | null = null
screen
  .onOpen(() => { loopId = setLoop(dt => tick(dt)) })
  .onClose(() => { if (loopId !== null) { clearLoop(loopId); loopId = null } })

Касания и кнопка «назад»

TypeScript
screen.onTouchStart(ev => …)          // срабатывает на касания где угодно на экране (полноэкранные жесты)
screen.onBackPressed(cb: () => void)  // аппаратный/жестовый «назад» Android — обычно Router.pop()

onTouchStart поддерживает ev.track() как кнопка — см. События указателя.

Нажатие «назад» разрешается по порядку: onBackPressed самого верхнего открытого виджета → onBackPressed текущего экрана/сцены → возврат из вложенной вкладки пейджера → pop роутера → глобальный запасной app.onBackPressed. Если не обработал никто, нажатие игнорируется — приложение не выходит, как и жест «назад» в iOS в корне стека.

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

TypeScript
let armedAt = 0
app.onBackPressed(() => {
  if (Date.now() - armedAt < 2000) { app.quit(); return }
  armedAt = Date.now()
  toast("Press back again to exit")
})

app.quit() возвращает в лаунчер внутри вьювера LeCodes; собранное standalone-приложение уходит в платформу (Android сворачивает его, iOS игнорирует вызов).

Экраны никогда не прокручиваются

Экран — фиксированный корень раскладки: у него нет позиции прокрутки, нет скролл-событий, нет pull-to-refresh. Прокрутка всегда живёт в ребёнке. Канонический экран — фиксированный хром (шапка, таб-бар) плюс одно прокручиваемое тело с flexGrow: 1:

TypeScript
UIScreen(
  Header("Profile", BackButton()),                 // фиксированный хром — стоит на месте
  UIScrollable(content)                       // ОДНО прокручиваемое тело
    .style({ flexGrow: 1, px: 24, gap: 16 })
    .onRefresh(async () => { await load() }),      // pull-to-refresh живёт на скроллируемом
).style({ bgColor: "black", pt: "safe-top" })

Скролл-события (onScroll / onScrollRelease / onOverscroll), pull-to-refresh (onRefresh) и стиль refreshControlColor принадлежат UIScrollable — и UIVirtualizedList для длинных данных.

Router

TypeScript
Router.init(homePage, opts?: { showDefaultBackButton?: boolean })  // вызови один раз в файле входа
Router.push(screen)                    // положить в стек, экран становится активным
Router.pop(to?: number)                // по умолчанию -1 = на экран назад
Router.replace(screen, opts?: { transition })   // подменить верхний экран
Router.current                         // вершина стека роутера (геттер)
Router.hide()                          // спрятать весь роутер (стек остаётся живым)
Router.restore()                       // вернуть его, реактивировав верхний экран

Presentable.current                    // то, что реально на экране прямо сейчас (или null)

Router.current — вершина стека — он остаётся осмысленным, даже пока роутер приостановлен прямым open(). Presentable.current — реально видимый сейчас пункт назначения (UIScreen, Scene, …, или null до первого открытия) — к нему прикрепляется UIWidget.show().

  • pop(to) — отрицательные значения относительны (-2 = на два экрана назад); 0 и положительные значения — абсолютный индекс в стеке (0 = домашний экран). Экраны, покидающие стек, уничтожаются.
  • Переходы replace: "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom" | "zoom" | "zoom-out" | "zoom-in" | "fade" | "none" — по умолчанию "none", темируется на всё приложение: theme({ replaceTransition: "fade" }) (любое имя или кастомная спецификация; явный transition в вызове всё равно побеждает, null сбрасывает — см. theme.md).
  • Замечание про платформы: desktop-хост пока не анимирует переходы — любой push/replace/pop подменяет экран мгновенно, какой бы переход ни передали (имя или спецификация). Хореография появления через onOpen + animateFrom работает везде и сегодня остаётся переносимой альтернативой.
  • showDefaultBackButton у init (по умолчанию false) показывает предоставленную хостом кнопку «назад».

Как экраны живут в стеке

Экраны ниже верхнего остаются смонтированными — их деревья элементов и состояние выживают, они просто не рендерятся. Навигация запускает колбэки жизненного цикла: push вызывает onClose уходящего экрана и onOpen входящего; pop вызывает их в обратном порядке, уничтожает нативные деревья снятых экранов и снова вызывает onOpen на экране, куда ты приземляешься. Снятый объект UIScreen всё ещё пригоден — повторный push его перемонтирует. Router.hide() вызывает onClose верхнего экрана; restore() снова вызывает onOpen. Вызов screen.open(), пока роутер активен, прячет роутер (его стек остаётся восстановимым).

События смены маршрута

TypeScript
Router.addEventListener("change", (screen: UIScreen) => …)   // срабатывает после каждой навигации
Router.removeEventListener("change", cb)

Колбэк получает вновь активный экран — полезно для общего таб-бара или аналитики.

Подводные камни

TypeScript
// ✗ скролл-методы на экране — экраны никогда не прокручиваются
UIScreen(...).onRefresh(load)       // метода нет на экране
// ✓ прокрутка и pull-to-refresh живут на теле UIScrollable
UIScreen(UIScrollable(...).style({ flexGrow: 1 }).onRefresh(load))

// ✗ цикл, запущенный в onOpen, никогда не остановлен — продолжает работать после Router.push()
screen.onOpen(() => setLoop(update))
// ✓ спаруй его в onClose (срабатывает на каждый уход навигацией)

Смотрите также