Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

NavStack

Uno stack di navigazione mostra una pagina alla volta e mantiene le pagine sottostanti: UINavigationController su iOS e Mac Catalyst, con l’header di sistema e lo swipe indietro, e una pila di pagine con una toolbar e il back predittivo su Android. Sui desktop (macOS, Windows, Linux, GPUI) e sul web, ogni pagina mostra in alto l’header proprio dell’app, e i tasti indietro e il pulsante indietro del browser rimuovono la pagina. NavStack è lo stack, NavPage una delle sue pagine.

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

Lo stack gestisce il comportamento di navigazione (transizioni, gesti e pulsanti indietro, stato delle pagine mentre sono coperte); il contenuto delle pagine e l’header dei desktop sono invece dell’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.")))
            },
        ))
}

Su iOS e Android la pagina mostra l’header di sistema con title e un pulsante indietro; sui desktop e sul web mostra header. Un’app destinata solo agli smartphone omette header; una destinata solo ai desktop omette le opzioni dell’header di sistema. La Guida alla navigazione illustra la navigazione di un’app completa: lo stato dello stack, i dialoghi, il pulsante indietro di Android e le tab.

#Come funziona uno stack

  • Le pagine dello stack sono le route: valori a tua scelta, dal fondo alla cima dello stack. Una route di solito identifica la pagina e ne porta i parametri.
  • In TSX le route risiedono nello stato creato da createNavStack. NavStack chiama la sua funzione figlia una volta per ogni push e mostra il risultato come pagina. La mantiene finché la sua route è nello stack, quindi una pagina conserva il proprio stato (signal, posizione di scorrimento, input) mentre altre pagine la coprono.
  • In Rust i figli dello stack sono le sue pagine: un nav_page() inserito in nav_stack() viene messo nello stack, uno tolto viene rimosso. Conserva le route in un signal e renderizza le pagine con each(..), con chiave la route, così le pagine sottostanti mantengono il proprio stato.
  • Ogni push è una pagina a sé, anche quando lo stesso valore di route viene inserito due volte.
  • Quando l’utente lascia una pagina in modo nativo (pulsante indietro, swipe indietro, indietro di Android, tasti indietro dei desktop, indietro del browser), la pagina viene rimossa. In TSX lo stack toglie la sua route dallo stato, quindi nav.routes() corrisponde sempre a ciò che l’utente vede. In Rust la pagina segnala on_dismiss, dove elimini la sua route (e quelle sopra di essa).
  • In TSX, una funzione figlia che restituisce qualcosa di diverso da un <NavPage> ottiene una pagina con le opzioni predefinite. Restituisci un <NavPage> per impostarne l’header e le altre opzioni.

#createNavStack

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

Crea lo stato di uno stack che parte da routes, dal fondo alla cima. Crealo una sola volta, ad esempio a livello di modulo, e passalo a <NavStack state>. Qualsiasi codice può importarlo per navigare.

MembroDescrizione
routes: Accessor<readonly R[]>Le route, dal fondo alla cima. Reattivo.
push(route: R): voidAggiunge una pagina.
pop(count?: number): voidRimuove count pagine (predefinito 1). Almeno una pagina resta sempre.
popToRoot(): voidTorna alla prima pagina.
replace(route: R): voidSostituisce la pagina in cima.
setRoutes(routes: R[]): voidSostituisce le pagine; una route che resta (lo stesso valore) conserva la propria pagina e il proprio stato.
remove(route: R): voidRimuove la pagina più alta di route (confrontata con ===), ovunque si trovi nello stack.

In Rust le stesse operazioni sono modifiche del tuo signal di route: routes.update(|r| r.push(route)) aggiunge, r.pop() rimuove, r.truncate(1) torna alla prima pagina che vi mantieni, e routes.set(..) sostituisce le pagine.

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

#Prop di NavStack

