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

Documentação

Navegação

nsh_base (TSX: nsh/base) oferece uma pilha de navegação nativa e diálogos nativos. Uma pilha de navegação (nav_stack() em Rust, NavStack em TSX) é uma UINavigationController no iOS, com seu cabeçalho, gesto de voltar deslizando e títulos grandes, e uma pilha com toolbar e predictive back no Android; nos desktops e na web, cada página mostra o cabeçalho que o seu app desenha. Você mantém as rotas no seu estado; a pilha renderiza cada rota como uma página e a mantém montada, com seu estado, enquanto ela estiver na pilha.

Em Rust, importe os builders pelo nome junto com o prelude (use nsh_base::{dialog, nav_page, nav_stack};; os alertas do sistema são nsh_base::alert_dialog()). As rotas são um signal e os filhos da pilha são suas páginas, uma por rota, identificadas por chave (each): uma página que entra faz o push, e uma que sai faz o pop. Em TSX, createNavStack guarda as rotas e NavStack as renderiza.

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

#[derive(Debug, Clone, PartialEq)]
enum Route {
    Inbox,
    Message(u32),
}

/// The page's header on the desktops and the web; iOS and Android show the system's, from `title`.
fn header(routes: Signal<Vec<Route>>, title: &'static str) -> impl IntoElement {
    view()
        .h(px(48.0))
        .flex_row()
        .items_center()
        .gap_3()
        .border_b_1()
        .border_color(rgb(0xe5e7eb))
        .px_4()
        .child(when(
            move || routes.with(|r| r.len() > 1),
            move || {
                text("Back")
                    .role(AccessibilityRole::Button)
                    .on_press(move || routes.update(|r| { r.pop(); }))
                    .text_color(rgb(0x2563eb))
                    .active(|s| s.opacity(0.6))
            },
        ))
        .child(text(title).text_size(px(17.0)).font_weight(FontWeight::SEMIBOLD))
}

fn inbox(routes: Signal<Vec<Route>>) -> Screen {
    nav_page()
        .title("Inbox")
        .header(header(routes, "Inbox"))
        .child(scroll().flex_1().children((1..=5).map(move |id| {
            view()
                .on_press(move || routes.update(|r| r.push(Route::Message(id))))
                .p_4()
                .active(|s| s.bg(rgb(0xf3f4f6)))
                .child(text(format!("Message {id}")))
        })))
}

fn message(routes: Signal<Vec<Route>>, id: u32) -> Screen {
    nav_page()
        .title(format!("Message {id}"))
        .header(header(routes, "Message"))
        .child(view().flex_1().p(px(16.0)).child(text(format!("The body of message {id}."))))
}

#[component]
pub fn App() -> impl IntoElement {
    let routes = signal(vec![Route::Inbox]);
    nav_stack().flex_1().child(each(
        move || routes.get(),
        |route: &Route| format!("{route:?}"),
        move |route, index| {
            let page = match route.get_untracked() {
                Route::Inbox => inbox(routes),
                Route::Message(id) => message(routes, id),
            };
            // The user went back natively: drop the route (and those above it).
            page.on_dismiss(move || routes.update(|r| r.truncate(index.get_untracked())))
        },
    ))
}

#O estado da pilha

Em Rust, as rotas são o seu próprio signal (signal(vec![..]), ou global::<Routes>() para um que o app inteiro alcança), e você faz push, pop e replace atualizando-o: routes.update(|r| r.push(route)), r.pop(), r.truncate(1), routes.set(..). A chave de cada página (aqui o texto Debug da rota) diz à pilha qual página é qual, de modo que as páginas cuja rota permanece mantêm seu estado; dê uma chave única às rotas que podem se repetir. Trate o on_dismiss de cada página: o usuário voltou de forma nativa, e a rota (com as que estão acima dela) sai do seu signal.

Em TSX, createNavStack(routes) cria o estado da pilha a partir de um array de rotas, da base ao topo. Uma rota é qualquer valor que você quiser (uma string, um objeto, uma union discriminada como acima), e cada push é uma página própria, inclusive quando a mesma rota é empilhada duas vezes. Crie o estado uma só vez, no nível do módulo ou no seu componente raiz, e importe-o onde as páginas navegarem.

