Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

Elementos

NavStack

Uma pilha de navegação mostra uma página por vez e mantém as páginas abaixo dela: UINavigationController no iOS e no Mac Catalyst, com o cabeçalho do sistema e o gesto de deslizar para voltar, e uma pilha de páginas com barra de ferramentas e voltar preditivo no Android. Nos desktops (macOS, Windows, Linux, GPUI) e na web, cada página mostra o cabeçalho próprio do app no topo, e as teclas de voltar e o botão de voltar do navegador removem a página da pilha. NavStack é a pilha, NavPage é uma de suas 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);

A pilha controla o comportamento de navegação (transições, gestos e botões de voltar, estado das páginas enquanto estão cobertas); o conteúdo das páginas e o cabeçalho dos desktops ficam por conta do 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.")))
            },
        ))
}

No iOS e no Android, a página mostra o cabeçalho do sistema com title e um botão de voltar; nos desktops e na web, mostra header. Um app que mira apenas celulares deixa header de fora; um que mira apenas desktops deixa de fora as opções do cabeçalho do sistema. O Guia de navegação percorre a navegação de um app inteiro: o estado da pilha, diálogos, o botão de voltar do Android e as abas.

#Como uma pilha funciona

  • As páginas da pilha são rotas: valores de sua escolha, da base ao topo da pilha. Uma rota geralmente nomeia a página e carrega seus parâmetros.
  • Em TSX, as rotas ficam no estado criado por createNavStack. NavStack chama sua função filha uma vez a cada push e mostra o resultado como uma página. Ele mantém essa página enquanto sua rota estiver na pilha, então uma página mantém seu estado (signals, posição de rolagem, campos de entrada) enquanto outras páginas a cobrem.
  • Em Rust, os filhos da pilha são suas páginas: um nav_page() colocado em nav_stack() é empilhado, e um que for deixado de fora é removido. Mantenha as rotas em um signal e renderize as páginas com each(..), usando a rota como chave, para que as páginas abaixo mantenham seu estado.
  • Cada push é uma página própria, inclusive quando o mesmo valor de rota é empilhado duas vezes.
  • Quando o usuário sai de uma página de forma nativa (botão de voltar, deslizar para voltar, voltar do Android, teclas de voltar dos desktops, voltar do navegador), a página é removida. Em TSX, a pilha remove a rota do estado, de modo que nav.routes() sempre corresponde ao que o usuário vê. Em Rust, a página dispara on_dismiss, onde você descarta a rota dela (e as rotas acima dela).
  • Em TSX, uma função filha que retorna algo diferente de <NavPage> gera uma página com opções padrão. Retorne um <NavPage> para definir o cabeçalho e as demais opções.

#createNavStack

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

Cria o estado de uma pilha que começa com routes, da base ao topo. Crie-o uma só vez, por exemplo no nível do módulo, e passe-o para <NavStack state>. Qualquer código pode importá-lo para navegar.

Membro Descrição
routes: Accessor<readonly R[]> As rotas, da base ao topo. Reativo.
push(route: R): void Empilha uma página.
pop(count?: number): void Remove count páginas (padrão 1). Pelo menos uma página sempre permanece.
popToRoot(): void Volta para a primeira página.
replace(route: R): void Substitui a página do topo.
setRoutes(routes: R[]): void Substitui as páginas; uma rota que permanece (o mesmo valor) mantém sua página e seu estado.
remove(route: R): void Remove a página mais ao topo de route (comparada com ===), esteja onde estiver na pilha.

Em Rust, as mesmas operações são alterações no seu signal de rotas: routes.update(|r| r.push(route)) empilha, r.pop() remove, r.truncate(1) volta para a primeira página que você mantém nele, e routes.set(..) substitui as 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 Descrição
state as páginas são seus filhos NavStackState<R> O estado que guarda as rotas (createNavStack). Obrigatório.
children .child(..) (route: R, index: Accessor<number>) => JSX.Element Renderiza uma rota. Retorne um <NavPage> para suas opções. Obrigatório.
style, class métodos de estilo O estilo da pilha; normalmente flex: 1.
testID .test_id(..) string Localiza a pilha em testes e em nsh test --trace.

A função filha em TSX recebe o próprio valor da rota e um accessor da posição dela na pilha.

#Props de NavPage

NavPage é uma página de uma pilha. Ela guarda o conteúdo da página, seu header nos desktops e na web, e as opções do cabeçalho do sistema no iOS e no Android. Em Rust, nav_page() retorna a página e seus métodos builder são os nomes das props em snake case.

