Docs

Navigation

nsh_base (TSX: nsh/base) provides a native navigation stack and native dialogs. A navigation stack (nav_stack() in Rust, NavStack in TSX) is a UINavigationController on iOS, with its header, swipe back and large titles, and a stack with a toolbar and predictive back on Android; on the desktops and the web, each page shows the header your app draws. You keep the routes in your state; the stack renders each route as a page and keeps it mounted, with its state, while it is in the stack.

In Rust, import the builders by name next to the prelude (use nsh_base::{dialog, nav_page, nav_stack};; system alerts are nsh_base::alert_dialog()). The routes are a signal and the stack’s children are its pages, one per route, kept by key (each): a page goes in to push it and out to pop it. In TSX, createNavStack holds the routes and NavStack renders them.

use nsh_base::{nav_page, nav_stack};
use nsh_core::prelude::*;

#[derive(Debug, Clone, PartialEq)]
enum Route {
    Inbox,
    Message(u32),
}

/// The page's header on the desktops and the web; iOS and Android show the system's, from `title`.
fn header(routes: Signal<Vec<Route>>, title: &'static str) -> impl IntoElement {
    view()
        .h(px(48.0))
        .flex_row()
        .items_center()
        .gap_3()
        .border_b_1()
        .border_color(rgb(0xe5e7eb))
        .px_4()
        .child(when(
            move || routes.with(|r| r.len() > 1),
            move || {
                text("Back")
                    .role(AccessibilityRole::Button)
                    .on_press(move || routes.update(|r| { r.pop(); }))
                    .text_color(rgb(0x2563eb))
                    .active(|s| s.opacity(0.6))
            },
        ))
        .child(text(title).text_size(px(17.0)).font_weight(FontWeight::SEMIBOLD))
}

fn inbox(routes: Signal<Vec<Route>>) -> Screen {
    nav_page()
        .title("Inbox")
        .header(header(routes, "Inbox"))
        .child(scroll().flex_1().children((1..=5).map(move |id| {
            view()
                .on_press(move || routes.update(|r| r.push(Route::Message(id))))
                .p_4()
                .active(|s| s.bg(rgb(0xf3f4f6)))
                .child(text(format!("Message {id}")))
        })))
}

fn message(routes: Signal<Vec<Route>>, id: u32) -> Screen {
    nav_page()
        .title(format!("Message {id}"))
        .header(header(routes, "Message"))
        .child(view().flex_1().p(px(16.0)).child(text(format!("The body of message {id}."))))
}

#[component]
pub fn App() -> impl IntoElement {
    let routes = signal(vec![Route::Inbox]);
    nav_stack().flex_1().child(each(
        move || routes.get(),
        |route: &Route| format!("{route:?}"),
        move |route, index| {
            let page = match route.get_untracked() {
                Route::Inbox => inbox(routes),
                Route::Message(id) => message(routes, id),
            };
            // The user went back natively: drop the route (and those above it).
            page.on_dismiss(move || routes.update(|r| r.truncate(index.get_untracked())))
        },
    ))
}

#The stack’s state

In Rust, the routes are your own signal (signal(vec![..]), or global::<Routes>() for one the whole app reaches), and you push, pop and replace by updating it: routes.update(|r| r.push(route)), r.pop(), r.truncate(1), routes.set(..). Each page’s key (here the route’s Debug text) tells the stack which page is which, so pages whose route stays keep their state; give routes that may repeat a unique key. Handle each page’s on_dismiss: the user went back natively, and the route (with those above it) leaves your signal.

In TSX, createNavStack(routes) creates the stack’s state from an array of routes, bottom to top. A route is any value you like (a string, an object, a discriminated union as above), and each push is a page of its own, the same route pushed twice included. Create the state once, at module level or in your root component, and import it wherever pages navigate.

MemberDescription
routes()The routes, bottom to top (reactive).
push(route)Pushes a page.
pop(count?)Pops count pages (default 1); at least one stays.
popToRoot()Pops back to the first page.
replace(route)Replaces the top page.
setRoutes(routes)Replaces the pages, e.g. after signing in; a route that stays (the same value) keeps its page and its state.
remove(route)Removes the topmost page of route, wherever it is.

To start several pages deep, pass more routes: createNavStack<Route>([{ name: "inbox" }, { name: "message", id: 3 }]).

