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

Elementos

NavStack

Una pila de navegación muestra una página a la vez y conserva las páginas que quedan debajo: UINavigationController en iOS y Mac Catalyst, con el encabezado del sistema y el gesto de deslizar para volver, y una pila de páginas con barra de herramientas y retroceso predictivo en Android. En los escritorios (macOS, Windows, Linux, GPUI) y en la web, cada página muestra el encabezado propio de la app en la parte superior, y las teclas de retroceso y el botón de retroceso del navegador quitan la página. NavStack es la pila y NavPage una de sus páginas.

ui-navigation.tsx
ui-navigation.tsxLive
// Navigation: a stack of pages with header options, pushed and popped; in a
// wide window the inbox and the open message side by side (master–detail).
import { type JSX, mount, signal, Text, View } from "nsh";
import { createNavStack, NavPage, NavStack } from "nsh/base";
import { button, buttonText, focusRing, hoverFill, size, tint, ui, wide } from "./palette";

function Button(props: { title: string; variant?: "primary" | "secondary"; onPress: () => void }) {
  const kind = () => (props.variant !== "secondary" ? "primary" : "secondary");
  return (
    <View role="button" onPress={props.onPress} {...button(kind())}>
      <Text style={buttonText(kind())}>{props.title}</Text>
    </View>
  );
}

type Route = { name: "home" } | { name: "detail"; id: number };

const nav = createNavStack<Route>([{ name: "home" }]);

// The page's own header where NSH draws none (the web, the desktops); iOS and
// Android show the system's, from `title` and `headerRight`.
function Header(props: { title: string; right?: JSX.Element }) {
  return (
    <View style={{ backgroundColor: ui().page, borderBottomWidth: 1, borderBottomColor: ui().line }} class="h-12 flex-row items-center px-2">
      <View class="w-20 flex-row">
        <If when={nav.routes().length > 1}>
          <View role="button" accessibilityLabel="Back" onPress={() => nav.pop()} hoverStyle={hoverFill()} focusStyle={focusRing()} class="rounded-md px-2 py-1 active:opacity-60">
            <Text style={{ fontSize: 17, color: ui().accent }}>‹ Back</Text>
          </View>
        </If>
      </View>
      <Text style={{ numberOfLines: 1, fontSize: 17, fontWeight: "600", color: ui().text }} class="flex-1 text-center">
        {props.title}
      </Text>
      <View class="w-20 flex-row justify-end px-2">{props.right}</View>
    </View>
  );
}

// A page's content: a column as wide as a phone, centered in a wider window.
function Body(props: { children: JSX.Element }) {
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 16, paddingTop: 24 }}>
      <View style={{ width: "100%", maxWidth: 480, alignSelf: "center", gap: 12 }}>{props.children}</View>
    </View>
  );
}

function Home() {
  const opened = signal(0);
  return (
    <NavPage title="Inbox" header={<Header title="Inbox" />}>
      <Body>
        <Text style={{ color: ui().muted }}>{`Opened ${opened()} messages`}</Text>
        <Button
          title="Open message 1"
          onPress={() => {
            opened.set(opened() + 1);
            nav.push({ name: "detail", id: 1 });
          }}
        />
        <Button title="Open message 2" variant="secondary" onPress={() => nav.push({ name: "detail", id: 2 })} />
      </Body>
    </NavPage>
  );
}

function Detail(props: { id: number }) {
  const starred = signal(false);
  const star = () => (
    <Text onPress={() => starred.set(!starred())} style={{ fontSize: 17, color: ui().accent }} hoverStyle={hoverFill()} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
      {starred() ? "★" : "☆"}
    </Text>
  );
  return (
    <NavPage title={`Message ${props.id}`} headerRight={star()} header={<Header title={`Message ${props.id}`} right={star()} />}>
      <Body>
        <Text style={{ color: ui().text }}>{`The body of message ${props.id}.`}</Text>
        <Button title="Next message" onPress={() => nav.push({ name: "detail", id: props.id + 1 })} />
        <Button title="Back to the inbox" variant="secondary" onPress={() => nav.popToRoot()} />
      </Body>
    </NavPage>
  );
}

