Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Documentación

Navegación

nsh_base (TSX: nsh/base) ofrece una pila de navegación nativa y diálogos nativos. Una pila de navegación (nav_stack() en Rust, NavStack en TSX) es un UINavigationController en iOS, con su encabezado, el gesto de deslizar para volver y los títulos grandes, y una pila con barra de herramientas y back predictivo en Android; en los escritorios y en la web, cada página muestra el encabezado que dibuja tu app. Tú guardas las rutas en tu estado; la pila renderiza cada ruta como una página y la mantiene montada, con su estado, mientras esté en la pila.

En Rust, importa los builders por nombre junto al prelude (use nsh_base::{dialog, nav_page, nav_stack};; las alertas del sistema son nsh_base::alert_dialog()). Las rutas son una signal y los hijos de la pila son sus páginas, una por ruta, identificadas por clave (each): para hacer push se añade una página y para hacer pop se quita. En TSX, createNavStack contiene las rutas y NavStack las renderiza.

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())))
        },
    ))
}

#El estado de la pila

En Rust, las rutas son tu propia signal (signal(vec![..]), o global::<Routes>() para una a la que accede toda la app), y haces push, pop y replace actualizándola: routes.update(|r| r.push(route)), r.pop(), r.truncate(1), routes.set(..). La clave de cada página (aquí el texto Debug de la ruta) le dice a la pila qué página es cuál, de modo que las páginas cuya ruta permanece conservan su estado; da una clave única a las rutas que puedan repetirse. Gestiona el on_dismiss de cada página: el usuario volvió atrás de forma nativa, y la ruta (junto con las que están encima) sale de tu signal.

En TSX, createNavStack(routes) crea el estado de la pila a partir de un array de rutas, de abajo hacia arriba. Una ruta es cualquier valor que quieras (una cadena, un objeto, una unión discriminada como la anterior), y cada push es una página propia, incluida la misma ruta empujada dos veces. Crea el estado una sola vez, a nivel de módulo o en tu componente raíz, e impórtalo donde las páginas naveguen.

Miembro Descripción
routes() Las rutas, de abajo hacia arriba (reactivo).
push(route) Empuja una página.
pop(count?) Quita count páginas (1 por defecto); siempre queda al menos una.
popToRoot() Vuelve a la primera página.
replace(route) Reemplaza la página superior.
setRoutes(routes) Reemplaza las páginas, p. ej. tras iniciar sesión; una ruta que permanece (el mismo valor) conserva su página y su estado.
remove(route) Quita la página más alta de route, esté donde esté.

Para empezar con varias páginas de profundidad, pasa más rutas: createNavStack<Route>([{ name: "inbox" }, { name: "message", id: 3 }]).

Cuando el usuario sale de una página de forma nativa (el botón atrás del encabezado, deslizar para volver en iOS, el botón o gesto atrás de Android, las teclas y el botón del ratón de atrás en los escritorios, el botón atrás del navegador), la pila de TSX quita esa ruta de su estado por ti, así que routes() siempre coincide con lo que hay en pantalla; una página de Rust lo notifica con on_dismiss, como se vio antes.

Una ruta lleva lo que la página necesita. Las apps de galería empujan rutas que contienen lo que construye la página, lo que mantiene pequeño el tipo de ruta cuando hay muchas páginas:

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) }));

En Rust, nav_stack() es una vista cuyos hijos son sus páginas, de abajo hacia arriba; se le aplica estilo como a cualquier vista (.flex_1()). En TSX:

Prop Tipo Descripción
state NavStackState<R> Las rutas que se muestran.
children (route: R, index: Accessor<number>) => JSX.Element Renderiza una ruta; devuelve un <NavPage> para su encabezado.
style, class El estilo propio de la pila (normalmente flex: 1).

La función de render se ejecuta una vez por ruta, cuando se empuja; route es el valor simple y index() su posición actual. Lo que devuelve se convierte en el contenido de la página. Devuelve un <NavPage> para configurar el encabezado; cualquier otra cosa se envuelve en una página con las opciones por defecto.

Las páginas permanecen montadas mientras su ruta esté en la pila: al volver atrás se muestra la página anterior exactamente como se dejó, con su posición de scroll, sus inputs y sus signals. Los handlers de cleanup de una página se ejecutan cuando su ruta sale de la pila.

Una página (nav_page() en Rust, NavPage en TSX) envuelve el contenido de una página y lleva su encabezado. iOS y Android muestran su encabezado del sistema, configurado con el título y las opciones de encabezado. Los escritorios y la web muestran el slot de encabezado de la página (.header(..), en TSX header), con tus propias vistas, en la parte superior de la página: tan ancho como la página, tan alto como resulte de su layout, con el contenido debajo. Dale a una página ambos, como hace el ejemplo anterior, y se verá natural en todas partes. Todas las opciones son reactivas: cambia una signal y el encabezado la sigue.

