Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

NavStack

Une pile de navigation affiche une page à la fois et conserve les pages situées en dessous : UINavigationController sur iOS et Mac Catalyst, avec l’en-tête système et le balayage pour revenir, et une pile de pages avec une barre d’outils et le retour prédictif sur Android. Sur les plateformes de bureau (macOS, Windows, Linux, GPUI) et le web, chaque page affiche en haut l’en-tête propre à l’app, et les touches de retour ainsi que le bouton retour du navigateur dépilent la page. NavStack est la pile, NavPage l’une de ses pages.

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 pile gère le comportement de navigation (transitions, gestes et boutons de retour, état des pages tant qu’elles sont recouvertes) ; le contenu des pages et l’en-tête sur les plateformes de bureau relèvent de l’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.")))
            },
        ))
}

Sur iOS et Android, la page affiche l’en-tête système avec title et un bouton de retour ; sur les plateformes de bureau et le web, elle affiche header. Une app qui cible uniquement les téléphones omet header ; une app qui cible uniquement les plateformes de bureau omet les options de l’en-tête système. Le Guide de navigation présente la navigation d’une app complète : l’état de la pile, les dialogues, le bouton retour d’Android et les onglets.

#Fonctionnement d’une pile

  • Les pages de la pile sont des routes : des valeurs de votre choix, du bas de la pile vers le haut. Une route désigne généralement la page et porte ses paramètres.
  • En TSX, les routes résident dans l’état que createNavStack crée. NavStack appelle sa fonction enfant une fois pour chaque push et affiche le résultat comme une page. Il conserve cette page tant que sa route est dans la pile, de sorte qu’une page garde son état (signaux, position de défilement, champs de saisie) pendant que d’autres pages la recouvrent.
  • En Rust, les enfants de la pile sont ses pages : un nav_page() placé dans nav_stack() est empilé, et un autre retiré est dépilé. Conservez les routes dans un signal et affichez les pages avec each(..), indexées par route, afin que les pages en dessous conservent leur état.
  • Chaque push crée une page distincte, y compris lorsque la même valeur de route est empilée deux fois.
  • Lorsque l’utilisateur quitte une page de façon native (bouton retour, balayage pour revenir, retour Android, touches de retour des plateformes de bureau, retour du navigateur), la page disparaît. En TSX, la pile retire sa route de l’état, de sorte que nav.routes() correspond toujours à ce que voit l’utilisateur. En Rust, la page signale on_dismiss, où vous retirez sa route (et les routes au-dessus).
  • En TSX, une fonction enfant qui renvoie autre chose qu’un <NavPage> produit une page avec les options par défaut. Renvoyez un <NavPage> pour définir son en-tête et ses autres options.

#createNavStack

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

Crée l’état d’une pile qui démarre avec routes, de bas en haut. Créez-le une seule fois, par exemple au niveau du module, et passez-le à <NavStack state>. N’importe quel code peut l’importer pour naviguer.

MembreDescription
routes: Accessor<readonly R[]>Les routes, de bas en haut. Réactif.
push(route: R): voidEmpile une page.
pop(count?: number): voidDépile count pages (1 par défaut). Au moins une page reste toujours.
popToRoot(): voidDépile jusqu’à la première page.
replace(route: R): voidRemplace la page du haut.
setRoutes(routes: R[]): voidRemplace les pages ; une route qui reste (la même valeur) conserve sa page et son état.
remove(route: R): voidRetire la page la plus haute de route (comparée avec ===), où qu’elle se trouve dans la pile.

En Rust, les mêmes opérations sont des modifications de votre signal de routes : routes.update(|r| r.push(route)) empile, r.pop() dépile, r.truncate(1) dépile jusqu’à la première page que vous y conservez, et routes.set(..) remplace les pages.

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