const messages = [1, 2, 3, 4, 5, 6];

/** A wide window: the inbox on the start side, the selected message beside it. */
function Inbox() {
  const selected = signal(1);
  const starred = signal<number[]>([]);
  const star = (id: number) => (
    <Text
      onPress={() => starred.set(starred().includes(id) ? starred().filter((s) => s !== id) : [...starred(), id])}
      style={{ fontSize: 17, color: ui().accent }}
      hoverStyle={hoverFill()}
      focusStyle={focusRing()}
      activeStyle={{ opacity: 0.7 }}
    >
      {starred().includes(id) ? "★" : "☆"}
    </Text>
  );
  return (
    <View style={{ flex: 1, flexDirection: "row", backgroundColor: ui().page }}>
      <View style={{ width: 320, borderRightWidth: 1, borderRightColor: ui().line }}>
        <Header title="Inbox" />
        <View style={{ padding: 8, gap: 2 }}>
          <Each of={messages}>
            {(id) => (
              <View
                role="button"
                onPress={() => selected.set(id())}
                hoverStyle={selected() === id() ? undefined : hoverFill()}
                style={{
                  height: size.row + 12,
                  paddingHorizontal: 12,
                  borderRadius: 8,
                  justifyContent: "center",
                  gap: 2,
                  backgroundColor: selected() === id() ? tint(ui().accent, 0.14) : "transparent",
                }}
                focusStyle={focusRing()}
                activeStyle={{ opacity: 0.7 }}
              >
                <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
                  <Text style={{ flex: 1, fontSize: size.label, fontWeight: "600", color: ui().text }}>{`Message ${id()}`}</Text>
                  <Text style={{ fontSize: 12, color: ui().accent }}>{starred().includes(id()) ? "★" : ""}</Text>
                </View>
                <Text style={{ numberOfLines: 1, fontSize: 12, color: ui().muted }}>{`The body of message ${id()}.`}</Text>
              </View>
            )}
          </Each>
        </View>
      </View>
      <View style={{ flex: 1, minWidth: 0 }}>
        <Header title={`Message ${selected()}`} right={star(selected())} />
        <View style={{ padding: 24, gap: 12 }}>
          <Text style={{ color: ui().text }}>{`The body of message ${selected()}.`}</Text>
          <Text style={{ fontSize: 13, color: ui().muted }}>Click a message on the left; on a phone it opens as a page of a NavStack.</Text>
          <Button title="Next message" onPress={() => selected.set(selected() === messages.length ? 1 : selected() + 1)} />
        </View>
      </View>
    </View>
  );
}

function App() {
  return (
    <>
      {() =>
        wide() ? (
          <Inbox />
        ) : (
          <NavStack state={nav} style={{ flex: 1 }}>
            {(route) => (route.name === "home" ? <Home /> : <Detail id={route.id} />)}
          </NavStack>
        )
      }
    </>
  );
}

mount(App);

La pila se encarga del comportamiento de navegación (transiciones, gestos y botones de retroceso, el estado de las páginas mientras están cubiertas); el contenido de las páginas y el encabezado de escritorio son de la app.

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

/// A button with the app's look.
fn action(title: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
    button()
        .on_press(on_press)
        .rounded_lg()
        .bg(rgb(0x2563eb))
        .px_4()
        .py_3()
        .child(text(title).text_color(white()).font_weight(FontWeight::SEMIBOLD))
}

/// The header on the desktops and the web, with a back button over the first page.
fn bar(title: impl Into<SharedString>, routes: Signal<Vec<u32>>) -> impl IntoElement {
    view()
        .flex_row()
        .items_center()
        .gap_3()
        .px_4()
        .py_3()
        .border_b_1()
        .border_color(rgb(0xe5e5e5))
        .child(when(
            move || !routes.with(Vec::is_empty),
            move || {
                action("Back", move || {
                    routes.update(|r| {
                        r.pop();
                    })
                })
            },
        ))
        .child(text(title.into()).text_lg().font_weight(FontWeight::SEMIBOLD))
}

