Guides

Native host integration

What an iOS or Android host app has to inject — safe-area insets, theme, and global events — for Vy UI to behave like a native citizen on device.

A LynxView knows nothing about the device around it. There is no env(safe-area-inset-*), no prefers-color-scheme, no window — the bundle only knows what the host application tells it. Container apps like Lynx Go and Sparkling inject that context for you; the moment you build your own host, injecting it becomes your job.

Lynx gives a host two channels:

  • Global props — a dictionary the host pushes onto the view, readable from the bundle as lynx.__globalProps. Vy UI treats these as a boot-time snapshot: push them before the template loads.
  • Global events — named events sent through sendGlobalEvent, received in the bundle via the GlobalEventEmitter module. These are live, so they carry anything that changes after boot.

What Vy UI reads

SignalChannelContractConsumed by
Safe-area insetsglobal propssafeAreaTop / safeAreaBottom (logical px), or Sparkling's topHeight / bottomHeight + osuseSafeArea, VyApp
Device theme at bootglobal propstheme: "light" | "dark"useColorMode in 'system' mode
Theme changesglobal eventthemechanged with "light" / "dark" (or { theme })useColorMode
Keyboard show/hideglobal eventkeyboardstatuschanged — Lynx emits this itselfVyKeyboardAware, VyInput
Anything of yoursglobal eventyour event nameuseGlobalEvent

Two details worth internalizing before the snippets:

  • Push global props before loadTemplate. useSafeArea and useColorMode snapshot __globalProps when the tree mounts. Pushing after load means the first render sees nothing, and there is no re-render on prop updates in Vue Lynx today. Changes that must land live — theme flips, mainly — go through global events instead.
  • Android insets are normalized to zero on purpose. Android containers conventionally inset the LynxView itself (fitsSystemWindows, window insets applied to the view), so useSafeArea folds os: "android" to { top: 0, bottom: 0 } rather than double-padding. Inset the view natively on Android and let the props stay zero. If your Android host goes edge-to-edge instead, override from app code with provideSafeAreaInsets.

iOS

Push props right after creating the view, before the template loads. Read the insets from the key window, not the view controller's own view — view.safeAreaInsets is zero until the first layout pass, which is after you want to push:

ViewController.swift
private func pushGlobalProps() {
    let window = UIApplication.shared.connectedScenes
        .compactMap { ($0 as? UIWindowScene)?.keyWindow }
        .first
    let insets = window?.safeAreaInsets ?? .zero
    lynxView?.updateGlobalPropsWithDictionary([
        // Lynx Explorer's key names — the convention for a bare host.
        "safeAreaTop": insets.top,      // 59 on a Dynamic Island phone
        "safeAreaBottom": insets.bottom, // 34 with a home indicator
        "theme": traitCollection.userInterfaceStyle == .dark ? "dark" : "light",
    ])
}

Call it before loadTemplate:

view.addSubview(lynxView)
pushGlobalProps()
lynxView.loadTemplate(fromURL: bundleURL)

For live appearance changes, forward the trait change as a global event. The dictionary push is optional here — it keeps __globalProps honest for the next page load — but the event is what flips the running page:

override func traitCollectionDidChange(_ previous: UITraitCollection?) {
    super.traitCollectionDidChange(previous)
    guard traitCollection.userInterfaceStyle != previous?.userInterfaceStyle else { return }
    let theme = traitCollection.userInterfaceStyle == .dark ? "dark" : "light"
    lynxView?.sendGlobalEvent("themechanged", withParams: [theme])
}

With that in place, useColorMode() in 'system' mode boots on the device appearance and follows it live — VyApp re-skins the tree through its existing remount contract, nothing extra to wire in the bundle.

Android

Same contract, Kotlin spelling. Global props ride on TemplateData:

MainActivity.kt
private fun pushGlobalProps(lynxView: LynxView) {
    val dark = resources.configuration.uiMode and
        Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
    lynxView.updateGlobalProps(TemplateData.fromMap(mapOf(
        // Insets stay zero: inset the LynxView natively instead (see above).
        "os" to "android",
        "theme" to if (dark) "dark" else "light",
    )))
}

And theme changes forward through sendGlobalEvent:

override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    val dark = newConfig.uiMode and
        Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
    lynxView.sendGlobalEvent("themechanged", JavaOnlyArray().apply {
        pushString(if (dark) "dark" else "light")
    })
}

Exact signatures move between Lynx SDK versions — check the headers your Podfile or Gradle pin resolves to if the compiler disagrees.

Your own signals

themechanged is nothing special — it is one name Vy UI happens to listen for. Your host can send anything through the same channel (connectivity, deep links, push payloads), and the bundle subscribes with useGlobalEvent:

<script setup lang="ts">
import { useGlobalEvent } from '@vyui/core'

useGlobalEvent('connectivitychanged', (...args) => {
  const online = args[0] === 'online'
  // ...
})
</script>

The listener attaches on mount and detaches on unmount; on web and in vitest there is no lynx global and the call is a no-op, so components using it stay portable.

Persisting state

Lynx leaves storage to the host. There is no localStorage inside a LynxView, and the SDK's type definitions declare no storage module, so anything that has to survive an app restart round-trips through the two channels above.

Reading at boot is just another global prop. The host reads its own store — UserDefaults on iOS, SharedPreferences on Android — and adds the value to the dictionary it already pushes:

"savedTheme": UserDefaults.standard.string(forKey: "theme") ?? "system",

Writing goes the other way, through a native module the host registers and the bundle calls on the background thread. Container apps each register their own, so the module name and its methods are yours to choose and yours to document:

NativeModules.bridge.call('setPref', { theme: 'dark' }, () => {})

Vy UI ships no storage composable, deliberately. The module name, its signatures, and the store behind it are all host-defined, so a wrapper in the library would be a rename of the call you already wrote. The one piece of Vy UI state worth persisting is the color mode, and its mode ref is writable — restore it at setup, save it on change:

<script setup lang="ts">
import { watch } from 'vue'
import { useColorMode } from '@vyui/kit'

const { mode } = useColorMode()

const saved = lynx.__globalProps?.savedTheme
if (saved === 'light' || saved === 'dark') mode.value = saved

watch(mode, m => NativeModules.bridge.call('setPref', { theme: m }, () => {}))
</script>

savedTheme is a different signal from theme: theme is the device appearance the host observes, savedTheme is the choice the user made in a previous session. Leaving the saved value at 'system' folds one into the other.

Viewport size

Not host-injected, but part of the same "no window" story: the bundle reads its size from SystemInfo in physical pixels. getViewportSize() converts that to logical px, and VyApp's viewport-change emit tells you when it changes (rotation, split screen).

Checklist

  • updateGlobalProps pushed before loadTemplate
  • iOS: safeAreaTop / safeAreaBottom from the key window
  • Android: os: "android", insets handled on the native view
  • theme prop at boot, themechanged event on change
  • Custom host events named and documented next to the native code that sends them
  • Persisted preferences read into global props at boot, written back through a host module