Prop Rust Tipo Padrão Descrição
header .header(..) JSX.Element nenhum Desktops e web: o cabeçalho da página, posicionado no topo da página, tão largo quanto a página e tão alto quanto o seu layout, com o conteúdo abaixo dele.
title .title(..) string nenhum iOS e Android: o título do cabeçalho do sistema.
headerVisible .header_visible(..) boolean true iOS e Android: o cabeçalho do sistema é exibido.
headerTransparent .header_transparent(..) boolean false iOS e Android: o conteúdo se estende por baixo de um cabeçalho do sistema transparente.
hidesBottomBarWhenPushed .hides_bottom_bar_when_pushed(..) boolean false iOS: a barra de abas do sistema sai enquanto esta página está no topo, movendo-se na transição — a mesma animação da página, e a barra nunca é reconstruída; ela volta quando a página é removida.
headerLargeTitle .header_large_title(..) boolean false iOS: um título grande que encolhe até a barra conforme o conteúdo rola; o conteúdo rola por baixo do cabeçalho.
headerBackTitle .header_back_title(..) string nenhum iOS e Android: o rótulo do botão de voltar que leva de volta a esta página (no Android, sua descrição para leitores de tela).
headerBackVisible .header_back_visible(..) boolean true iOS e Android: o botão de voltar é exibido.
headerLeft .header_left(..) JSX.Element nenhum iOS e Android: conteúdo na borda inicial do cabeçalho do sistema, no lugar do botão de voltar.
headerRight .header_right(..) JSX.Element nenhum iOS e Android: conteúdo na borda final do cabeçalho do sistema.
headerTitle .header_title(..) JSX.Element nenhum iOS e Android: conteúdo que substitui o título.
headerStyle .header_style(..) { backgroundColor?, color?, tintColor? } plataforma iOS e Android: as cores do cabeçalho do sistema: o fundo, o do título (color) e o dos botões (tintColor).
headerClass .header_style_with(|s| ..) string nenhum As mesmas cores como classes: bg-*, text-* para o título, tint-* para os botões (em Rust .bg(..), .text_color(..), .tint_color(..), cores do tema que acompanham a aparência). headerStyle tem prioridade sobre elas.
popOnBack .pop_on_back(..) boolean true Os botões de voltar e o deslizar para voltar removem a página. Veja Saindo de uma página.
popOnSwipe .pop_on_swipe(..) boolean true O gesto interativo de voltar remove a página: deslizar para voltar no iOS, voltar preditivo do Android que acompanha o dedo. Os botões de voltar removem a página de qualquer forma.
animation .animation(..) "default" | "slide" | "fade" | "none" "default" Como a página entra e sai.
transitionShader .transition_shader(TransitionShader::new(program)) { source: string; uniforms?: Record<string, ShaderUniform>; duration?: number } nenhum A página entra e sai por meio de um shader WGSL no lugar de seu animation: shade(uv) lê a página abaixo como sample_from e esta página como sample_to enquanto nsh.progress vai de 0 a 1 ao longo de duration milissegundos (350 por padrão), e de 1 de volta a 0 quando a página sai. Quando o sistema pede menos movimento, a página usa o seu próprio animation. Veja Shader.
statusBar .status_bar(..) { foreground?, hidden?, animated? } nenhum iOS e Android: a barra de status enquanto a página está no topo. Veja Barra de status.
style, class métodos de estilo nenhum O estilo da página.
children .child(..) JSX.Element nenhum O conteúdo.
testID .test_id(..) string nenhum Localiza a página em testes e em nsh test --trace.

#Eventos de NavPage

Evento Rust Descrição
onBackPress .on_back_press(..) O usuário pressionou voltar na página (o botão de voltar do cabeçalho, voltar do Android, Escape, Alt+Seta para a esquerda ou o botão de voltar do mouse nos desktops, o voltar do navegador), independentemente de popOnBack ter permitido remover a página.
onDismiss .on_dismiss(..) O usuário removeu a página (botão de voltar, deslize); em TSX sua rota já foi removida, e em Rust você a descarta aqui.

#Slots do cabeçalho

header, headerLeft, headerRight e headerTitle são slots: elementos comuns que leem signals, tratam toques e se atualizam enquanto a página é exibida. header é o cabeçalho inteiro do app nos desktops e na web; os outros três vão para o cabeçalho do sistema no iOS e no 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())
}

O conteúdo da página começa abaixo do cabeçalho: abaixo de um cabeçalho do sistema opaco no iOS e no Android, abaixo de header nos desktops e na web. Com headerTransparent, headerLargeTitle ou headerVisible={false}, ele começa no topo da página no iOS e no Android; use env(safe-area-inset-top) ou safeAreaInsets() para mantê-lo afastado do cabeçalho e da barra de status.

No iOS, headerVisible={false} mantém o deslizar para voltar habilitado enquanto popOnSwipe e popOnBack forem verdadeiros. Um gesto interativo de voltar aceito encerra a edição quando a transição começa, então o teclado é dispensado durante o arrasto. Cancelar a transição restaura o foco do mesmo editor quando outro controle não o tiver assumido.

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

#Saindo de uma página

popOnBack={false} mantém o usuário em uma página, por exemplo enquanto um formulário tem alterações não salvas. Enquanto for false, o deslizar para voltar fica desativado e os botões e teclas de voltar chamam onBackPress sem remover a página. Pergunte ao usuário e depois remova a página você mesmo:

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 é chamado a cada toque em voltar, inclusive quando a página é removida, então ele também informa que o usuário voltou. Para desativar apenas o gesto de deslizar e manter os botões de voltar, use popOnSwipe={false}. A confirmação em si é um AlertDialog.

#Notas por plataforma

  • iOS: UINavigationController com o cabeçalho do sistema, botão de voltar e deslizar para voltar; headerLargeTitle é o título grande do UIKit. As alterações na pilha que chegam durante uma transição em andamento são aplicadas quando ela termina.
  • Android: uma pilha com barra de ferramentas. O botão de voltar do sistema remove a página do topo, com voltar preditivo nas versões do Android que o possuem; a descrição da seta para cima para leitores de tela é o headerBackTitle da página inferior, ou o do sistema. Veja BackHandler em BackHandler para tratar o botão de voltar por conta própria.
  • Mac Catalyst: igual ao iOS.
  • Desktops (macOS, Windows, Linux, GPUI) e web: cada página mostra seu header no topo e seu conteúdo abaixo dele. Escape, Alt+Seta para a esquerda e o botão de voltar do mouse voltam nos desktops, e o botão de voltar do navegador faz isso na web.

Para conteúdo exibido sobre a pilha e seu cabeçalho, como uma sheet ou um fluxo em tela cheia, use um Dialog.