fn app() -> impl IntoElement {
    // The messages pushed over the inbox, bottom to top.
    let routes = signal(Vec::<u32>::new());
    nav_stack()
        .flex_1()
        .child(
            nav_page()
                .title("Inbox")
                .header(bar("Inbox", routes))
                .child(view().flex_1().p_4().child(action("Open message 1", move || {
                    routes.update(|r| r.push(1))
                }))),
        )
        .child(each(
            move || routes.get(),
            |id: &u32| *id,
            move |id, depth| {
                let title: SharedString = format!("Message {}", id.get_untracked()).into();
                nav_page()
                    .title(title.clone())
                    .header(bar(title, routes))
                    // The user went back natively: this page and those above it are gone.
                    .on_dismiss(move || routes.update(|r| r.truncate(depth.get_untracked())))
                    .child(view().flex_1().p_4().child(text("The message.")))
            },
        ))
}

En iOS y Android la página muestra el encabezado del sistema con title y un botón de retroceso; en los escritorios y en la web muestra header. Una app que solo apunta a teléfonos omite header; una que solo apunta a escritorios omite las opciones del encabezado del sistema. La Guía de navegación recorre la navegación de una app completa: el estado de la pila, los diálogos, el botón de retroceso de Android y las pestañas.

#Cómo funciona una pila

  • Las páginas de la pila son rutas: valores que tú eliges, desde la base de la pila hasta la cima. Una ruta suele nombrar la página y llevar sus parámetros.
  • En TSX las rutas viven en el estado que crea createNavStack. NavStack llama a su función hija una vez por cada push y muestra el resultado como una página. Mantiene esa página mientras su ruta esté en la pila, de modo que una página conserva su estado (señales, posición de scroll, campos de entrada) mientras otras páginas la cubren.
  • En Rust los hijos de la pila son sus páginas: un nav_page() puesto en nav_stack() se apila, y uno que se deja fuera se quita. Guarda las rutas en una señal y renderiza las páginas con each(..), con clave por ruta, para que las páginas de abajo conserven su estado.
  • Cada push es una página propia, incluido el mismo valor de ruta apilado dos veces.
  • Cuando el usuario sale de una página de forma nativa (el botón de retroceso, deslizar para volver, el retroceso de Android, las teclas de retroceso de los escritorios, el retroceso del navegador), la página se va. En TSX la pila quita su ruta del estado, de modo que nav.routes() siempre coincide con lo que ve el usuario. En Rust la página informa on_dismiss, donde eliminas su ruta (y las rutas que están encima).
  • En TSX, una función hija que devuelve algo distinto de un <NavPage> obtiene una página con las opciones por defecto. Devuelve un <NavPage> para definir su encabezado y otras opciones.

#createNavStack

function createNavStack<R>(routes: R[]): NavStackState<R>

Crea el estado de una pila que empieza con routes, de la base a la cima. Créalo una sola vez, por ejemplo a nivel de módulo, y pásalo a <NavStack state>. Cualquier código puede importarlo para navegar.

Miembro Descripción
routes: Accessor<readonly R[]> Las rutas, de la base a la cima. Reactivo.
push(route: R): void Apila una página.
pop(count?: number): void Quita count páginas (1 por defecto). Siempre queda al menos una página.
popToRoot(): void Vuelve a la primera página.
replace(route: R): void Reemplaza la página superior.
setRoutes(routes: R[]): void Reemplaza las páginas; una ruta que permanece (el mismo valor) conserva su página y su estado.
remove(route: R): void Quita la página más alta de route (comparada con ===), esté donde esté en la pila.

En Rust las mismas operaciones son cambios de tu señal de rutas: routes.update(|r| r.push(route)) apila, r.pop() quita, r.truncate(1) vuelve a la primera página que conservas en ella, y routes.set(..) reemplaza las páginas.

routes.update(|r| r.push(7));
routes.update(|r| r.truncate(r.len().saturating_sub(2)));
routes.set(vec![1, 2]);
let depth = move || routes.with(Vec::len);

#Props de NavStack

