Docs

Platform-specific code

One NSH bundle runs on iOS, Android, macOS, Windows and Linux (and in Mac Catalyst), and in browsers through the web runtime. Most of an app needs no platform checks at all: elements, styles, layout and modules behave the same everywhere, and the components you draw from them look the same everywhere. When something really is specific to one platform, NSH gives it a clear place, so platform code stays visible and contained:

RustTSXFor
Builder methods (.ripple(..), .input_accessory(..))ios={{…}}, android={{…}}, desktop={{…}}, web={{…}}Props only one platform has.
on_back_press, nsh_core::element::desktopnsh/mobile, nsh/desktopAPIs only mobile platforms or only desktops have.
os(), idiom(), is_desktop()platform.os, platform.desktop, platform.selectChoosing values or code per platform.
.when(os() == Os::Ios, ..)ios: / android: / web: classesStyles per platform.

#Desktop and mobile

Every platform is desktop or mobile, by one rule. Desktop is macOS, Windows, Linux, and browsers in the desktop idiom (a mouse and a wide window, the web). Mobile is iOS, Android, and browsers in the phone or tablet idiom; Mac Catalyst runs the iOS backend, so it is mobile too, with ios={{…}} props. desktop={{…}} props, platform.select({ desktop, mobile }), platform.desktop and platform.idiom === "desktop" all follow this rule, as do nsh/base’s components (a menu is a popup on desktops and a sheet on mobile) and, in Rust, is_desktop().

nsh/desktop’s isAvailable answers a narrower question: whether that module’s window and menu APIs work, which they do on macOS, Windows and Linux. Gate layout, density and input on platform.desktop, and calls of Window and Menu on isAvailable.

#Platform-only props

In Rust, a prop one platform has is a builder method like any other (.ripple(..), .elevation(..) for Android, .input_accessory(..) for iOS, .window_drag(..) for the desktops); the other platforms leave it aside. In TSX, props that exist on one platform only are grouped in an object named after it. Each element accepts ios, android, desktop (macOS, Windows, Linux, and browsers in the desktop idiom) and web (browsers); on other platforms the object is ignored, and on its own platform only its keys are applied. A desktop browser applies both desktop and web.

use nsh_base::dialog;
use nsh_core::{
    Ripple,
    element::{AutoFill, Orientation}, prelude::*,
};

view()
    .on_press(open)
    .active(|s| s.opacity(0.6))
    .ripple(Ripple {
        color: Some(rgba(0x0000001f).into()),
        ..Ripple::default()
    })
    .elevation(px(4.0))
    .p_4()
    .rounded(px(12.0))
    .bg(white())
    .child(text("Open"))

input().placeholder("Code").auto_fill(AutoFill::OneTimeCode)

when(shown, || {
    dialog()
        .supported_orientations([Orientation::Portrait])
        .status_bar_translucent(true)
        .child(…)
})

The TSX objects are reactive like any prop; a key removed from the object is cleared.

What exists today:

RustTSXOnDescription
.ripple(Ripple { .. })android={{ ripple }}every elementThe material ripple while pressed: color, borderless, radius, foreground.
.elevation(..)android={{ elevation }}every elementThe material shadow, in points, from the view’s shape.
android={{ accessibilityLiveRegion }}every element"none", "polite" or "assertive": TalkBack announces the view’s changes.
.status_bar_translucent(..)android={{ statusBarTranslucent }}DialogThe dialog’s content reaches under the status bar.
.input_accessory(..)ios={{ inputAccessoryViewID }}InputThe InputAccessoryView shown over the keyboard.
.auto_fill(..)ios={{ textContentType }}InputiOS’s names for autofill types (Rust’s AutoFill covers both platforms).
.supported_orientations(..)ios={{ supportedOrientations }}DialogThe orientations it allows.
.back_forward_gestures(..)ios={{ allowsBackForwardNavigationGestures }}WebViewSwipe back and forward in the page history.
.header_right_system_item(..)ios={{ headerRightSystemItem }}NavPageUIKit’s own bar button at the end of the header, pressing headerRight.
.header_left_system_item(..)ios={{ headerLeftSystemItem }}NavPageUIKit’s own bar button at the start of the header, pressing headerLeft.
.header_right_symbol(..)ios={{ headerRightSymbol }}NavPageUIKit’s own bar button showing an SF Symbol at the end of the header, pressing headerRight.
.header_left_symbol(..)ios={{ headerLeftSymbol }}NavPageUIKit’s own bar button showing an SF Symbol at the start of the header, pressing headerLeft.
.back_forward_gestures(..)desktop={{ allowsBackForwardNavigationGestures }}WebViewmacOS: two-finger trackpad swipes go back and forward in the page history; Windows: touchscreen swipes; Linux: touchpad swipes.
.window_drag(..)desktop={{ windowDrag }}every elementPressing it drags the window, as the system’s title bar would (see nsh/desktop).