When the user leaves a page natively (the back button in the header, a swipe back on iOS, Android’s back button or gesture, the desktops’ back keys and mouse button, the browser’s back button), the TSX stack removes that route from its state for you, so routes() always matches what is on screen; a Rust page reports it with on_dismiss, as above.

A route carries what the page needs. The gallery apps push routes that hold what makes the page, which keeps the route type small when there are many pages:

use std::rc::Rc;

use nsh_core::prelude::*;

/// A pushed page: its key (unique in the stack) and what makes it.
#[derive(Clone)]
pub struct Route {
    pub key: SharedString,
    pub page: Rc<dyn Fn() -> Screen>,
}

impl PartialEq for Route {
    fn eq(&self, other: &Self) -> bool {
        self.key == other.key
    }
}

/// The pushed pages, bottom to top: one signal for the whole app.
#[derive(Default, Clone)]
pub struct Routes(pub Vec<Route>);

pub fn routes() -> Signal<Routes> {
    global::<Routes>()
}

// Elsewhere: routes().update(|r| r.0.push(Route { key: "settings".into(), page: Rc::new(settings) }));

In Rust, nav_stack() is a view whose children are its pages, bottom to top; style it as any view (.flex_1()). In TSX:

PropTypeDescription
stateNavStackState<R>The routes to show.
children(route: R, index: Accessor<number>) => JSX.ElementRenders a route; return a <NavPage> for its header.
style, classThe stack’s own style (usually flex: 1).

The render function runs once per route, when it is pushed; route is the plain value, index() its current position. What it returns becomes the page’s content. Return a <NavPage> to set the header; anything else is wrapped in a page with default options.

Pages stay mounted while their route is in the stack: going back shows the previous page exactly as it was left, with its scroll position, inputs and signals. A page’s cleanup handlers run when its route leaves the stack.

A page (nav_page() in Rust, NavPage in TSX) wraps a page’s content and carries its header. iOS and Android show their system header, set up by the title and the header options. The desktops and the web show the page’s header slot (.header(..), TSX header), your own views, at the top of the page: as wide as the page, as tall as it lays out, with the content below it. Give a page both, as the example above does, and it looks at home everywhere. All options are reactive: change a signal and the header follows.

In Rust, each prop below is a method of nav_page() in snake case: .title(..), .header(..), .header_visible(..), .header_transparent(..), .header_style_with(..), .header_style(..), .header_back_visible(..), .header_left(..), .header_right(..), .header_title(..), .header_large_title(..), .header_back_title(..), .animation(ScreenAnimation::Fade), .status_bar(..), .pop_on_swipe(..), .pop_on_back(..), .on_back_press(..), .on_dismiss(..); its children are its content.

PropTypeDefaultDescription
headerJSX.ElementThe desktops and the web: the page’s header, drawn by the app (a title, a back button, toolbar buttons).
titlestringiOS and Android: the system header’s title.
headerVisiblebooleantrueiOS and Android: the system header shows.
headerTransparentbooleanfalseiOS and Android: the content extends under the header.
headerStyle{ backgroundColor?, color?, tintColor? }iOS and Android: the header’s background, the title’s color (color) and the buttons’ (tintColor).
headerClassstringThe same as classes: bg-*, text-* for the title, tint-* for the buttons; headerStyle wins over them.
headerBackVisiblebooleantrueiOS and Android: the back button shows.
headerLeftJSX.ElementiOS and Android: views at the start of the header (they replace the back button).
headerRightJSX.ElementiOS and Android: views at the end of the header.
ios={{ headerRightSystemItem }}"add" | "done" | "close" | "edit" | "compose" | "search" | "share" | "trash" | "save" | "cancel"iOS: UIKit’s own bar button at the end of the header, with the system’s look, symbol or label, in place of headerRight’s views; a press on it is a press on the first pressable view of headerRight, so keep one there. Android shows headerRight as it is.
headerTitleJSX.ElementiOS and Android: views replacing the title.
headerLargeTitlebooleanfalseiOS: a large title that shrinks into the bar as the content scrolls.
headerBackTitlestringThe label of the back button that leads back to this page (iOS; Android’s up arrow says it to screen readers).
animation"default" | "slide" | "fade" | "none""default"How the page enters and leaves.
statusBar{ foreground?, hidden?, animated? }iOS and Android: the status bar while the page is on top (Status bar).
popOnSwipebooleantrueThe interactive back gesture (iOS swipe back, Android predictive back) pops. The back buttons pop either way.
popOnBackbooleantrueEvery way back pops the page: the back buttons, Android’s back, the desktops’ back keys, the browser’s back button and the swipe.
onBackPress() => voidThe user pressed back on the page, whether or not popOnBack let it pop.
onDismiss() => voidThe user popped the page; its route is already removed.
style, classThe page’s content view.