Prop Rust Tipo Descripción
state las páginas son sus hijos NavStackState<R> El estado que contiene las rutas (createNavStack). Obligatoria.
children .child(..) (route: R, index: Accessor<number>) => JSX.Element Renderiza una ruta. Devuelve un <NavPage> para sus opciones. Obligatoria.
style, class métodos de estilo El estilo de la pila; normalmente flex: 1.
testID .test_id(..) string Localiza la pila en las pruebas y en nsh test --trace.

La función hija de TSX recibe el propio valor de la ruta y un accesor de su posición en la pila.

#Props de NavPage

NavPage es una página de una pila. Contiene el contenido de la página, su header en los escritorios y en la web, y las opciones del encabezado del sistema en iOS y Android. En Rust, nav_page() devuelve la página y sus métodos de construcción son los nombres de las props en snake-case.

Prop Rust Tipo Por defecto Descripción
header .header(..) JSX.Element ninguno Escritorios y web: el encabezado de la página, colocado en la parte superior, tan ancho como la página y tan alto como resulte de su layout, con el contenido debajo.
title .title(..) string ninguno iOS y Android: el título del encabezado del sistema.
headerVisible .header_visible(..) boolean true iOS y Android: se muestra el encabezado del sistema.
headerTransparent .header_transparent(..) boolean false iOS y Android: el contenido se extiende bajo un encabezado del sistema transparente.
hidesBottomBarWhenPushed .hides_bottom_bar_when_pushed(..) boolean false iOS: la barra de pestañas del sistema se retira mientras esta página está en la cima, moviéndose con la transición (la misma animación que la página), y la barra nunca se reconstruye; vuelve cuando la página se quita.
headerLargeTitle .header_large_title(..) boolean false iOS: un título grande que se reduce hacia la barra a medida que el contenido se desplaza; el contenido se desplaza bajo el encabezado.
headerBackTitle .header_back_title(..) string ninguno iOS y Android: la etiqueta del botón de retroceso que lleva de vuelta a esta página (en Android, su descripción para lectores de pantalla).
headerBackVisible .header_back_visible(..) boolean true iOS y Android: se muestra el botón de retroceso.
headerLeft .header_left(..) JSX.Element ninguno iOS y Android: contenido en el borde inicial del encabezado del sistema, en lugar del botón de retroceso.
headerRight .header_right(..) JSX.Element ninguno iOS y Android: contenido en el borde final del encabezado del sistema.
headerTitle .header_title(..) JSX.Element ninguno iOS y Android: contenido que reemplaza al título.
headerStyle .header_style(..) { backgroundColor?, color?, tintColor? } plataforma iOS y Android: los colores del encabezado del sistema: su fondo, los del título (color) y los de los botones (tintColor).
headerClass .header_style_with(|s| ..) string ninguno Los mismos colores como clases: bg-*, text-* para el título, tint-* para los botones (en Rust .bg(..), .text_color(..), .tint_color(..), colores del tema que siguen la apariencia). headerStyle tiene prioridad sobre ellas.
popOnBack .pop_on_back(..) boolean true Los botones de retroceso y el gesto de deslizar para volver quitan la página. Consulta Salir de una página.
popOnSwipe .pop_on_swipe(..) boolean true El gesto de retroceso interactivo quita la página: deslizar para volver en iOS, retroceso predictivo de Android que sigue el dedo. Los botones de retroceso la quitan en cualquier caso.
animation .animation(..) "default" | "slide" | "fade" | "none" "default" Cómo entra y sale la página.
transitionShader .transition_shader(TransitionShader::new(program)) { source: string; uniforms?: Record<string, ShaderUniform>; duration?: number } ninguno La página entra y sale mediante un shader WGSL en lugar de su animation: shade(uv) lee la página de abajo como sample_from y esta página como sample_to mientras nsh.progress va de 0 a 1 durante duration milisegundos (350 por defecto), y de 1 a 0 cuando la página sale. Cuando el sistema pide menos movimiento, la página usa su propio animation. Consulta Shader.
statusBar .status_bar(..) { foreground?, hidden?, animated? } ninguno iOS y Android: la barra de estado mientras la página está en la cima. Consulta Barra de estado.
style, class métodos de estilo ninguno El estilo de la página.
children .child(..) JSX.Element ninguno El contenido.
testID .test_id(..) string ninguno Localiza la página en las pruebas y en nsh test --trace.