Membro Descrição
routes() As rotas, da base ao topo (reativo).
push(route) Empilha uma página.
pop(count?) Remove count páginas (padrão 1); pelo menos uma permanece.
popToRoot() Volta até a primeira página.
replace(route) Substitui a página do topo.
setRoutes(routes) Substitui as páginas, por exemplo após o login; uma rota que permanece (o mesmo valor) mantém sua página e seu estado.
remove(route) Remove a página mais ao topo de route, onde quer que ela esteja.

Para começar com várias páginas de profundidade, passe mais rotas: createNavStack<Route>([{ name: "inbox" }, { name: "message", id: 3 }]).

Quando o usuário sai de uma página de forma nativa (o botão de voltar no cabeçalho, o gesto de deslizar para voltar no iOS, o botão ou gesto de voltar do Android, as teclas e o botão do mouse de voltar nos desktops, o botão de voltar do navegador), a pilha em TSX remove essa rota do seu estado por você, de modo que routes() sempre corresponde ao que está na tela; uma página em Rust informa isso com on_dismiss, como acima.

Uma rota carrega o que a página precisa. Os apps de galeria empilham rotas que contêm aquilo que forma a página, o que mantém o tipo da rota pequeno quando há muitas páginas:

use std::rc::Rc;

use nsh_core::prelude::*;

/// A pushed page: its key (unique in the stack) and what makes it.
#[derive(Clone)]
pub struct Route {
    pub key: SharedString,
    pub page: Rc<dyn Fn() -> Screen>,
}

impl PartialEq for Route {
    fn eq(&self, other: &Self) -> bool {
        self.key == other.key
    }
}

/// The pushed pages, bottom to top: one signal for the whole app.
#[derive(Default, Clone)]
pub struct Routes(pub Vec<Route>);

pub fn routes() -> Signal<Routes> {
    global::<Routes>()
}

// Elsewhere: routes().update(|r| r.0.push(Route { key: "settings".into(), page: Rc::new(settings) }));

Em Rust, nav_stack() é uma view cujos filhos são suas páginas, da base ao topo; estilize-a como qualquer view (.flex_1()). Em TSX:

Prop Tipo Descrição
state NavStackState<R> As rotas a mostrar.
children (route: R, index: Accessor<number>) => JSX.Element Renderiza uma rota; retorne um <NavPage> para o cabeçalho dela.
style, class O estilo da própria pilha (geralmente flex: 1).

A função de renderização roda uma vez por rota, quando ela é empilhada; route é o valor simples, index() sua posição atual. O que ela retorna se torna o conteúdo da página. Retorne um <NavPage> para definir o cabeçalho; qualquer outra coisa é envolvida em uma página com as opções padrão.

As páginas permanecem montadas enquanto sua rota estiver na pilha: ao voltar, a página anterior aparece exatamente como foi deixada, com sua posição de rolagem, inputs e signals. Os handlers de cleanup de uma página rodam quando sua rota sai da pilha.

Uma página (nav_page() em Rust, NavPage em TSX) envolve o conteúdo de uma página e carrega seu cabeçalho. O iOS e o Android mostram o cabeçalho do sistema, configurado pelo título e pelas opções de cabeçalho. Os desktops e a web mostram o slot de cabeçalho da página (.header(..), em TSX header), com as suas próprias views, no topo da página: tão largo quanto a página, tão alto quanto o layout resultar, com o conteúdo abaixo dele. Dê os dois a uma página, como faz o exemplo acima, e ela fica natural em qualquer lugar. Todas as opções são reativas: mude um signal e o cabeçalho acompanha.

Em Rust, cada prop abaixo é um método de nav_page() em snake case: .title(..), .header(..), .header_visible(..), .header_transparent(..), .header_style_with(..), .header_style(..), .header_back_visible(..), .header_left(..), .header_right(..), .header_title(..), .header_large_title(..), .header_back_title(..), .animation(ScreenAnimation::Fade), .status_bar(..), .pop_on_swipe(..), .pop_on_back(..), .on_back_press(..), .on_dismiss(..); seus filhos são o seu conteúdo.