Header items are ordinary views, so they can be pressable and reactive:

fn compose(routes: Signal<Vec<Route>>) -> Screen {
    let draft = signal(String::new());
    let cancel = move || {
        text("Cancel")
            .on_press(move || routes.update(|r| { r.pop(); }))
            .text_size(px(17.0))
            .text_color(rgb(0x0969da))
    };
    let send_button = move || {
        text("Send")
            .on_press(move || send(draft.get()))
            .text_size(px(17.0))
            .font_weight(FontWeight::SEMIBOLD)
            .text_color(move || if draft.with(String::is_empty) { rgb(0x8c959f) } else { rgb(0x0969da) })
    };
    nav_page()
        .title("New message")
        .header_left(cancel())
        .header_right(send_button())
        .header(
            view()
                .h(px(48.0))
                .flex_row()
                .items_center()
                .justify_between()
                .px_4()
                .child(cancel())
                .child(text("New message").text_size(px(17.0)).font_weight(FontWeight::SEMIBOLD))
                .child(send_button()),
        )
        .child(
            input()
                .multiline(true)
                .value(move || draft.get())
                .on_change(move |t: &str| draft.set(t.to_owned()))
                .flex_1()
                .p(px(16.0))
                .text_size(px(17.0)),
        )
}

A colored system header (in Rust, header_style_with takes typed styles: bg, text_color and tint_color, the color of the header’s buttons; theme colors follow the appearance):

nav_page()
    .title("Profile")
    .header_style_with(|s| s.bg(rgb(0x1a7f37)).text_color(rgb(0xffffff)).tint_color(rgb(0xffffff)))

A page that shows no header still has the system bars around it; pad its content by the safe area insets:

use nsh_base::nav_page;

nav_page()
    .header_visible(false)
    .child(view().flex_1().p_safe_area() /* … */)

#Safe areas under the header

Inside a page, the env(safe-area-inset-*) variables and the page’s insets (Scope::insets in Rust, safeAreaInsets() in TSX) are those below the system header, within the page. With a transparent header, the content starts at the top of the page and the top inset is the header’s height, so padding by env(safe-area-inset-top) keeps text clear of it while a background image fills the whole page. With a large title, put a scroll view directly in the page so the title can collapse as it scrolls. On the desktops and the web, the content starts below the page’s header slot.

#Keeping the user on a page: popOnBack

To ask before discarding unsaved changes, set pop_on_back (TSX popOnBack) to false while there is something to lose, handle the back press (.on_back_press(..), TSX onBackPress), and pop once the user agrees:

use nsh_base::{alert_dialog, nav_page};
use nsh_core::prelude::*;

fn edit_note(routes: Signal<Vec<Route>>) -> Screen {
    let draft = signal(String::new());
    let confirm = move || {
        spawn(async move {
            let choice = alert_dialog()
                .title("Discard your changes?")
                .cancel("Keep editing")
                .destructive("Discard")
                .show()
                .await;
            if choice == Ok(Some("Discard".into())) {
                routes.update(|r| { r.pop(); });
            }
        });
    };
    nav_page()
        .title("Edit")
        .pop_on_back(move || draft.with(String::is_empty))
        .on_back_press(move || {
            if !draft.with(String::is_empty) {
                confirm();
            }
        })
        .child(
            input()
                .value(move || draft.get())
                .on_change(move |t: &str| draft.set(t.to_owned()))
                .placeholder("A note")
                .m(px(16.0))
                .text_size(px(17.0)),
        )
}

With pop_on_back(false) (popOnBack={false}), the page stays on every way back (the back buttons, Android’s back button and gesture, the desktops’ back keys, the browser’s back button, the swipe), and the back press event tells you the user tried. Popping from code (removing the route, nav.pop()) pops as always.

pop_on_swipe(false) (popOnSwipe={false}) is the lighter option: it turns off only the interactive gesture (iOS swipe back, Android’s predictive back animation), and the back buttons still pop.