#Eventos de NavPage

Evento Rust Descripción
onBackPress .on_back_press(..) El usuario pulsó retroceso en la página (el botón de retroceso del encabezado, el retroceso de Android, Escape, Alt+Izquierda o el botón de retroceso de un ratón en los escritorios, el retroceso del navegador), tanto si popOnBack permitió quitarla como si no.
onDismiss .on_dismiss(..) El usuario quitó la página (botón de retroceso, deslizamiento); su ruta desaparece en TSX, y Rust la elimina aquí.

#Slots del encabezado

header, headerLeft, headerRight y headerTitle son slots: elementos normales que leen señales, gestionan pulsaciones y se actualizan mientras la página se muestra. header es el encabezado completo de la app en los escritorios y en la web; los otros tres van en el encabezado del sistema en iOS y Android.

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

fn profile(edit: impl Fn() + 'static) -> Screen {
    nav_page()
        .title("Profile")
        .header(profile_bar())
        .header_style_with(|s| s.bg(rgb(0x1a7f37)).text_color(white()).tint_color(white()))
        .header_right(text("Edit").text_color(white()).on_press(move || edit()))
        .child(scroll().flex_1())
}

El contenido de la página empieza debajo del encabezado: debajo de un encabezado del sistema opaco en iOS y Android, debajo de header en los escritorios y en la web. Con headerTransparent, headerLargeTitle o headerVisible={false}, empieza en la parte superior de la página en iOS y Android; usa env(safe-area-inset-top) o safeAreaInsets() para mantenerlo despejado del encabezado y de la barra de estado.

En iOS, headerVisible={false} mantiene activado el gesto de deslizar para volver mientras popOnSwipe y popOnBack sean verdaderos. Un gesto de retroceso interactivo aceptado finaliza la edición cuando empieza la transición, por lo que el teclado se cierra durante el arrastre. Si se cancela la transición, se restaura el foco del mismo editor cuando otro control no lo ha tomado.

nav_page()
    .title("Large title")
    .header_large_title(true)
    .child(scroll().flex_1())

#Salir de una página

popOnBack={false} mantiene al usuario en una página, por ejemplo mientras un formulario tiene cambios sin guardar. Mientras es falso, deslizar para volver está desactivado y los botones y teclas de retroceso llaman a onBackPress sin quitar la página. Pregunta al usuario y luego quítala tú mismo:

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

fn edit_note(routes: Signal<Vec<u32>>) -> Screen {
    let note = 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 || note.with(String::is_empty))
        .on_back_press(move || {
            if !note.with(String::is_empty) {
                confirm();
            }
        })
        .child(
            input()
                .value(note)
                .on_change(move |v: &str| note.set(v.to_owned()))
                .placeholder("A note"),
        )
}

onBackPress se llama en cada pulsación de retroceso, también cuando la página se quita, así que además te indica que el usuario volvió atrás. Para desactivar solo el gesto de deslizamiento y mantener los botones de retroceso, usa popOnSwipe={false}. La confirmación en sí es un AlertDialog.

#Notas por plataforma

  • iOS: UINavigationController con el encabezado del sistema, botón de retroceso y deslizar para volver; headerLargeTitle es el título grande de UIKit. Los cambios en la pila que llegan durante una transición en curso se aplican cuando esta termina.
  • Android: una pila con barra de herramientas. El botón de retroceso del sistema quita la página superior, con retroceso predictivo en las versiones de Android que lo incluyen; la descripción de la flecha hacia arriba para lectores de pantalla es el headerBackTitle de la página inferior, o la del sistema. Consulta BackHandler en BackHandler para gestionar tú mismo el botón de retroceso.
  • Mac Catalyst: igual que en iOS.
  • Escritorios (macOS, Windows, Linux, GPUI) y web: cada página muestra su header en la parte superior y su contenido debajo. Escape, Alt+Izquierda y el botón de retroceso de un ratón vuelven atrás en los escritorios, y el botón de retroceso del navegador en la web.

Para el contenido que se muestra sobre la pila y su encabezado, como una hoja o un flujo de pantalla completa, usa un Dialog.