UIScreen & Router

UIScreen is one whole screen of the app; Router is the stack of screens — push opens one on top, pop and the back button go back, replace swaps the top. Pages side by side that the user swipes between are a UIPager, and the standard bottom-tab shell with a stack per tab is UITabs (both pager.md).

  • open a details screen and come back
  • handle the Android back button or the iOS back swipe on a screen (onBack)
  • start a timer when a screen opens and stop it when the screen closes (onOpen / onClose)
  • slide or fade to the next screen with a transition of your own
  • replace the login screen with the home screen so back does not return to it
  • go back to the first screen from deep in the stack (Router.pop(0))
  • keep a header fixed while the content under it scrolls
  • know which screen is shown, for a shared tab bar or analytics
  • give a screen an address in the browser on the web (url())

Platforms: every host; the back press only on Android (the back button and gesture), iOS (the edge swipe) and the web (the browser's Back) — not on desktop or in the web player.

At a glance

TypeScript
const detail = UIScreen(
  UIButton(UIText("‹ Back").style({ color: "white" }))
    .style({ alignSelf: "flex-start", p: 8 })
    .onClick(() => Router.pop()),           // the system back pops by itself; this is for every host
  UIText("Detail").style({ fontSize: 24, fontWeight: 700, color: "white" }),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)", gap: 16 })

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

A single-screen app builds one UIScreen and calls open(); an app with several screens hands them to Router. A screen fills its slot — the device, or a pager's box for a page — and lays its children out as a UIColumn.

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

screen.open(opts?: { transition })     // show this screen directly — it replaces what is shown; an
                                       //   active Router is suspended until Router.restore()
screen.close(opts?: { transition })    // take it off screen if it is the one shown (nothing replaces
                                       //   it); a screen that is not shown is left alone
screen.onTouchStart(ev => …)           // a touch on the screen that no button inside it takes
screen.keepAlive(enabled?: boolean)    // a pager keeps this page's views when it drops it — pager.md

Full container surface: .append / .insert / .remove / .setContent, .style, .animateTo / .animateFrom, .onLayout. onTouchStart supports ev.track() like a button — see Pointer events.

Note

a screen always fills its slot — its style has no sizing props (width, height, flexGrow, position are type errors there). Style its padding, background, and layout of children. Its background is the theme's bg (theme.md); with no theme it has none (the host's backdrop shows through) — set bgColor and text colors, or a theme.

Start and stop work when a screen opens and closes: onOpen / onClose

A timer, a poll or a socket that serves one screen starts in the screen's onOpen and stops in its onClose — or it keeps running behind the other screens.

TypeScript
screen.onOpen(cb: () => void): this    // the screen became the one shown
screen.onClose(cb: () => void): this   // it stopped being the one shown — covered, popped, replaced, closed
TypeScript
let seconds = 0
let timer: number | null = null
const label = UIText("0 s").style({ fontSize: 32, color: "white" })

const stopwatch = UIScreen(label)
  .style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
  .onOpen(() => {                                  // shown: tick every second
    timer = setInterval(() => { label.text = `${++seconds} s` }, 1000)
  })
  .onClose(() => {                                 // covered, popped or replaced: stop
    if (timer !== null) { clearInterval(timer); timer = null }
  })

Router.init(stopwatch)

They fire on every activation, not just the first: Router.init and open() fire onOpen, pushing away from a screen fires its onClose, popping back to it fires onOpen again — the stopwatch stands still while another screen covers it and goes on when it comes back. They fire when the navigation is made — at the start of its transition, not when it lands. Each call adds a handler. See Conventions § Lifecycle.

Note

animations running on a screen's elements (animateTo / animateFrom, loops included) are cancelled when the screen is covered or leaves; restart a looping one in onOpen.

Back button and back-swipe on a screen: onBack

To handle the Android back button (and the iOS back swipe) on a screen yourself — ask before discarding an edit, close something inside the screen — give the screen an onBack. Without one, back pops the router by itself; with one, the screen takes the press and nothing pops unless the handler calls Router.pop() (or UIPager.pop()).