PropRustTipoDescrizione
statele pagine sono i suoi figliNavStackState<R>Lo stato che contiene le route (createNavStack). Obbligatorio.
children.child(..)(route: R, index: Accessor<number>) => JSX.ElementRenderizza una route. Restituisci un <NavPage> per le sue opzioni. Obbligatorio.
style, classmetodi di stileLo stile dello stack; di solito flex: 1.
testID.test_id(..)stringTrova lo stack nei test e in nsh test --trace.

La funzione figlia in TSX riceve il valore della route stessa e un accessor della sua posizione nello stack.

#Prop di NavPage

NavPage è una pagina di uno stack. Contiene il contenuto della pagina, il suo header sui desktop e sul web, e le opzioni dell’header di sistema su iOS e Android. In Rust, nav_page() restituisce la pagina e i suoi metodi builder sono i nomi delle prop in snake case.

PropRustTipoPredefinitoDescrizione
header.header(..)JSX.ElementnessunoDesktop e web: l’header della pagina, posto in cima alla pagina, largo quanto la pagina e alto quanto il suo layout, con il contenuto sotto.
title.title(..)stringnessunoiOS e Android: il titolo dell’header di sistema.
headerVisible.header_visible(..)booleantrueiOS e Android: l’header di sistema viene mostrato.
headerTransparent.header_transparent(..)booleanfalseiOS e Android: il contenuto si estende sotto un header di sistema trasparente.
hidesBottomBarWhenPushed.hides_bottom_bar_when_pushed(..)booleanfalseiOS: la tab bar di sistema esce finché questa pagina è in cima, muovendosi durante la transizione — con la stessa animazione della pagina, e la barra non viene mai ricostruita; ritorna quando la pagina viene rimossa.
headerLargeTitle.header_large_title(..)booleanfalseiOS: un titolo grande che si riduce nella barra mentre il contenuto scorre; il contenuto scorre sotto l’header.
headerBackTitle.header_back_title(..)stringnessunoiOS e Android: l’etichetta del pulsante indietro che riporta a questa pagina (su Android, la sua descrizione per gli screen reader).
headerBackVisible.header_back_visible(..)booleantrueiOS e Android: il pulsante indietro viene mostrato.
headerLeft.header_left(..)JSX.ElementnessunoiOS e Android: contenuto sul bordo iniziale dell’header di sistema, al posto del pulsante indietro.
headerRight.header_right(..)JSX.ElementnessunoiOS e Android: contenuto sul bordo finale dell’header di sistema.
headerTitle.header_title(..)JSX.ElementnessunoiOS e Android: contenuto che sostituisce il titolo.
headerStyle.header_style(..){ backgroundColor?, color?, tintColor? }piattaformaiOS e Android: i colori dell’header di sistema: lo sfondo, quello del titolo (color) e quello dei pulsanti (tintColor).
headerClass.header_style_with(|s| ..)stringnessunoGli stessi colori come classi: bg-*, text-* per il titolo, tint-* per i pulsanti (in Rust .bg(..), .text_color(..), .tint_color(..), colori del tema che seguono l’aspetto). headerStyle ha la precedenza su di essi.
popOnBack.pop_on_back(..)booleantrueI pulsanti indietro e lo swipe indietro rimuovono la pagina. Vedi Uscire da una pagina.
popOnSwipe.pop_on_swipe(..)booleantrueIl gesto indietro interattivo rimuove la pagina: lo swipe indietro di iOS, il back predittivo di Android che segue il dito. I pulsanti indietro la rimuovono in ogni caso.
animation.animation(..)"default" | "slide" | "fade" | "none""default"Come la pagina entra ed esce.
transitionShader.transition_shader(TransitionShader::new(program)){ source: string; uniforms?: Record<string, ShaderUniform>; duration?: number }nessunoLa pagina entra ed esce tramite uno shader WGSL al posto del suo animation: shade(uv) legge la pagina sottostante come sample_from e questa pagina come sample_to mentre nsh.progress va da 0 a 1 in duration millisecondi (350 per impostazione predefinita), e da 1 a 0 quando la pagina esce. Quando il sistema richiede meno movimento, la pagina usa il proprio animation. Vedi Shader.
statusBar.status_bar(..){ foreground?, hidden?, animated? }nessunoiOS e Android: la barra di stato mentre la pagina è in cima. Vedi Barra di stato.
style, classmetodi di stilenessunoLo stile della pagina.
children.child(..)JSX.ElementnessunoIl contenuto.
testID.test_id(..)stringnessunoTrova la pagina nei test e in nsh test --trace.

