NativeView

A live platform view the host registers — a map, a camera preview, a scanner, a web page, a view of your own shell. A view that fills the screen is a destination of its own: Router.push(view), its controls in view.widgets(...); it goes among a screen's children only as a part of that screen (a map preview in a card). For a map, a camera, a QR scanner or a web page use its typed wrapper — MapView, CameraView, QRScanner, WebView — built on this channel.

  • show a map, a camera or a web page full screen with buttons over it
  • put a small live view inside a card on a screen
  • close a full-screen view with a button or the system back
  • check that the device has the view before offering the feature
  • call a method of the view and get its result
  • react to what the view reports (a tap, a finished drawing)
  • test a screen with a map or a camera headless, or show it on a design board

Platforms: iOS and Android; the web and the web player for the first-party plugins' views (each has a web half); not on the desktop (only a host built with a view plugin, full screen). A view of the app's own exists only in a shell built from the project (lecodes app) — not in the le.codes viewer app or on the web — check NativeView.isSupported(name).

At a glance

A view of your shell, full screen, with its controls as widgets:

TypeScript
// "panorama": a view this app's shell registers — full screen, so a destination of its own
const panorama = NativeView("panorama", { url: "https://example.com/hall.jpg" })

const title = UIText("").style({ fontSize: 16 })
const info = UIWidget(title)                                      // a panel, hidden until a tap
  .style({ display: "none", bottom: "safe-bottom", left: 16, right: 16, p: 16, borderRadius: 12, bgColor: "white" })
const close = UIWidget(UIButton(UIText("Close")).onClick(() => Router.pop()))
  .style({ top: "safe-top", left: 16, p: 12, borderRadius: 8, bgColor: "white" })
panorama.widgets(close, info)                                     // its controls, over it while it is shown

panorama.on("spotTap", spot => { title.text = spot.title; info.show() })    // an event the view emits
panorama.onOpen(() => {                                           // shown: calls reach it from here
  panorama.call("lookAt", 90, 0).catch(err => console.log("lookAt failed:", err.message))
})

Router.init(UIScreen(
  NativeView.isSupported("panorama")
    ? UIButton(UIText("Open the panorama")).onClick(() => Router.push(panorama))
    : UIText("No panorama viewer on this device"),
).style({ p: 24, pt: "safe-top" }))

A full-screen view with buttons over it: Router.push and widgets

To show a map, a camera preview, a scanner or a web page full screen with your own buttons over it, push the view itself: it is a destination of its own, like a screen. Router.push(view) shows it over the current screen and Router.pop() comes back; everything over it — a close button, a search bar, a card that slides up — is one of its widgets, laid out in the view's box and shown while it is presented, and a touch outside a widget reaches the view. Don't build a screen around a full-screen view (the view among its children, absolute panels over it): embed a view only when it is a part of a screen.

TypeScript
const viewer = NativeView("panorama", { url: "https://example.com/hall.jpg" })   // your shell's view
viewer.widgets(                                     // over the view, in its box, while it is shown
  UIWidget(UIButton(UIText("Close")).onClick(() => Router.pop()))
    .style({ top: "safe-top", left: 16, p: 12, borderRadius: 8, bgColor: "white" }),
)

const home = UIScreen(
  UIButton(UIText("Open the panorama")).onClick(() => Router.push(viewer)),
).style({ p: 24, pt: "safe-top" })
Router.init(home)
TypeScript
Router.push(view, { transition? })    // over the current destination; Router.pop() comes back
view.open({ transition? })            // in place of what is shown, the router suspended; throws without the view
view.close({ transition? })           // dismiss it, if it is the one on screen
view.widgets(...widgets)              // what floats over it while it is presented
view.onOpen(cb) / view.onClose(cb)    // it became / stopped being the visible destination
view.onBack(cb)                       // the system back while it is shown, in place of the pop
Note

the way back. Android's back button and gesture and the browser's Back on the web pop a pushed view (onBack takes the press instead). iOS starts no back swipe while a native view is the destination, and the desktop, the web player and headless tests have no back input — a view that fills the screen needs a close button among its widgets.

Note

a native view never moves in a transition: the screen next to it plays its side, above it (transitions).

A view as part of a screen: embed it

To put a small live view inside a screen — a map preview in a card, a camera box in a form, a chart between texts — place it among the screen's children: it is sized and placed by its styles like any element (width, height, flex, margins; animateTo, classes and onLayout too).

TypeScript
// "chart": a view this app's shell registers, here a part of the screen
const chart = NativeView("chart", { theme: "light", series: [3, 1, 4, 1, 5] })   // its data at creation
  .style({ height: 180 })                                          // sized by its styles, like a box
  .on("pointTap", point => toast(`value ${point.value}`))

UIScreen(
  UIColumn(
    UIText("Steps this week").style({ fontSize: 18, fontWeight: "600" }),
    NativeView.isSupported("chart") ? chart : UIText("No chart on this device"),
  ).style({ p: 16, gap: 12, borderRadius: 16, bgColor: "white" }),
  UIText("Keep walking"),
).style({ p: 16, pt: "safe-top", gap: 16, bgColor: "#f2f2f7" }).open()

