UIScreen & Router
UIScreen is the root of every UI tree — it always fills the device and behaves as a UIColumn
(or as a scroll container after .makeScrollable()). 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
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
UIScreen(): UIScreen
UIScreen(children: UINodeChild[]): UIScreen
screen.open(): void // show this screen directly (single-screen apps; hides an active Router)
screen.close(): void // close the directly-opened screenFull container surface: .append / .insert / .remove / .setContent, .style,
.animateTo / .animateFrom, .onLayout.
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
screen.onOpen(cb: () => void): this // screen became the active one
screen.onClose(cb: () => void): this // screen stopped being the active oneThese 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.
let loopId: number | null = null
screen
.onOpen(() => { loopId = setLoop(dt => tick(dt)) })
.onClose(() => { if (loopId !== null) { clearLoop(loopId); loopId = null } })Touch & back button
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.
Scrolling — makeScrollable()
A screen is not scrollable by default. makeScrollable() turns the whole screen into one
vertical scroll flow and unlocks three methods that don't exist otherwise:
const screen = UIScreen([...])
.makeScrollable() // returns the same screen, now scrollable
.onRefresh(async () => { await load() }) // pull-to-refresh; spinner hides when the promise resolves
screen.onScroll(pos => { }) // scroll position in px
screen.onOverscroll(pos => { }) // pull beyond the edgeof the three, only onRefresh is declared chainable (returns the screen); call
onScroll / onOverscroll as statements.
refreshControlColor (a screen style) tints the native pull-to-refresh spinner.
pull-to-refresh exists only on makeScrollable() — a child UIScrollable cannot
provide it. Rule of thumb: whole screen is one flow (article, feed, long form) →
makeScrollable(); a scroll region inside a fixed layout (list under a pinned header) → a child
UIScrollable.
Router
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 // the active UIScreen (getter)
Router.hide() // hide the whole router (stack stays alive)
Router.restore() // bring it back, reactivating the top screenpop(to)— negative values are relative (-2= back two screens);0and positive values are an absolute index into the stack (0= the home screen). Screens that leave the stack are destroyed.replacetransitions:"slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom" | "zoom" | "zoom-out" | "zoom-in" | "fade" | "none"— default"fade".init'sshowDefaultBackButton(defaultfalse) 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
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
// ✗ onRefresh / onScroll without makeScrollable()
UIScreen([...]).onRefresh(load) // method doesn't exist on a plain screen
// ✓
UIScreen([...]).makeScrollable().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
- UIWidget — overlays that float above the screen stack
- Containers —
UIColumnlayout rules,UIScrollable - Conventions § Lifecycle
- Pointer events & gestures