En Rust, cada prop de abajo es un método de nav_page() en 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(..); sus hijos son su contenido.

Prop Tipo Por defecto Descripción
header JSX.Element Escritorios y web: el encabezado de la página, dibujado por la app (un título, un botón atrás, botones de barra de herramientas).
title string iOS y Android: el título del encabezado del sistema.
headerVisible boolean true iOS y Android: se muestra el encabezado del sistema.
headerTransparent boolean false iOS y Android: el contenido se extiende bajo el encabezado.
headerStyle { backgroundColor?, color?, tintColor? } iOS y Android: el fondo del encabezado, el color del título (color) y el de los botones (tintColor).
headerClass string Lo mismo que classes: bg-*, text-* para el título, tint-* para los botones; headerStyle tiene prioridad sobre ellos.
headerBackVisible boolean true iOS y Android: se muestra el botón atrás.
headerLeft JSX.Element iOS y Android: vistas al inicio del encabezado (reemplazan el botón atrás).
headerRight JSX.Element iOS y Android: vistas al final del encabezado.
ios={{ headerRightSystemItem }} "add" | "done" | "close" | "edit" | "compose" | "search" | "share" | "trash" | "save" | "cancel" iOS: el propio botón de barra de UIKit al final del encabezado, con el aspecto, símbolo o etiqueta del sistema, en lugar de las vistas de headerRight; una pulsación sobre él es una pulsación sobre la primera vista pulsable de headerRight, así que mantén una ahí. Android muestra headerRight tal cual.
ios={{ headerLeftSystemItem }} igual que headerRightSystemItem iOS: lo mismo, al inicio del encabezado, pulsando headerLeft.
ios={{ headerRightSymbol }} string iOS: el propio botón de barra de UIKit que muestra el SF Symbol con ese nombre ("slider.horizontal.3") en lugar de las vistas de headerRight; una pulsación sobre él es una pulsación sobre la primera vista pulsable de headerRight, y VoiceOver lee la etiqueta de esa vista. headerRightSystemItem tiene prioridad sobre él; un nombre que SF Symbols no tiene muestra headerRight tal cual. Android muestra headerRight tal cual.
ios={{ headerLeftSymbol }} string iOS: lo mismo, al inicio del encabezado, pulsando headerLeft.
headerTitle JSX.Element iOS y Android: vistas que reemplazan el título.
headerLargeTitle boolean false iOS: un título grande que se reduce hasta la barra al desplazar el contenido.
headerBackTitle string La etiqueta del botón atrás que lleva de vuelta a esta página (iOS; la flecha de subir de Android se la indica a los lectores de pantalla).
animation "default" | "slide" | "fade" | "none" "default" Cómo entra y sale la página.
statusBar { foreground?, hidden?, animated? } iOS y Android: la barra de estado mientras la página está arriba (Status bar).
popOnSwipe boolean true El gesto interactivo de volver (deslizar para volver en iOS, back predictivo en Android) hace pop. Los botones atrás hacen pop en cualquier caso.
popOnBack boolean true Toda forma de volver atrás hace pop de la página: los botones atrás, el atrás de Android, las teclas de atrás de los escritorios, el botón atrás del navegador y el deslizamiento.
onBackPress () => void El usuario pulsó atrás en la página, haya permitido popOnBack el pop o no.
onDismiss () => void El usuario hizo pop de la página; su ruta ya se ha quitado.
style, class La vista de contenido de la página.

Los elementos del encabezado son vistas normales, así que pueden ser pulsables y reactivos:

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)),
        )
}

Un encabezado del sistema con color (en Rust, header_style_with admite estilos tipados: bg, text_color y tint_color, el color de los botones del encabezado; los colores del tema siguen la apariencia):

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

Una página que no muestra encabezado sigue teniendo las barras del sistema a su alrededor; aplica padding a su contenido según los insets del safe area:

use nsh_base::nav_page;

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

#Áreas seguras bajo el encabezado

Dentro de una página, las variables env(safe-area-inset-*) y los insets de la página (Scope::insets en Rust, safeAreaInsets() en TSX) son los que quedan bajo el encabezado del sistema, dentro de la página. Con un encabezado transparente, el contenido empieza en la parte superior de la página y el inset superior es la altura del encabezado, así que aplicar padding con env(safe-area-inset-top) mantiene el texto despejado mientras una imagen de fondo llena toda la página. Con un título grande, coloca una vista de scroll directamente en la página para que el título pueda contraerse al desplazar. En los escritorios y en la web, el contenido empieza debajo del slot de encabezado de la página.

#Mantener al usuario en una página: popOnBack

