Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Elemente

NavStack

Ein Navigations-Stack zeigt jeweils eine Seite und behält die darunterliegenden Seiten bei: UINavigationController auf iOS und Mac Catalyst, mit System-Header und Swipe-Back, und auf Android ein Stack von Seiten mit Toolbar und Predictive Back. Auf den Desktop-Plattformen (macOS, Windows, Linux, GPUI) und im Web zeigt jede Seite oben den app-eigenen Header, und die Zurück-Tasten sowie der Zurück-Button des Browsers entfernen die Seite vom Stack. NavStack ist der Stack, NavPage eine seiner Seiten.

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

Der Stack verantwortet das Navigationsverhalten (Übergänge, Zurück-Gesten und -Buttons, den Zustand der Seiten, solange sie verdeckt sind); der Inhalt der Seiten und der Desktop-Header gehören der 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.")))
            },
        ))
}

Auf iOS und Android zeigt die Seite den System-Header mit title und einem Zurück-Button; auf den Desktop-Plattformen und im Web zeigt sie header. Eine App, die nur Smartphones adressiert, lässt header weg; eine App, die nur Desktops adressiert, lässt die Optionen des System-Headers weg. Der Navigationsleitfaden führt durch die Navigation einer ganzen App: den Zustand des Stacks, Dialoge, den Android-Zurück-Button und Tabs.

#Funktionsweise eines Stacks

  • Die Seiten des Stacks sind Routen: Werte deiner Wahl, vom unteren Ende des Stacks bis zum oberen. Eine Route benennt in der Regel die Seite und trägt deren Parameter.
  • In TSX liegen die Routen in dem Zustand, den createNavStack erzeugt. NavStack ruft seine Child-Funktion einmal pro Push auf und zeigt das Ergebnis als Seite. Der Stack behält diese Seite, solange ihre Route im Stack liegt, sodass eine Seite ihren Zustand (Signals, Scroll-Position, Eingaben) behält, während andere Seiten sie verdecken.
  • In Rust sind die Children des Stacks seine Seiten: Ein nav_page(), das in nav_stack() eingefügt wird, wird gepusht, und eines, das fehlt, wird entfernt. Halte die Routen in einem Signal und rendere die Seiten mit each(..), nach Route geschlüsselt, damit die darunterliegenden Seiten ihren Zustand behalten.
  • Jeder Push ist eine eigene Seite, auch wenn derselbe Routenwert zweimal gepusht wird.
  • Verlässt der Nutzer eine Seite nativ (Zurück-Button, Swipe-Back, Android-Zurück, die Zurück-Tasten der Desktops, der Zurück-Button des Browsers), verschwindet die Seite. In TSX entfernt der Stack ihre Route aus dem Zustand, sodass nav.routes() immer dem entspricht, was der Nutzer sieht. In Rust meldet die Seite on_dismiss, und dort entfernst du ihre Route (und die Routen darüber).
  • Gibt in TSX eine Child-Funktion etwas anderes als ein <NavPage> zurück, entsteht eine Seite mit Standardoptionen. Gib ein <NavPage> zurück, um den Header und weitere Optionen zu setzen.

#createNavStack

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

Erzeugt den Zustand eines Stacks, der mit routes beginnt, von unten nach oben. Erzeuge ihn einmal, zum Beispiel auf Modulebene, und übergib ihn an <NavStack state>. Jeder Code kann ihn importieren, um zu navigieren.

Member Beschreibung
routes: Accessor<readonly R[]> Die Routen, von unten nach oben. Reaktiv.
push(route: R): void Pusht eine Seite.
pop(count?: number): void Entfernt count Seiten (Standard 1). Mindestens eine Seite bleibt immer erhalten.
popToRoot(): void Entfernt Seiten, bis nur die erste übrig ist.
replace(route: R): void Ersetzt die oberste Seite.
setRoutes(routes: R[]): void Ersetzt die Seiten; eine Route, die bleibt (derselbe Wert), behält ihre Seite und ihren Zustand.
remove(route: R): void Entfernt die oberste Seite von route (verglichen mit ===), egal wo im Stack sie liegt.