#Dialogs

A dialog (dialog() of nsh_base in Rust, Dialog in TSX) presents content over everything else, in its own native window layer: a presented view controller on iOS, a dialog window on Android, a layer over the whole window on the desktops and the web. Its look is yours. In Rust, the dialog is presented while it is in the view (inside a when) and its children fill it: draw the backdrop and the popup as views. In TSX, class and style style the popup that holds its children, backdropClass or backdropStyle add a backdrop behind it, and open controls it:

use nsh_base::dialog;
use nsh_core::prelude::*;

pub fn files() -> impl IntoElement {
    let open = signal(false);
    view()
        .flex_1()
        .child(view().on_press(move || open.set(true)).p(px(16.0)).child(text("Delete the file")))
        .child(when(
            move || open.get(),
            move || {
                dialog()
                    .animation(ModalAnimation::Fade)
                    // Escape, Android's back button, a swipe down on a sheet.
                    .on_cancel(move || open.set(false))
                    .child(
                        // The backdrop and the popup are the app's views.
                        view()
                            .flex_1()
                            .bg(rgba(0x00000066))
                            .items_center()
                            .justify_center()
                            .on_press(move || open.set(false))
                            .child(
                                view()
                                    .w(px(320.0))
                                    .gap_3()
                                    .rounded_2xl()
                                    .bg(white())
                                    .p_5()
                                    .on_press(|| {})
                                    .child(text("Delete the file?").text_size(px(20.0)).font_weight(FontWeight::BOLD))
                                    .child(
                                        view()
                                            .on_press(move || open.set(false))
                                            .child(text("Delete").text_color(rgb(0xcf222e))),
                                    ),
                            ),
                    )
            },
        ))
}

Escape, Android’s back button, a press on the backdrop (TSX’s backdrop, a native sheet’s in Rust) and a swipe down on a sheet close it natively (close_on_escape, close_on_backdrop_press and close_on_swipe, TSX closeOnEscape, closeOnBackdropPress and closeOnSwipe, each true by default). In Rust, on_cancel reports every attempt and on_close the close, so leave the dialog out of the view there; a backdrop your app draws closes it with its own press handler, as above. In TSX, onOpenChange(false, reason) tells you, so set open to false there. To confirm first, set a flag to false and handle the cancel event (on_cancel, onCancel), which reports every attempt, then close the dialog yourself. On iOS, .presentation(ModalPresentation::PageSheet) or FormSheet (presentation="pageSheet" or "formSheet") presents a sheet that keeps what is behind visible.

See Dialog for every prop. Inside a dialog, the env(safe-area-inset-*) variables and the insets are the dialog’s own. For system dialogs (an alert with actions, an action sheet) use AlertDialog from nsh_base (TSX: nsh/base).

#The Android back button

On Android, the back button (or gesture) is handled in this order:

  1. The top dialog: it reports the cancel (on_cancel, onCancel), and closes while close_on_escape / closeOnEscape is true (the default).
  2. Your back listeners (on_back_press in Rust, BackHandler from nsh/mobile in TSX), most recently added first.
  3. The top stack: its top page reports the back press, and pops while pop_on_back / popOnBack is true.
  4. Nothing handled it: the app exits.

A listener returns true when it handled the press, which keeps the stack from popping. While a listener is registered, Android’s back gesture does not preview the pop (the app answers only once the gesture ends), so add listeners only while they may take the press:

use nsh_core::prelude::*;

pub fn editor() -> impl IntoElement {
    let selecting = signal(false);
    // Listening only while selecting: otherwise the stack pops, with the gesture's preview.
    effect(move || {
        if selecting.get() {
            on_back_press(move || {
                selecting.set(false);
                true
            });
        }
    });
    // …
    view()
}

A listener made in an effect lasts until the effect runs again or its component goes; in TSX, the function BackHandler.onBackPress returns also stops it. In TSX, BackHandler.exitApp() leaves the app; nsh/mobile can be imported on iOS too, where BackHandler’s calls return at once, so the same code runs on both platforms. See Platform-specific code.

#Tabs and other layouts

A tab bar is your own UI, drawn from primitives: a row of pressable views and a signal holding the selected tab; render the selected tab’s content with when / <If> (or a function child in TSX). To keep each tab’s state while switching, give each tab its own stack and routes and keep all of them mounted, hiding the inactive ones with display: none.