Para preguntar antes de descartar cambios sin guardar, establece pop_on_back (TSX popOnBack) en false mientras haya algo que perder, gestiona la pulsación de atrás (.on_back_press(..), TSX onBackPress) y haz pop cuando el usuario acepte:

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", "Keep editing")
                .destructive("discard", "Discard")
                .show()
                .await;
            if choice.is_ok_and(|r| r.action.as_deref() == Some("discard")) {
                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)),
        )
}

Con pop_on_back(false) (popOnBack={false}), la página permanece con cualquier forma de volver atrás (los botones atrás, el botón y gesto atrás de Android, las teclas de atrás de los escritorios, el botón atrás del navegador, el deslizamiento), y el evento de pulsación de atrás te avisa de que el usuario lo intentó. Hacer pop desde código (quitar la ruta, nav.pop()) hace pop como siempre.

pop_on_swipe(false) (popOnSwipe={false}) es la opción más ligera: desactiva solo el gesto interactivo (deslizar para volver en iOS, la animación de back predictivo de Android), y los botones atrás siguen haciendo pop.

#Diálogos

Un diálogo (dialog() de nsh_base en Rust, Dialog en TSX) presenta contenido por encima de todo lo demás, en su propia capa de ventana nativa: un view controller presentado en iOS, una ventana de diálogo en Android, una capa sobre toda la ventana en los escritorios y en la web. Su aspecto es tuyo. En Rust, el diálogo se presenta mientras está en la vista (dentro de un when) y sus hijos lo llenan: dibuja el fondo y el popup como vistas. En TSX, class y style dan estilo al popup que contiene a sus hijos, backdropClass o backdropStyle añaden un fondo detrás de él, y open lo controla:

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, el botón atrás de Android, una pulsación sobre el fondo (el fondo de TSX, el de una sheet nativa en Rust) y un deslizamiento hacia abajo sobre una sheet lo cierran de forma nativa (close_on_escape, close_on_backdrop_press y close_on_swipe, en TSX closeOnEscape, closeOnBackdropPress y closeOnSwipe, todos true por defecto). En Rust, on_cancel notifica cada intento y on_close el cierre, así que deja el diálogo fuera de la vista allí; un fondo que dibuje tu app lo cierra con su propio handler de pulsación, como arriba. En TSX, onOpenChange({ open: false, reason }) te avisa, así que establece open en false allí. Para confirmar primero, pon una bandera en false y gestiona el evento de cancelación (on_cancel, onCancel), que notifica cada intento, y luego cierra tú mismo el diálogo. En iOS, .presentation(ModalPresentation::PageSheet) o FormSheet (presentation="pageSheet" o "formSheet") presenta una sheet que mantiene visible lo que hay detrás.

Consulta Dialog para ver todas las props. Dentro de un diálogo, las variables env(safe-area-inset-*) y los insets son los propios del diálogo. Para diálogos del sistema (una alerta con acciones, un action sheet) usa AlertDialog de nsh_base (TSX: nsh/base).

#El botón atrás de Android

En Android, el botón (o gesto) atrás se gestiona en este orden:

  1. El diálogo superior: notifica la cancelación (on_cancel, onCancel) y se cierra mientras close_on_escape / closeOnEscape sea true (el valor por defecto).
  2. Tus listeners de atrás (on_back_press en Rust, BackHandler de nsh/mobile en TSX), el añadido más recientemente primero.
  3. La pila superior: su página superior notifica la pulsación de atrás y hace pop mientras pop_on_back / popOnBack sea true.
  4. Nadie lo gestionó: la app se cierra.

Un listener devuelve true cuando gestionó la pulsación, lo que evita que la pila haga pop. Mientras hay un listener registrado, el gesto atrás de Android no muestra una vista previa del pop (la app responde solo cuando termina el gesto), así que añade listeners únicamente mientras puedan tomar la pulsación:

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()
}

Un listener creado en un effect dura hasta que el effect se vuelve a ejecutar o su componente desaparece; en TSX, la función que devuelve BackHandler.on también lo detiene. En TSX, BackHandler.exitApp() sale de la app; nsh/mobile también se puede importar en iOS, donde las llamadas de BackHandler regresan de inmediato, de modo que el mismo código se ejecuta en ambas plataformas. Consulta Platform-specific code.

#Pestañas y otros layouts

Una barra de pestañas es tu propia UI, dibujada a partir de primitivas: una fila de vistas pulsables y una signal que guarda la pestaña seleccionada; renderiza el contenido de la pestaña seleccionada con when / <If> (o un hijo función en TSX). Para conservar el estado de cada pestaña al cambiar, dale a cada una su propia pila y rutas y mantén todas montadas, ocultando las inactivas con display: none.

#Relacionado