Prop Tipo Padrão Descrição
header JSX.Element Desktops e web: o cabeçalho da página, desenhado pelo app (um título, um botão de voltar, botões de toolbar).
title string iOS e Android: o título do cabeçalho do sistema.
headerVisible boolean true iOS e Android: o cabeçalho do sistema é exibido.
headerTransparent boolean false iOS e Android: o conteúdo se estende por baixo do cabeçalho.
headerStyle { backgroundColor?, color?, tintColor? } iOS e Android: o fundo do cabeçalho, a cor do título (color) e a dos botões (tintColor).
headerClass string O mesmo que classes: bg-*, text-* para o título, tint-* para os botões; headerStyle prevalece sobre eles.
headerBackVisible boolean true iOS e Android: o botão de voltar é exibido.
headerLeft JSX.Element iOS e Android: views no início do cabeçalho (elas substituem o botão de voltar).
headerRight JSX.Element iOS e Android: views no fim do cabeçalho.
ios={{ headerRightSystemItem }} "add" | "done" | "close" | "edit" | "compose" | "search" | "share" | "trash" | "save" | "cancel" iOS: o próprio bar button do UIKit no fim do cabeçalho, com a aparência, o símbolo ou o rótulo do sistema, no lugar das views de headerRight; um toque nele é um toque na primeira view pressionável de headerRight, então mantenha uma ali. O Android mostra headerRight como está.
ios={{ headerLeftSystemItem }} igual a headerRightSystemItem iOS: o mesmo, no início do cabeçalho, acionando headerLeft.
ios={{ headerRightSymbol }} string iOS: o próprio bar button do UIKit mostrando o SF Symbol com esse nome ("slider.horizontal.3") no lugar das views de headerRight; um toque nele é um toque na primeira view pressionável de headerRight, e o VoiceOver lê o rótulo dessa view. headerRightSystemItem prevalece sobre ele; um nome que os SF Symbols não têm faz headerRight ser mostrado como está. O Android mostra headerRight como está.
ios={{ headerLeftSymbol }} string iOS: o mesmo, no início do cabeçalho, acionando headerLeft.
headerTitle JSX.Element iOS e Android: views que substituem o título.
headerLargeTitle boolean false iOS: um título grande que encolhe para dentro da barra conforme o conteúdo rola.
headerBackTitle string O rótulo do botão de voltar que leva de volta a esta página (iOS; a seta de voltar do Android diz isso aos leitores de tela).
animation "default" | "slide" | "fade" | "none" "default" Como a página entra e sai.
statusBar { foreground?, hidden?, animated? } iOS e Android: a barra de status enquanto a página está no topo (Barra de status).
popOnSwipe boolean true O gesto interativo de voltar (swipe back do iOS, predictive back do Android) faz o pop. Os botões de voltar fazem o pop de qualquer forma.
popOnBack boolean true Todo jeito de voltar faz o pop da página: os botões de voltar, o voltar do Android, as teclas de voltar dos desktops, o botão de voltar do navegador e o deslize.
onBackPress () => void O usuário pressionou voltar na página, tendo popOnBack permitido ou não o pop.
onDismiss () => void O usuário fez o pop da página; a rota dela já foi removida.
style, class A view de conteúdo da página.

Os itens do cabeçalho são views comuns, então podem ser pressionáveis e reativos:

fn compose(routes: Signal<Vec<Route>>) -> Screen {
    let draft = signal(String::new());
    let cancel = move || {
        text("Cancel")
            .on_press(move || routes.update(|r| { r.pop(); }))
            .text_size(px(17.0))
            .text_color(rgb(0x0969da))
    };
    let send_button = move || {
        text("Send")
            .on_press(move || send(draft.get()))
            .text_size(px(17.0))
            .font_weight(FontWeight::SEMIBOLD)
            .text_color(move || if draft.with(String::is_empty) { rgb(0x8c959f) } else { rgb(0x0969da) })
    };
    nav_page()
        .title("New message")
        .header_left(cancel())
        .header_right(send_button())
        .header(
            view()
                .h(px(48.0))
                .flex_row()
                .items_center()
                .justify_between()
                .px_4()
                .child(cancel())
                .child(text("New message").text_size(px(17.0)).font_weight(FontWeight::SEMIBOLD))
                .child(send_button()),
        )
        .child(
            input()
                .multiline(true)
                .value(move || draft.get())
                .on_change(move |t: &str| draft.set(t.to_owned()))
                .flex_1()
                .p(px(16.0))
                .text_size(px(17.0)),
        )
}

Um cabeçalho de sistema colorido (em Rust, header_style_with recebe estilos tipados: bg, text_color e tint_color, a cor dos botões do cabeçalho; as cores do tema acompanham a aparência):

nav_page()
    .title("Profile")
    .header_style_with(|s| s.bg(rgb(0x1a7f37)).text_color(rgb(0xffffff)).tint_color(rgb(0xffffff)))

Uma página que não mostra cabeçalho ainda tem as barras do sistema ao redor; aplique padding ao conteúdo com os insets da área segura:

use nsh_base::nav_page;

nav_page()
    .header_visible(false)
    .child(view().flex_1().p_safe_area() /* … */)

#Áreas seguras sob o cabeçalho

Dentro de uma página, as variáveis env(safe-area-inset-*) e os insets da página (Scope::insets em Rust, safeAreaInsets() em TSX) são os que ficam abaixo do cabeçalho do sistema, dentro da página. Com um cabeçalho transparente, o conteúdo começa no topo da página e o inset superior é a altura do cabeçalho, então aplicar padding com env(safe-area-inset-top) mantém o texto afastado dele enquanto uma imagem de fundo preenche a página inteira. Com um título grande, coloque uma scroll view diretamente na página para que o título possa colapsar ao rolar. Nos desktops e na web, o conteúdo começa abaixo do slot de cabeçalho da página.

#Mantendo o usuário em uma página: popOnBack

Para perguntar antes de descartar alterações não salvas, defina pop_on_back (TSX popOnBack) como false enquanto houver algo a perder, trate o toque em voltar (.on_back_press(..), TSX onBackPress) e faça o pop quando o usuário concordar:

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

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

Com pop_on_back(false) (popOnBack={false}), a página permanece em qualquer jeito de voltar (os botões de voltar, o botão e o gesto de voltar do Android, as teclas de voltar dos desktops, o botão de voltar do navegador, o deslize), e o evento de toque em voltar informa que o usuário tentou. Fazer o pop pelo código (removendo a rota, nav.pop()) faz o pop como sempre.

pop_on_swipe(false) (popOnSwipe={false}) é a opção mais leve: desativa apenas o gesto interativo (swipe back do iOS, animação de predictive back do Android), e os botões de voltar continuam fazendo o pop.

#Diálogos

Um diálogo (dialog() de nsh_base em Rust, Dialog em TSX) apresenta conteúdo sobre todo o resto, em sua própria camada de janela nativa: uma view controller apresentada no iOS, uma janela de diálogo no Android, uma camada sobre a janela inteira nos desktops e na web. A aparência é sua. Em Rust, o diálogo é apresentado enquanto está na view (dentro de um when) e seus filhos o preenchem: desenhe o backdrop e o popup como views. Em TSX, class e style estilizam o popup que contém seus filhos, backdropClass ou backdropStyle adicionam um backdrop atrás dele, e open o controla:

use nsh_base::dialog;
use nsh_core::prelude::*;