PropRustTypeDescription
stateles pages sont ses enfantsNavStackState<R>L’état qui contient les routes (createNavStack). Obligatoire.
children.child(..)(route: R, index: Accessor<number>) => JSX.ElementAffiche une route. Renvoyez un <NavPage> pour ses options. Obligatoire.
style, classméthodes de styleLe style de la pile ; généralement flex: 1.
testID.test_id(..)stringPermet de retrouver la pile dans les tests et nsh test --trace.

La fonction enfant TSX reçoit la valeur de la route elle-même et un accesseur de sa position dans la pile.

#Props de NavPage

NavPage est une page d’une pile. Elle contient le contenu de la page, son header sur les plateformes de bureau et le web, et les options de l’en-tête système sur iOS et Android. En Rust, nav_page() renvoie la page et ses méthodes de construction portent le nom des props en snake_case.

PropRustTypeDéfautDescription
header.header(..)JSX.ElementaucunPlateformes de bureau et web : l’en-tête de la page, placé en haut de la page, aussi large que la page et aussi haut que sa mise en page l’exige, avec le contenu en dessous.
title.title(..)stringaucuniOS et Android : le titre de l’en-tête système.
headerVisible.header_visible(..)booleantrueiOS et Android : l’en-tête système est affiché.
headerTransparent.header_transparent(..)booleanfalseiOS et Android : le contenu s’étend sous un en-tête système transparent.
hidesBottomBarWhenPushed.hides_bottom_bar_when_pushed(..)booleanfalseiOS : la barre d’onglets système disparaît tant que cette page est au sommet, en se déplaçant pendant la transition — avec la même animation que la page, et la barre n’est jamais reconstruite ; elle revient lorsque la page est dépilée.
headerLargeTitle.header_large_title(..)booleanfalseiOS : un grand titre qui se réduit dans la barre lorsque le contenu défile ; le contenu défile sous l’en-tête.
headerBackTitle.header_back_title(..)stringaucuniOS et Android : le libellé du bouton de retour qui ramène à cette page (sur Android, sa description pour les lecteurs d’écran).
headerBackVisible.header_back_visible(..)booleantrueiOS et Android : le bouton de retour est affiché.
headerLeft.header_left(..)JSX.ElementaucuniOS et Android : contenu au bord de début de l’en-tête système, à la place du bouton de retour.
headerRight.header_right(..)JSX.ElementaucuniOS et Android : contenu au bord de fin de l’en-tête système.
headerTitle.header_title(..)JSX.ElementaucuniOS et Android : contenu qui remplace le titre.
headerStyle.header_style(..){ backgroundColor?, color?, tintColor? }plateformeiOS et Android : les couleurs de l’en-tête système : son arrière-plan, celle du titre (color) et celle des boutons (tintColor).
headerClass.header_style_with(|s| ..)stringaucunLes mêmes couleurs sous forme de classes : bg-*, text-* pour le titre, tint-* pour les boutons (en Rust .bg(..), .text_color(..), .tint_color(..), des couleurs de thème qui suivent l’apparence). headerStyle l’emporte sur elles.
popOnBack.pop_on_back(..)booleantrueLes boutons de retour et le balayage pour revenir dépilent la page. Voir Quitter une page.
popOnSwipe.pop_on_swipe(..)booleantrueLe geste de retour interactif dépile la page : balayage pour revenir sur iOS, retour prédictif d’Android qui suit le doigt. Les boutons de retour dépilent dans les deux cas.
animation.animation(..)"default" | "slide" | "fade" | "none""default"La façon dont la page entre et sort.
transitionShader.transition_shader(TransitionShader::new(program)){ source: string; uniforms?: Record<string, ShaderUniform>; duration?: number }aucunLa page entre et sort au moyen d’un shader WGSL à la place de son animation : shade(uv) lit la page en dessous sous le nom sample_from et cette page sous le nom sample_to pendant que nsh.progress va de 0 à 1 sur duration millisecondes (350 par défaut), puis de 1 à 0 lorsque la page sort. Lorsque le système demande de réduire les animations, la page utilise son propre animation. Voir Shader.
statusBar.status_bar(..){ foreground?, hidden?, animated? }aucuniOS et Android : la barre d’état tant que la page est au sommet. Voir Barre d’état.
style, classméthodes de styleaucunLe style de la page.
children.child(..)JSX.ElementaucunLe contenu.
testID.test_id(..)stringaucunPermet de retrouver la page dans les tests et nsh test --trace.