#Eventi di NavPage

EventoRustDescrizione
onBackPress.on_back_press(..)L’utente ha premuto indietro sulla pagina (il pulsante indietro dell’header, l’indietro di Android, Escape, Alt+Sinistra o il pulsante indietro del mouse sui desktop, l’indietro del browser), indipendentemente dal fatto che popOnBack ne abbia permesso la rimozione.
onDismiss.on_dismiss(..)L’utente ha rimosso la pagina (pulsante indietro, swipe); in TSX la sua route è già sparita, in Rust la elimini qui.

#Slot dell’header

header, headerLeft, headerRight e headerTitle sono slot: elementi ordinari che leggono signal, gestiscono le pressioni e si aggiornano mentre la pagina è mostrata. header è l’intero header dell’app sui desktop e sul web; gli altri tre vanno nell’header di sistema su iOS e 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())
}

Il contenuto della pagina inizia sotto l’header: sotto un header di sistema opaco su iOS e Android, sotto header sui desktop e sul web. Con headerTransparent, headerLargeTitle o headerVisible={false}, su iOS e Android inizia in cima alla pagina; usa env(safe-area-inset-top) o safeAreaInsets() per tenerlo lontano dall’header e dalla barra di stato.

Su iOS, headerVisible={false} mantiene abilitato lo swipe indietro finché popOnSwipe e popOnBack sono veri. Un gesto indietro interattivo accettato termina la modifica all’inizio della transizione, quindi la tastiera si chiude durante il trascinamento. Annullare la transizione ripristina il focus dello stesso editor, se un altro controllo non lo ha preso.

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

#Uscire da una pagina

popOnBack={false} trattiene l’utente su una pagina, ad esempio mentre un modulo ha modifiche non salvate. Finché è false, lo swipe indietro è disattivato e i pulsanti e i tasti indietro chiamano onBackPress senza rimuovere la pagina. Chiedi all’utente, poi rimuovi la pagina da te:

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 viene chiamato a ogni pressione di indietro, anche quando la pagina viene rimossa, quindi indica anche che l’utente è tornato indietro. Per disattivare solo il gesto di swipe e mantenere i pulsanti indietro, usa popOnSwipe={false}. La conferma stessa è un AlertDialog.

#Note sulle piattaforme

  • iOS: UINavigationController con header di sistema, pulsante indietro e swipe indietro; headerLargeTitle è il titolo grande di UIKit. Le modifiche allo stack che arrivano durante una transizione in corso vengono applicate al suo termine.
  • Android: uno stack con una toolbar. Il pulsante indietro di sistema rimuove la pagina in cima, con il back predittivo sulle versioni di Android che lo supportano; la descrizione della freccia su per gli screen reader è il headerBackTitle della pagina sottostante, oppure quella del sistema. Vedi BackHandler in BackHandler per gestire tu stesso il pulsante indietro.
  • Mac Catalyst: come su iOS.
  • Desktop (macOS, Windows, Linux, GPUI) e web: ogni pagina mostra il proprio header in alto e il contenuto sotto. Escape, Alt+Sinistra e il pulsante indietro del mouse tornano indietro sui desktop, il pulsante indietro del browser sul web.

Per il contenuto mostrato sopra lo stack e il suo header, come uno sheet o un flusso a schermo intero, usa un Dialog.