pub fn files() -> impl IntoElement {
    let open = signal(false);
    view()
        .flex_1()
        .child(view().on_press(move || open.set(true)).p(px(16.0)).child(text("Delete the file")))
        .child(when(
            move || open.get(),
            move || {
                dialog()
                    .animation(ModalAnimation::Fade)
                    // Escape, Android's back button, a swipe down on a sheet.
                    .on_cancel(move || open.set(false))
                    .child(
                        // The backdrop and the popup are the app's views.
                        view()
                            .flex_1()
                            .bg(rgba(0x00000066))
                            .items_center()
                            .justify_center()
                            .on_press(move || open.set(false))
                            .child(
                                view()
                                    .w(px(320.0))
                                    .gap_3()
                                    .rounded_2xl()
                                    .bg(white())
                                    .p_5()
                                    .on_press(|| {})
                                    .child(text("Delete the file?").text_size(px(20.0)).font_weight(FontWeight::BOLD))
                                    .child(
                                        view()
                                            .on_press(move || open.set(false))
                                            .child(text("Delete").text_color(rgb(0xcf222e))),
                                    ),
                            ),
                    )
            },
        ))
}

Escape, o botão de voltar do Android, um toque no backdrop (o backdrop do TSX, o de uma sheet nativa em Rust) e um deslize para baixo em uma sheet o fecham de forma nativa (close_on_escape, close_on_backdrop_press e close_on_swipe, em TSX closeOnEscape, closeOnBackdropPress e closeOnSwipe, todos true por padrão). Em Rust, on_cancel informa cada tentativa e on_close o fechamento, então deixe o diálogo fora da view ali; um backdrop que o seu app desenha o fecha com o próprio handler de toque, como acima. Em TSX, onOpenChange({ open: false, reason }) avisa você, então defina open como false ali. Para confirmar antes, defina uma flag como false e trate o evento de cancelamento (on_cancel, onCancel), que informa cada tentativa, e então feche o diálogo você mesmo. No iOS, .presentation(ModalPresentation::PageSheet) ou FormSheet (presentation="pageSheet" ou "formSheet") apresenta uma sheet que mantém visível o que está atrás.

Veja Dialog para todas as props. Dentro de um diálogo, as variáveis env(safe-area-inset-*) e os insets são os do próprio diálogo. Para diálogos do sistema (um alerta com ações, um action sheet), use AlertDialog de nsh_base (TSX: nsh/base).

#O botão voltar do Android

No Android, o botão (ou gesto) de voltar é tratado nesta ordem:

  1. O diálogo do topo: ele informa o cancelamento (on_cancel, onCancel) e fecha enquanto close_on_escape / closeOnEscape for true (o padrão).
  2. Seus listeners de voltar (on_back_press em Rust, BackHandler de nsh/mobile em TSX), o adicionado mais recentemente primeiro.
  3. A pilha do topo: sua página do topo informa o toque em voltar e faz o pop enquanto pop_on_back / popOnBack for true.
  4. Se nada o tratou: o app é encerrado.

Um listener retorna true quando tratou o toque, o que impede a pilha de fazer o pop. Enquanto um listener estiver registrado, o gesto de voltar do Android não mostra uma prévia do pop (o app só responde quando o gesto termina), então adicione listeners apenas enquanto eles puderem assumir o toque:

use nsh_core::prelude::*;

pub fn editor() -> impl IntoElement {
    let selecting = signal(false);
    // Listening only while selecting: otherwise the stack pops, with the gesture's preview.
    effect(move || {
        if selecting.get() {
            on_back_press(move || {
                selecting.set(false);
                true
            });
        }
    });
    // …
    view()
}

Um listener criado em um effect dura até o effect rodar de novo ou seu componente sair; em TSX, a função que BackHandler.on retorna também o interrompe. Em TSX, BackHandler.exitApp() sai do app; nsh/mobile também pode ser importado no iOS, onde as chamadas de BackHandler retornam imediatamente, de modo que o mesmo código roda nas duas plataformas. Veja Código específico de plataforma.

#Abas e outros layouts

Uma barra de abas é a sua própria UI, desenhada a partir de primitivas: uma linha de views pressionáveis e um signal que guarda a aba selecionada; renderize o conteúdo da aba selecionada com when / <If> (ou um filho função em TSX). Para manter o estado de cada aba ao alternar, dê a cada aba sua própria pilha e rotas e mantenha todas montadas, escondendo as inativas com display: none.

#Relacionados