One instance lives in one place at a time: pushing a view that is embedded moves the same live view full screen, its state intact (promotion).

Note

an embedded view the host does not have draws a labelled placeholder on iOS and Android, an error box on the desktop (which shows views full screen only) and nothing on the web — guard it with NativeView.isSupported(name) and show something else.

Create a view: NativeView(name, params)

TypeScript
NativeView(name: string, params?: object): NativeView   // params: options for the host's factory, JSON data
view.viewName     // the registered name
view.params       // the creation params

params reach the host's factory once, when the platform view is made: put there what the view needs to start (a URL, the first data); everything after goes through call. Style it with the chained .style().

Note

the platform view is made the first time the view is shown — presented, or laid out among a screen's children (on the web, when it is created). Call it once it is shown — from onOpen, an event of the view or a tap: before that, a call rejects on iOS, Android and the web player (the host has no view yet). Closing or popping keeps it: shown again, the same live view comes back as it was left, and one that holds the camera or a stream still holds it — stop that from onClose with a method of its own.

Check that the device has the view: NativeView.isSupported

A view exists only where a host registered it, so ask before offering a feature that needs one (the guard of the At a glance):

TypeScript
NativeView.isSupported(name: string): boolean   // this host registered a view under `name`
NativeView.version(name: string): number        // its contract version: 0 = none, 1 = one written by hand
  • iOS and Android: the views the app's shell registers (engine.registerView(name, …)) and those of the plugins the app lists in app.json; the le.codes viewer app has the first-party ones only — map (not in the iOS App Clip), camera, QR scanner, web page; no WebRTC video.
  • the web and the web player: the web halves of the first-party plugins the app lists (a plugin of the project's own has none there).
  • the desktop: none in the released host; a Windows / Linux host built with a view plugin shows it full screen only.
  • headless tests: the views a run names with --native-view (below).

Without the view, open() throws; a pushed or an embedded one draws a labelled placeholder on iOS and Android and nothing on the web.

Talk to the view: call and events

To ask the view for something or change it after it is made, call one of its methods; to react to what it reports, listen to its events — the names and the data are what the view's native half defines.

TypeScript
const result = await view.call(method, ...args)   // plain data both ways — JSON's model plus Uint8Array
view.on(event, data => console.log(data))         // an event the native side emits (a payload, or none)
view.off(event, callback)
TypeScript
// "signature": a drawing pad this app's shell registers
const pad = NativeView("signature", { strokeColor: "#1a73e8" })
  .style({ height: 240, bgColor: "white" })
  .on("strokeEnd", () => console.log("stroke ended"))               // an event with no payload

const save = UIButton(UIText("Save")).onClick(async () => {
  try {
    const png: Uint8Array = await pad.call("export", "png")          // plain data both ways
    console.log("signature:", png.length, "bytes")
  } catch (err) {
    console.log("export failed:", err.message)   // no such view or method, or the view's own error
  }
})

UIScreen(pad, save).style({ p: 16, pt: "safe-top", gap: 12, bgColor: "#f2f2f7" }).open()

call rejects when the host has no such view or method, when the view has not been shown yet (iOS, Android, the web player), when the native side reports an error, and when an argument cannot cross (a cycle, a typed array that is not a Uint8Array) — then the host never sees the call.

Plugins: the typed wrappers

A plugin's view comes with a typed wrapper, generated rather than written against strings: a plugin is a contract.d.ts — the view's params, methods and events — and lecodes plugin gen writes the app-side class over call / on together with the native halves; a hand-written layer may sit on top (QRScanner.onScan hands over the decoded string). The first-party ones ship with the SDK: MapView, CameraView, QRScanner, WebView, RTCVideoView — each one a NativeView, so this page holds for them. Writing one of your own: cookbook § Native plugins.

For capabilities with no visual surface (GPS, notifications, …) use the service channel instead — the same protocol without a view: Service; Geolocation and Push are its first-party plugins.

Test a screen with a map or a camera headless: placeholder views

To test a screen with a map or a camera headless (lecodes render, lecodes test) or show it on a design board, name its views with --native-view: the headless host has no maps and no cameras, and each named view stands as a placeholder — a muted box with its label — where the real one would. Presented, it fills the screen under its widgets; embedded, it fills its own box.

bash
lecodes render --native-view panorama=Panorama --script tests/panorama.flow.json
lecodes test --native-view map=Map,camera         # a bare name is its own label
JSON
{ "steps": [
  { "tap": "Open the panorama", "expect": { "text": "Close" } },
  { "tap": "Close", "expect": { "text": "Open the panorama" } }
] }

A named view is supported there (NativeView.isSupported is true, open() works) and is in the JSON as { "type": "native", "view": "map", "rect": … }; every call rejects at once (headless draws native views only: map.flyTo) and no event comes from it — a wrapper that waits for its view's ready (MapView) keeps waiting, silently. A view the run does not name is unsupported, as on a host without the plugin: the At a glance shows its "No panorama viewer" text there. In code: createSession(js, { nativeViews: { map: "Map" } }).

See also