TypeScript
screen.onBack(cb: () => void): this   // the back press while this screen is shown; a second call replaces the first
TypeScript
let edited = false
let warnedAt = 0
const editor = UIScreen(
  UIInput().style({ placeholder: "Write a note", color: "white" }).onChange(() => { edited = true }),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
  .onBack(() => {
    if (!edited || Date.now() - warnedAt < 2000) { Router.pop(); return }   // nothing to lose, or a second press
    warnedAt = Date.now()
    toast("Unsaved note: press back again to discard it")
  })

Router.init(UIScreen(
  UIButton(UIText("New note").style({ color: "white" })).onClick(() => Router.push(editor)),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" }))

A back press runs one chain; the first step that takes it wins:

  1. the shown widgets that have an onBack, the last shown first (UIModal, UIPopover and UIBottomSheet have one: they close — with dismissible(false) they keep the press and stay);
  2. the current destination's onBack (a screen, a scene, a native view, a video);
  3. the innermost pager whose current tab is drilled in pops it;
  4. the router pops;
  5. app.onBack, the app-wide fallback when no screen takes the back press — the place for app-wide policy such as press-back-again-to-exit (app.md).

If nothing takes the press, it is ignored — the app does not exit.

Note

Android runs the whole chain on its back button and back gesture. iOS has no back button: the system's edge swipe is offered only when a screen would come back — a router pop, a pushed screen's own onBack, a drilled-in pager tab — and the chain runs when the finger lifts (the swipe is the animation; no transition plays). So on iOS a widget's onBack, the root screen's and app.onBack never fire, and nothing swipes back from a scene, a native view or a video: give those a back button of their own. On the web the browser's Back runs the chain while the router is above its root, and Escape runs the onBack of a shown widget (step 1: a modal or a sheet closes). Desktop, the web player and headless tests have no back press: put a visible back button on a pushed screen.

Keep a header fixed over scrolling content: one UIScrollable body

A screen never scrolls: it is a fixed layout root, with no scroll position, no scroll events and no pull-to-refresh. To keep a header (or a tab bar) in place while the content moves, put the fixed parts first and one scrollable body with flexGrow: 1 under them:

TypeScript
const rows = Array.from({ length: 40 }, (_, i) => UIText(`Row ${i + 1}`).style({ fontSize: 18, color: "white" }))

const profile = UIScreen(
  UIRow(UIText("Profile").style({ fontSize: 20, fontWeight: 700, color: "white" }))
    .style({ height: 56, alignItems: "center", px: 24 }),     // the header: stays put
  UIScrollable(rows)                                          // ONE scrolling body under it
    .style({ flexGrow: 1, px: 24, gap: 16 })
    .onRefresh(async () => { console.log("pulled to refresh") }),   // pull-to-refresh lives here too
).style({ bgColor: "black", pt: "safe-top" })

profile.open()

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)                                        // once, in the entry file: the stack's root, shown at once
Router.push(page, opts?: { transition, popTransition })      // push onto the stack; the page becomes the one shown
Router.replace(page, opts?: { transition, popTransition })   // swap the top — no back entry to the replaced one
Router.pop(to?: number, opts?: { transition })               // default -1 = one screen back
Router.current                         // the top of the router stack (getter); null before Router.init
Router.hide(opts?: { transition })     // take the router's page off screen (the stack stays)
Router.restore(opts?: { transition })  // show the stack top again, after hide() or a direct open()

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

A page is any Presentable: a UIScreen, a Scene / ARScene, a Scene2D, a NativeView, a UIVideo. A destination that prepares first (ARScene: the camera permission, a warm-up) makes push / replace return a Promise — the current page stays until it is ready, and a rejection (permission denied, superseded) leaves the stack as it was.

Router.current is the top of the stack — it stays meaningful while the router is suspended by a direct open() or hide(). It follows the runtime's change report, which arrives after the navigating call returns: right after Router.push(page) it still names the previous top. Presentable.current is set at once: the destination actually visible (a UIScreen, Scene, …), null before the first open and after close() / hide() — UIPopover.show() attaches to it.

Not implemented

Router.init(home, { showDefaultBackButton }) — the option is accepted and ignored; no host draws a back button.

Replace a screen so back does not return to it: Router.replace

After a login, an onboarding or a splash screen, Router.replace swaps the top of the stack for the next screen: the old one leaves the stack, so neither the back button nor Router.pop() returns to it.

TypeScript
const home = UIScreen(UIText("Welcome").style({ fontSize: 24, color: "white" }))
  .style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })

const login = UIScreen(
  UIButton(UIText("Log in").style({ color: "white" }))
    .style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
    .onClick(() => Router.replace(home)),      // home takes the login's place in the stack
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })

Router.init(login)

A replace plays no transition unless one is named or themed (Transitions).

Go back several screens or to the first one: Router.pop(to)

Router.pop() goes back one screen; pop(to) unwinds several in one step — a wizard or a checkout that ends on the screen it started from:

TypeScript
const step = (n: number): UIScreen => UIScreen(
  UIText(`Step ${n} of 3`).style({ fontSize: 24, color: "white" }),
  UIButton(UIText(n < 3 ? "Next" : "Done").style({ color: "white" }))
    .style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
    .onClick(() => n < 3 ? Router.push(step(n + 1)) : Router.pop(0)),   // 0 = the first screen
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)", gap: 16 })

Router.init(step(1))

Negative values of to are relative (-2 = back two screens); 0 and positive values are an absolute index into the stack (0 = the first screen). At the root pop() does nothing. Screens that leave the stack are detached, not destroyed (below).

Transitions

Every call that changes what is on screen takes a transition — Router.push / replace / pop / hide / restore, and open() / close() of a screen, a scene, a native view, a video:

call default
Router.push "push"
Router.replace "none", themeable: theme({ replaceTransition: "fade" }) — see theme.md
Router.pop, the back button the way back the screen remembers (below)
open(), close(), Router.hide(), Router.restore() "none"

The names (TransitionName): "push" | "pop" | "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom" | "zoom" | "zoom-in" | "zoom-out" | "fade" | "none". push / pop keep each phone platform's own feel: Android's timing and curve on Android, iOS's on every other host.

Your own. A transition (TransitionSpec) is two poses: where the incoming screen comes FROM (enter) and where the outgoing one goes TO (exit); a side left out stays put. A pose (TransitionPose) takes the keys of an animateTo bag a transition can move — transform, opacity, dim (black over the screen, 0..1) — with the side's own duration (ms, default 300), delay (ms) and easing (default ease-in-out); an array is a list of keyframes. In a pose a % in translate is a percent of the screen's own size:

TypeScript
const sheet: TransitionSpec = {
  enter: { transform: "translateY(100%)", duration: 400, easing: "outCubic" },   // the new screen rises from below
  exit: { transform: "scale(0.94)", dim: 0.3, duration: 400 },                  // the old one shrinks back and darkens
}

const details = UIScreen(UIText("Details").style({ fontSize: 24, color: "white" }))
  .style({ bgColor: "#1C1C1E", p: 20, pt: "max(safe-top, 24px)" })

const home = UIScreen(
  UIButton(UIText("Show details").style({ color: "white" }))
    .style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
    .onClick(() => Router.push(details, { transition: sheet })),   // back plays the mirror: it sinks again
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })

Router.init(home)

onTop: "exit" keeps the outgoing screen above the incoming one (default: the incoming is on top). A pose's transform is a list of translate / scale / rotate / skew functions, the same functions in every keyframe — anything else is ignored with a warning. A transition lasts as long as its longer side. The option bags of open() and close() are PresentOptions / DismissOptions: { transition?: Transition }, where Transition = TransitionName | TransitionSpec.

The way back. A screen on the stack remembers how to leave: popTransition, or — when none is given — the transition it came with, the other way round. Router.pop() and the back button play it; Router.pop(to, { transition }) overrides it for one call.

TypeScript
Router.push(page)                                          // back: a pop
Router.push(page, { transition: "slide-from-bottom" })     // back: it leaves downwards
Router.push(page, { transition: "zoom", popTransition: "pop" })