In Rust sind dieselben Operationen Änderungen an deinem Routen-Signal: routes.update(|r| r.push(route)) pusht, r.pop() entfernt die oberste Seite, r.truncate(1) entfernt Seiten bis zur ersten Seite, die du darin behältst, und routes.set(..) ersetzt die Seiten.

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 Rust Typ Beschreibung
state die Seiten sind seine Children NavStackState<R> Der Zustand, der die Routen enthält (createNavStack). Erforderlich.
children .child(..) (route: R, index: Accessor<number>) => JSX.Element Rendert eine Route. Gib ein <NavPage> für ihre Optionen zurück. Erforderlich.
style, class Style-Methoden Der Style des Stacks; üblicherweise flex: 1.
testID .test_id(..) string Findet den Stack in Tests und nsh test --trace.

Die TSX-Child-Funktion erhält den Routenwert selbst und einen Accessor für seine Position im Stack.

NavPage ist eine Seite eines Stacks. Es enthält den Inhalt der Seite, ihren header auf den Desktop-Plattformen und im Web sowie die Optionen des System-Headers auf iOS und Android. In Rust gibt nav_page() die Seite zurück, und ihre Builder-Methoden heißen wie die Props, in Snake-Case.

Prop Rust Typ Standard Beschreibung
header .header(..) JSX.Element keiner Desktops und Web: der Header der Seite, oben auf der Seite platziert, so breit wie die Seite und so hoch, wie er sich layoutet, mit dem Inhalt darunter.
title .title(..) string keiner iOS und Android: der Titel des System-Headers.
headerVisible .header_visible(..) boolean true iOS und Android: der System-Header wird angezeigt.
headerTransparent .header_transparent(..) boolean false iOS und Android: der Inhalt reicht unter einen transparenten System-Header.
hidesBottomBarWhenPushed .hides_bottom_bar_when_pushed(..) boolean false iOS: Die System-Tab-Bar verschwindet, solange diese Seite oben liegt, und bewegt sich dabei im Übergang mit – dieselbe Animation wie die Seite, und die Bar wird nie neu aufgebaut; sie kommt zurück, wenn die Seite entfernt wird.
headerLargeTitle .header_large_title(..) boolean false iOS: ein großer Titel, der beim Scrollen des Inhalts in die Bar schrumpft; der Inhalt scrollt unter den Header.
headerBackTitle .header_back_title(..) string keiner iOS und Android: die Beschriftung des Zurück-Buttons, der zu dieser Seite zurückführt (auf Android seine Beschreibung für Screenreader).
headerBackVisible .header_back_visible(..) boolean true iOS und Android: der Zurück-Button wird angezeigt.
headerLeft .header_left(..) JSX.Element keiner iOS und Android: Inhalt am vorderen Rand des System-Headers, anstelle des Zurück-Buttons.
headerRight .header_right(..) JSX.Element keiner iOS und Android: Inhalt am hinteren Rand des System-Headers.
headerTitle .header_title(..) JSX.Element keiner iOS und Android: Inhalt, der den Titel ersetzt.
headerStyle .header_style(..) { backgroundColor?, color?, tintColor? } Plattform iOS und Android: die Farben des System-Headers: sein Hintergrund, die des Titels (color) und die der Buttons (tintColor).
headerClass .header_style_with(|s| ..) string keiner Dieselben Farben als Klassen: bg-*, text-* für den Titel, tint-* für die Buttons (in Rust .bg(..), .text_color(..), .tint_color(..), Theme-Farben, die dem Erscheinungsbild folgen). headerStyle hat Vorrang.
popOnBack .pop_on_back(..) boolean true Die Zurück-Buttons und Swipe-Back entfernen die Seite. Siehe Eine Seite verlassen.
popOnSwipe .pop_on_swipe(..) boolean true Die interaktive Zurück-Geste entfernt die Seite: Swipe-Back auf iOS, Predictive Back auf Android, das dem Finger folgt. Die Zurück-Buttons entfernen die Seite in jedem Fall.
animation .animation(..) "default" | "slide" | "fade" | "none" "default" Wie die Seite eintritt und verschwindet.
transitionShader .transition_shader(TransitionShader::new(program)) { source: string; uniforms?: Record<string, ShaderUniform>; duration?: number } keiner Die Seite tritt über einen WGSL-Shader ein und verschwindet über ihn, anstelle ihres animation: shade(uv) liest die darunterliegende Seite als sample_from und diese Seite als sample_to, während nsh.progress über duration Millisekunden (standardmäßig 350) von 0 auf 1 läuft und beim Verlassen der Seite von 1 zurück auf 0. Wenn das System reduzierte Bewegung verlangt, verwendet die Seite ihr eigenes animation. Siehe Shader.
statusBar .status_bar(..) { foreground?, hidden?, animated? } keiner iOS und Android: die Statusleiste, solange die Seite oben liegt. Siehe Statusleiste.
style, class Style-Methoden keiner Der Style der Seite.
children .child(..) JSX.Element keiner Der Inhalt.
testID .test_id(..) string keiner Findet die Seite in Tests und nsh test --trace.
Event Rust Beschreibung
onBackPress .on_back_press(..) Der Nutzer hat auf der Seite Zurück gedrückt (Zurück-Button des Headers, Android-Zurück, Escape, Alt+Links oder die Zurück-Taste einer Maus auf den Desktops, Zurück im Browser), unabhängig davon, ob popOnBack das Entfernen erlaubt hat.
onDismiss .on_dismiss(..) Der Nutzer hat die Seite entfernt (Zurück-Button, Swipe); ihre Route ist in TSX verschwunden, und Rust entfernt sie hier.