#Événements de NavPage

ÉvénementRustDescription
onBackPress.on_back_press(..)L’utilisateur a appuyé sur retour sur la page (bouton de retour de l’en-tête, retour Android, Échap, Alt+Gauche ou bouton retour d’une souris sur les plateformes de bureau, retour du navigateur), que popOnBack ait autorisé ou non le dépilement.
onDismiss.on_dismiss(..)L’utilisateur a dépilé la page (bouton de retour, balayage) ; sa route a disparu en TSX, et Rust la retire ici.

#Slots de l’en-tête

header, headerLeft, headerRight et headerTitle sont des slots : des éléments ordinaires qui lisent des signaux, gèrent les appuis et se mettent à jour tant que la page est affichée. header constitue l’intégralité de l’en-tête de l’app sur les plateformes de bureau et le web ; les trois autres vont dans l’en-tête système sur iOS et 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())
}

Le contenu de la page commence sous l’en-tête : sous un en-tête système opaque sur iOS et Android, sous header sur les plateformes de bureau et le web. Avec headerTransparent, headerLargeTitle ou headerVisible={false}, il commence en haut de la page sur iOS et Android ; utilisez env(safe-area-inset-top) ou safeAreaInsets() pour le maintenir à distance de l’en-tête et de la barre d’état.

Sur iOS, headerVisible={false} garde le balayage pour revenir activé tant que popOnSwipe et popOnBack sont vrais. Un geste de retour interactif accepté met fin à l’édition au début de la transition, de sorte que le clavier se ferme pendant le glissement. L’annulation de la transition rend le focus au même éditeur si aucun autre contrôle ne l’a pris.

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

#Quitter une page

popOnBack={false} garde l’utilisateur sur une page, par exemple tant qu’un formulaire contient des modifications non enregistrées. Tant qu’il est faux, le balayage pour revenir est désactivé et les boutons et touches de retour appellent onBackPress sans dépiler. Interrogez l’utilisateur, puis dépilez vous-même :

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 est appelé à chaque appui sur retour, y compris lorsque la page est dépilée, il vous indique donc aussi que l’utilisateur est revenu en arrière. Pour désactiver uniquement le geste de balayage et conserver les boutons de retour, utilisez popOnSwipe={false}. La confirmation elle-même est une AlertDialog.

#Notes par plateforme

  • iOS : UINavigationController avec l’en-tête système, le bouton de retour et le balayage pour revenir ; headerLargeTitle est le grand titre d’UIKit. Les changements de pile qui arrivent pendant une transition en cours sont appliqués à la fin de celle-ci.
  • Android : une pile avec une barre d’outils. Le bouton retour système dépile la page du haut, avec le retour prédictif sur les versions d’Android qui le prennent en charge ; la description de la flèche de retour pour les lecteurs d’écran est le headerBackTitle de la page inférieure, ou celle du système. Voir BackHandler dans BackHandler pour gérer vous-même le bouton retour.
  • Mac Catalyst : comme sur iOS.
  • Plateformes de bureau (macOS, Windows, Linux, GPUI) et web : chaque page affiche son header en haut et son contenu en dessous. Échap, Alt+Gauche et le bouton retour d’une souris reviennent en arrière sur les plateformes de bureau, le bouton retour du navigateur sur le web.

Pour du contenu affiché au-dessus de la pile et de son en-tête, comme une feuille ou un parcours en plein écran, utilisez un Dialog.