A zoom and a fade go back with a fade. A spec of your own goes back with its mirror: the screen that came leaves into its enter pose, the one it covered comes back from its exit pose, with the timing and curves as written. A replace that names no popTransition keeps the way back of the screen it replaces — it belongs to the place in the stack.

Things to know:

  • The first pose is on screen in the call that navigates; the transition's clock starts on the next frame. The screen that leaves stays mounted until its transition lands.
  • One transition at a time: a navigation made while one plays lands it first (the screen that was leaving is dropped at rest), then plays its own from rest.
  • A screen's own transform and opacity are not seen while a transition moves it; they are back when it lands. Put a lasting effect on a child container.
  • A scene, a native view and a video never move: next to one of them only the screen's side of the transition plays, above it.
  • A % in translate is a percent of the element's own box everywhere — .style() and animateTo measure an element the way a transition measures the screen.

How screens live on the stack

Screens below the top stay mounted — their element trees and state survive and keep taking edits (a text set on a covered screen is there when it comes back); they're just not rendered. push fires the outgoing screen's onClose and the incoming one's onOpen; pop fires them the other way round. A screen that leaves navigation — popped, replaced, closed, or a directly opened screen something else replaced — is detached once its transition lands, never destroyed: the element keeps its tree, typed text included, and pushing (or opening) the same object again shows it as it was. A screen you no longer reference is freed. Router.hide() fires the top screen's onClose; restore() fires onOpen again. Calling screen.open() while a router is active suspends the router (its stack stays restorable).

Track which screen the user is on: the change event

To log the screens a user visits for analytics, or keep a shared tab bar in step, listen to the router's change event — it fires with the new top of the stack:

TypeScript
Router.addEventListener("change", (page: Presentable) => …)   // the new top of the stack
Router.removeEventListener("change", cb)
TypeScript
const home = UIScreen(
  UIButton(UIText("Settings").style({ color: "white" })).onClick(() => Router.push(settings)),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
const settings = UIScreen(
  UIButton(UIText("Done").style({ color: "white" })).onClick(() => Router.pop()),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
const names = new Map<Presentable, string>([[home, "home"], [settings, "settings"]])

Router.addEventListener("change", (page) => {
  console.log(`screen view: ${names.get(page)}`)    // push, pop, replace — the back button's pops too
})
Router.init(home)                                   // init itself fires no "change"

It fires on every change of the router's stack — push, pop (the back button's too), replace — and not on init, hide / restore or a direct open().

Addresses on the web — url()

On the web a screen can name its address: while it is the one shown, the browser's address bar shows it (…/#/product/42). The value is a ROUTE — the app never sees the page's own address. Calling url() again on a screen that is shown changes the address in place (a filter, a search); a screen with no url() keeps the address of the screen under it.

The router and the browser's history go together with no code of the app's: Router.push adds an entry, Router.pop goes back one, and the browser's Back runs the same chain as the back button of a phone (an open dialog closes first, then the screen's onBack, then the router pops).

The way IN is the app's, and it is the code a deep link on a phone takes — see app § Deep links:

TypeScript
const ProductScreen = (id: string) => UIScreen(
  UIText(`Product ${id}`).style({ fontSize: 24, color: "white" }),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
  .url(`/product/${id}`)                             // shown: the address bar reads …/#/product/42

const openFromLink = (route: string) => {
  const product = /^\/product\/(\w+)/.exec(route)
  if (product) Router.push(ProductScreen(product[1]))
}
Router.init(UIScreen(UIText("Home").style({ color: "white" })).style({ bgColor: "black", p: 20 }))
if (app.launchUrl) openFromLink(app.launchUrl)      // the page was opened (or reloaded) at a route
app.addEventListener("url", openFromLink)           // the address was changed while the app runs
Note

url() does nothing on a host without an address bar (phones, desktop) and in the web player.

Pitfalls

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

// ✗ interval started in onOpen, never stopped — keeps running after Router.push()
screen.onOpen(() => setInterval(poll, 1000))
// ✓ stop it in onClose, which fires on every navigation away
let timer = 0
screen.onOpen(() => { timer = setInterval(poll, 1000) }).onClose(() => clearInterval(timer))

See also