#Header-Slots

header, headerLeft, headerRight und headerTitle sind Slots: gewöhnliche Elemente, die Signals lesen, Eingaben verarbeiten und sich aktualisieren, solange die Seite angezeigt wird. header ist auf den Desktop-Plattformen und im Web der gesamte Header der App; die anderen drei gehören auf iOS und Android in den System-Header.

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

Der Inhalt der Seite beginnt unterhalb des Headers: unterhalb eines undurchsichtigen System-Headers auf iOS und Android, unterhalb von header auf den Desktop-Plattformen und im Web. Mit headerTransparent, headerLargeTitle oder headerVisible={false} beginnt er auf iOS und Android am oberen Rand der Seite; verwende env(safe-area-inset-top) oder safeAreaInsets(), um ihn vom Header und von der Statusleiste freizuhalten.

Auf iOS lässt headerVisible={false} Swipe-Back aktiviert, solange popOnSwipe und popOnBack wahr sind. Eine akzeptierte interaktive Zurück-Geste beendet die Bearbeitung, sobald der Übergang beginnt, sodass die Tastatur während des Ziehens verschwindet. Wird der Übergang abgebrochen, erhält derselbe Editor wieder den Fokus, sofern kein anderes Steuerelement ihn übernommen hat.

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

#Eine Seite verlassen

popOnBack={false} hält den Nutzer auf einer Seite, zum Beispiel solange ein Formular ungespeicherte Änderungen enthält. Solange es false ist, ist Swipe-Back deaktiviert, und die Zurück-Buttons und -Tasten rufen onBackPress auf, ohne die Seite zu entfernen. Frage den Nutzer und entferne die Seite dann selbst:

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 wird bei jedem Drücken von Zurück aufgerufen, auch wenn die Seite entfernt wird, und zeigt dir daher auch an, dass der Nutzer zurückgegangen ist. Um nur die Swipe-Geste zu deaktivieren und die Zurück-Buttons beizubehalten, verwende popOnSwipe={false}. Die Bestätigung selbst ist ein AlertDialog.

#Hinweise zu den Plattformen

  • iOS: UINavigationController mit System-Header, Zurück-Button und Swipe-Back; headerLargeTitle ist der große Titel von UIKit. Änderungen am Stack, die während eines laufenden Übergangs eintreffen, werden angewendet, wenn dieser endet.
  • Android: ein Stack mit Toolbar. Der System-Zurück-Button entfernt die oberste Seite, mit Predictive Back auf Android-Versionen, die es unterstützen; die Beschreibung des Aufwärtspfeils für Screenreader ist das headerBackTitle der darunterliegenden Seite oder die des Systems. Siehe BackHandler in BackHandler, um den Zurück-Button selbst zu behandeln.
  • Mac Catalyst: wie auf iOS.
  • Desktops (macOS, Windows, Linux, GPUI) und Web: Jede Seite zeigt oben ihren header und darunter ihren Inhalt. Escape, Alt+Links und die Zurück-Taste einer Maus gehen auf den Desktops zurück, auf dem Web der Zurück-Button des Browsers.

Für Inhalte, die über dem Stack und seinem Header erscheinen, etwa ein Sheet oder ein Vollbild-Flow, verwende einen Dialog.