In TSX, desktop={{…}} and web={{…}} are accepted everywhere. The types (IOSProps, AndroidProps, DesktopProps and their per-element forms) tell your editor what each element takes.

What an element means on every platform is a top-level prop (in Rust a method of the same name), applied where it means something: NavPage’s title, headerStyle, headerLargeTitle and headerBackTitle set up the system header of iOS and Android, its header slot is the header of the desktops and the web, and Dialog’s presentation picks iOS’s sheets. AlertDialog.show’s closeOnBackdropPress, pagingEnabled, refreshing, hitSlop and the rest work on every platform. A platform’s own system feature goes in its object, and when several platforms have it, in each of theirs under the same name: WebView’s allowsBackForwardNavigationGestures, the system’s swipes through the page history, is in ios={{…}} and desktop={{…}}.

#Platform modules

In Rust, the APIs only one platform has are functions of nsh_core::prelude (on_back_press, input_accessory_view) and of nsh_core::element::desktop (the window: set_title, set_title_bar, title_bar, minimize, toggle_maximize, toggle_full_screen, set_frame, set_level, order_front, close_window; menus: set_application_menu, popup_menu); elsewhere they do nothing. In TSX, nsh/mobile holds the APIs of iOS and Android (some of them one platform’s), nsh/desktop those of the desktops. Both can be imported on every platform, so one bundle runs everywhere: on other platforms their functions return at once and their components take no room. Each exports isAvailable, which is true on its own platforms; platform.os tells them apart.

#nsh/mobile

isAvailable is true on iOS (with Mac Catalyst) and Android. Both platforms have TabBar, the system’s tab bar.

#BackHandler (Android)

RustTSXDescription
on_back_press(listener)BackHandler.on("press", listener)Listens to the back button after dialogs, before stacks; return true when handled. The listener goes with the component (or effect) that added it; in TSX the call also returns a function that stops it earlier.
BackHandler.exitApp()Leaves the app.
use nsh_core::prelude::*;

fn player() -> impl IntoElement {
    let full_screen = signal(false);
    // Back leaves full screen before it leaves the page. The listener goes
    // with the view.
    on_back_press(move || {
        if !full_screen.get_untracked() {
            return false;
        }
        full_screen.set(false);
        true
    });
    // …
}

The back button is handled by the top dialog first, then the back listeners (most recent first), then the top navigation stack, and the app exits when nothing handled it. See Navigation.

#InputAccessoryView (iOS)

RustTSXDescription
input_accessory_view(native_id)InputAccessoryViewA bar over the keyboard of the inputs naming its native id (.input_accessory(..), ios={{ inputAccessoryViewID }}).

See Forms and the keyboard.

#nsh/desktop

Rust apps draw their own title bars with nsh_core::element::desktop (Building with Rust). TSX’s module:

ExportDescription
WindowThe window: setTitle, setTitleBar({ style: "hidden" }) for a title bar of the app’s own, minimize, toggleMaximize, toggleFullScreen, close.
titleBarThe hidden title bar’s height and the room the system’s window controls take (reactive).
MenusetApplicationMenu(items) (the menu bar, with shortcuts) and popup(items, at?) (context menus). An item’s icon is an image as <Image>’s src takes it, so menus show the app’s own icon set: an SVG takes the color of the menu’s text, a bitmap keeps its colors. macOS and Windows show it; GTK’s menus show no icon beside a label.
isAvailabletrue where the window and menu APIs work: macOS, Windows and Linux. Desktop browsers have platform.desktop and keep the browser’s window and menus.

See nsh/desktop.

#The status bar

On both platforms the status bar belongs to the page or dialog on top, as in UIKit, where the topmost view controller decides it: NavPage and Dialog take statusBar (in Rust .status_bar(StatusBarStyle::Light), or a StatusBarSetting for the other fields) with foreground ("auto", "light" for dark backgrounds, "dark" for light ones), hidden and animated. The page or dialog shown most recently wins, field by field, and the one below it applies again when it goes away. nsh.json’s "statusBar" is the app’s default under them.

use nsh_base::nav_page;
use nsh_core::prelude::*;

fn photo_viewer() -> impl IntoElement {
    nav_page()
        .header_visible(false)
        .status_bar(StatusBarStyle::Light)
        .child(view().flex_1().bg(black()))
}

In an app embedded in a native one, the host app’s view controller decides the status bar on iOS. An app that declares "capabilities" in nsh.json needs "statusBar": true to change it.

#platform.os and platform.select

os() in Rust and the platform constant from nsh in TSX tell you where the app runs: Os::Ios, Os::Android, Os::MacOs, Os::Windows, Os::Linux or Os::Web, and platform.os "ios", "android", "macos", "windows", "linux" or "web" (Mac Catalyst is "ios", with platform.catalyst in either of its idioms; the macOS backend is "macos", the Linux backend "linux" and the Windows backend "windows", and their elements apply their desktop props; browsers are "web", and apply desktop props too when platform.desktop is true). See Device and environment for the version, the idiom and the rest.

use nsh_core::{element::Os, prelude::*};

if os() == Os::Android {
    // …
}

#platform.select

In Rust a match os() chooses the value, with is_desktop() for the desktops. In TSX, platform.select(spec) returns the value for this platform. It looks for the OS’s own key, then desktop or mobile by the rule above (platform.desktop), then native (every platform but the web), then default:

use nsh_core::{element::Os, prelude::*};

fn card() -> View {
    let card = view().rounded(px(12.0)).bg(white());
    match os() {
        Os::Ios => card.shadow_md(),
        Os::Android => card.border_1().border_color(rgb(0xe5e7eb)),
        _ => card,
    }
}

let hint = match os() {
    Os::Ios => "Swipe down to close",
    Os::Android => "Press back to close",
    _ => "Close",
};

It returns undefined when nothing matches and there is no default.

#Rendering different components

Choose components the same way, at module level (TSX) when the choice never changes:

fn spinner() -> AnyElement {
    if os() == Os::Ios {
        ios_spinner().into_element()
    } else {
        material_spinner().into_element()
    }
}

or inline with when / <If>:

when(os() == Os::Ios, || input_accessory_view("bar").child(…))
    .otherwise(|| text("Android has no input accessories."))

#Platform variants in Tailwind

Rust styles per platform with .when(os() == Os::Ios, |v| ..); in TSX, the ios:, android: and web: variants apply classes on one platform:

view()
    .rounded_xl()
    .bg(white())
    .p_4()
    .when(os() == Os::Ios, |v| v.shadow_md())
    .when(os() == Os::Android, |v| v.border_1().border_color(rgb(0xe4e4e7)))

#Prefer shared behavior

Before reaching for a platform check, consider whether the difference matters to your users. Elements and styles already map to each platform’s native behavior where it should differ (scroll physics, text rendering, keyboards, accessibility), while staying the same where users expect consistency (your app’s own look). Platform checks are best kept for platform conventions users rely on: Android’s back button and toasts, iOS’s sheets and large titles, autofill names, Android’s ripple.