LeCodesdocs

UIScreen & Router

UIScreen is the root of every UI tree — it always fills the device and behaves as a UIColumn. A screen never scrolls; scrolling lives in a child (see below). A single-screen app calls screen.open(); a multi-page app hands its screens to Router and navigates with push / pop / replace.

At a glance

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     // show this screen directly (single-screen apps; hides an active Router)
screen.close(): void    // close the directly-opened screen

Full container surface: .append / .insert / .remove / .setContent, .style, .animateTo / .animateFrom, .onLayout.

Note

a screen always fills the device — sizing styles on it (width, height, flexGrow, flexShrink, position) are no-ops. Style its padding, background, and layout of children. The default background is black; always set bgColor and text colors explicitly.

Lifecycle — onOpen / onClose

TypeScript
screen.onOpen(cb: () => void): this    // screen became the active one
screen.onClose(cb: () => void): this   // screen stopped being the active one

These fire on every activation, not just the first: pushing away from a screen fires its onClose, popping back to it fires onOpen again. Anything started in onOpen must be stopped in onClose — loops, intervals, sockets — or it keeps running behind other screens. See Lifecycle.

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

Touch & back button

TypeScript
screen.onTouchStart(ev => …)          // fires for touches anywhere on the screen (full-screen gestures)
screen.onBackPressed(cb: () => void)  // Android hardware/gesture back — typically Router.pop()

onTouchStart supports ev.track() like a button — see Pointer events.

A back press resolves in order: the topmost open widget's onBackPressed → the current screen/scene's onBackPressed → a drilled-in pager tab pops → the router pops → the global app.onBackPressed fallback. If nothing handles it, the press is ignored — the app does not exit, same as the iOS back gesture at the stack root.

The global fallback is the place for app-wide policy: it fires only at the very end of the chain, so navigation everywhere else keeps working untouched. The classic double-press-to-exit:

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() returns to the launcher inside the LeCodes viewer; a standalone-built app leaves to the platform (Android backgrounds it, iOS ignores the call).

Screens never scroll

A screen is a fixed layout root — it has no scroll position, no scroll events, no pull-to-refresh. Scrolling always lives in a child. The canonical screen is fixed chrome (header, tab bar) plus one scrollable body with flexGrow: 1:

TypeScript
UIScreen(
  Header("Profile", BackButton()),                 // fixed chrome — stays put
  UIScrollable(content)                       // ONE scrolling body
    .style({ flexGrow: 1, px: 24, gap: 16 })
    .onRefresh(async () => { await load() }),      // pull-to-refresh lives on the scrollable
).style({ bgColor: "black", pt: "safe-top" })

Scroll events (onScroll / onScrollRelease / onOverscroll), pull-to-refresh (onRefresh) and the refreshControlColor style all belong to UIScrollable — and to UIVirtualizedList for long data.

Router

TypeScript
Router.init(homePage, opts?: { showDefaultBackButton?: boolean })  // call once in the entry file
Router.push(screen)                    // push onto the stack, screen becomes active
Router.pop(to?: number)                // default -1 = one screen back
Router.replace(screen, opts?: { transition })   // swap the top screen
Router.current                         // top of the router stack (getter)
Router.hide()                          // hide the whole router (stack stays alive)
Router.restore()                       // bring it back, reactivating the top screen

Presentable.current                    // what's actually on screen right now (or null)

Router.current is the top of the stack — it stays meaningful even while the router is suspended by a direct open(). Presentable.current is the destination actually visible (a UIScreen, Scene, …, or null before the first open) — UIWidget.show() attaches to it.

  • pop(to) — negative values are relative (-2 = back two screens); 0 and positive values are an absolute index into the stack (0 = the home screen). Screens that leave the stack are destroyed.
  • replace transitions: "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom" | "zoom" | "zoom-out" | "zoom-in" | "fade" | "none" — default "none", themeable app-wide: theme({ replaceTransition: "fade" }) (any name or custom spec; an explicit per-call transition still wins, null resets — see theme.md).
  • Platform note: the desktop host does not animate transitions yet — every push/replace/pop swaps instantly, whatever transition is passed (named or spec). Entrance choreography via onOpen + animateFrom works everywhere and is the portable alternative today.
  • init's showDefaultBackButton (default false) shows a host-provided back button.

How screens live on the stack

Screens below the top stay mounted — their element trees and state survive, they're just not rendered. Navigation drives lifecycle callbacks: push fires the outgoing screen's onClose and the incoming one's onOpen; pop fires them in reverse, destroys the popped screens' native trees, and re-fires onOpen on the screen you land on. A popped UIScreen object is still usable — pushing it again remounts it. Router.hide() fires the top screen's onClose; restore() fires onOpen again. Calling screen.open() while a router is active hides the router (its stack stays restorable).

Route-change events

TypeScript
Router.addEventListener("change", (screen: UIScreen) => …)   // fires after every navigation
Router.removeEventListener("change", cb)

The callback receives the newly active screen — useful for a shared tab bar or analytics.

Pitfalls

TypeScript
// ✗ scroll methods on a screen — screens never scroll
UIScreen(...).onRefresh(load)       // method doesn't exist on a screen
// ✓ scrolling and pull-to-refresh live on a UIScrollable body
UIScreen(UIScrollable(...).style({ flexGrow: 1 }).onRefresh(load))

// ✗ loop started in onOpen, never stopped — keeps running after Router.push()
screen.onOpen(() => setLoop(update))
// ✓ pair it in onClose (fires on every navigation away)

See also