Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

NavStack

Навигационный стек показывает одну страницу за раз и сохраняет страницы под ней: UINavigationController на iOS и Mac Catalyst, с системным заголовком и свайпом назад, и стек страниц с тулбаром и предиктивным «назад» на Android. На десктопах (macOS, Windows, Linux, GPUI) и в вебе каждая страница показывает собственный заголовок приложения в верхней части, а клавиши «назад» и кнопка «назад» браузера снимают страницу со стека. NavStack — это стек, NavPage — одна из его страниц.

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

Стек отвечает за поведение навигации (переходы, жесты и кнопки «назад», состояние страниц, пока они перекрыты); содержимое страниц и заголовок на десктопах определяет само приложение.

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.")))
            },
        ))
}

На iOS и Android страница показывает системный заголовок с title и кнопкой «назад»; на десктопах и в вебе она показывает header. Приложение, ориентированное только на телефоны, обходится без header; приложение только для десктопов не использует опции системного заголовка. В руководстве по навигации разобрана навигация целого приложения: состояние стека, диалоги, кнопка «назад» на Android и вкладки.

#Как работает стек

  • Страницы стека — это маршруты (routes): произвольные значения на ваш выбор, от низа стека к верху. Маршрут обычно называет страницу и несёт её параметры.
  • В TSX маршруты хранятся в состоянии, которое создаёт createNavStack. NavStack вызывает свою дочернюю функцию один раз для каждого push и показывает результат как страницу. Страница хранится, пока её маршрут находится в стеке, поэтому она сохраняет своё состояние (сигналы, позицию прокрутки, поля ввода), пока её перекрывают другие страницы.
  • В Rust дочерние элементы стека — это его страницы: nav_page(), помещённая в nav_stack(), добавляется в стек, а убранная из него снимается. Храните маршруты в сигнале и отрисовывайте страницы через each(..) с ключом по маршруту, чтобы нижние страницы сохраняли своё состояние.
  • Каждый push создаёт отдельную страницу, в том числе при повторном добавлении одного и того же значения маршрута.
  • Когда пользователь уходит со страницы штатными средствами (кнопка «назад», свайп назад, «назад» на Android, клавиши «назад» на десктопах, «назад» в браузере), страница удаляется. В TSX стек убирает её маршрут из состояния, поэтому nav.routes() всегда соответствует тому, что видит пользователь. В Rust страница сообщает on_dismiss, и в этом обработчике вы убираете её маршрут (и маршруты выше него).
  • В TSX дочерняя функция, которая возвращает не <NavPage>, получает страницу с опциями по умолчанию. Верните <NavPage>, чтобы задать её заголовок и другие опции.

#createNavStack

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

Создаёт состояние стека, который начинается с routes (от низа к верху). Создайте его один раз, например на уровне модуля, и передайте в <NavStack state>. Любой код может импортировать его для навигации.

ЭлементОписание
routes: Accessor<readonly R[]>Маршруты от низа к верху. Реактивное значение.
push(route: R): voidДобавляет страницу.
pop(count?: number): voidСнимает count страниц (по умолчанию 1). Как минимум одна страница всегда остаётся.
popToRoot(): voidВозвращается к первой странице.
replace(route: R): voidЗаменяет верхнюю страницу.
setRoutes(routes: R[]): voidЗаменяет страницы; маршрут, который остаётся (то же значение), сохраняет свою страницу и её состояние.
remove(route: R): voidУдаляет самую верхнюю страницу с route (сравнение через ===), где бы она ни находилась в стеке.

В Rust те же операции выражаются изменениями сигнала с маршрутами: routes.update(|r| r.push(route)) добавляет страницу, r.pop() снимает, r.truncate(1) возвращает к первой оставленной в нём странице, а routes.set(..) заменяет страницы.

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

#Свойства NavStack

СвойствоRustТипОписание
stateстраницы — его дочерние элементыNavStackState<R>Состояние, в котором хранятся маршруты (createNavStack). Обязательно.
children.child(..)(route: R, index: Accessor<number>) => JSX.ElementОтрисовывает маршрут. Верните <NavPage>, чтобы задать его опции. Обязательно.
style, classметоды стиляСтиль стека; обычно flex: 1.
testID.test_id(..)stringНаходит стек в тестах и nsh test --trace.

Дочерняя функция в TSX получает само значение маршрута и аксессор его позиции в стеке.

#Свойства NavPage

NavPage — это одна страница стека. Она содержит контент страницы, её header на десктопах и в вебе, а также опции системного заголовка на iOS и Android. В Rust nav_page() возвращает страницу, а её методы-билдеры называются так же, как свойства, но в snake_case.

СвойствоRustТипПо умолчаниюОписание
header.header(..)JSX.ElementнетДесктопы и веб: заголовок страницы, расположенный в верхней части страницы, шириной со страницу и высотой по своей раскладке, с контентом под ним.
title.title(..)stringнетiOS и Android: заголовок системного заголовка.
headerVisible.header_visible(..)booleantrueiOS и Android: системный заголовок показывается.
headerTransparent.header_transparent(..)booleanfalseiOS и Android: контент простирается под прозрачный системный заголовок.
hidesBottomBarWhenPushed.hides_bottom_bar_when_pushed(..)booleanfalseiOS: системная панель вкладок скрывается, пока эта страница наверху, и уезжает вместе с переходом — той же анимацией, что и страница, при этом панель никогда не пересоздаётся; она возвращается, когда страница снимается.
headerLargeTitle.header_large_title(..)booleanfalseiOS: большой заголовок, который сжимается в панель при прокрутке контента; контент прокручивается под заголовком.
headerBackTitle.header_back_title(..)stringнетiOS и Android: подпись кнопки «назад», которая ведёт обратно на эту страницу (на Android — её описание для скринридеров).
headerBackVisible.header_back_visible(..)booleantrueiOS и Android: кнопка «назад» показывается.
headerLeft.header_left(..)JSX.ElementнетiOS и Android: контент у переднего края системного заголовка вместо кнопки «назад».
headerRight.header_right(..)JSX.ElementнетiOS и Android: контент у заднего края системного заголовка.
headerTitle.header_title(..)JSX.ElementнетiOS и Android: контент, который заменяет заголовок.
headerStyle.header_style(..){ backgroundColor?, color?, tintColor? }платформенноеiOS и Android: цвета системного заголовка: фон, цвет заголовка (color) и цвет кнопок (tintColor).
headerClass.header_style_with(|s| ..)stringнетТе же цвета в виде классов: bg-*, text-* для заголовка, tint-* для кнопок (в Rust .bg(..), .text_color(..), .tint_color(..), цвета темы, следующие за оформлением). headerStyle имеет приоритет над ними.
popOnBack.pop_on_back(..)booleantrueКнопки «назад» и свайп назад снимают страницу. См. Уход со страницы.
popOnSwipe.pop_on_swipe(..)booleantrueИнтерактивный жест «назад» снимает страницу: свайп назад на iOS, предиктивное «назад» на Android, следующее за пальцем. Кнопки «назад» снимают страницу в любом случае.
animation.animation(..)"default" | "slide" | "fade" | "none""default"Как страница появляется и уходит.
transitionShader.transition_shader(TransitionShader::new(program)){ source: string; uniforms?: Record<string, ShaderUniform>; duration?: number }нетСтраница появляется и уходит через шейдер WGSL вместо своего animation: shade(uv) читает страницу под ней как sample_from, а эту страницу как sample_to, пока nsh.progress меняется от 0 до 1 за duration миллисекунд (по умолчанию 350), а при уходе страницы — от 1 обратно к 0. Если система запрашивает уменьшенное движение, страница использует свой animation. См. Shader.
statusBar.status_bar(..){ foreground?, hidden?, animated? }нетiOS и Android: строка состояния, пока страница наверху. См. Status bar.
style, classметоды стилянетСтиль страницы.
children.child(..)JSX.ElementнетКонтент.
testID.test_id(..)stringнетНаходит страницу в тестах и nsh test --trace.

#События NavPage

СобытиеRustОписание
onBackPress.on_back_press(..)Пользователь нажал «назад» на странице (кнопка «назад» в заголовке, «назад» на Android, Escape, Alt+Left или кнопка «назад» мыши на десктопах, «назад» в браузере), независимо от того, позволил ли popOnBack снять страницу.
onDismiss.on_dismiss(..)Пользователь снял страницу (кнопка «назад», свайп); в TSX её маршрут уже удалён, а в Rust вы убираете его здесь.

#Слоты заголовка

header, headerLeft, headerRight и headerTitle — это слоты: обычные элементы, которые читают сигналы, обрабатывают нажатия и обновляются, пока страница показана. header — весь заголовок приложения на десктопах и в вебе; остальные три размещаются в системном заголовке на iOS и 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())
}

Контент страницы начинается под заголовком: под непрозрачным системным заголовком на iOS и Android, под header на десктопах и в вебе. При headerTransparent, headerLargeTitle или headerVisible={false} на iOS и Android он начинается от верха страницы; используйте env(safe-area-inset-top) или safeAreaInsets(), чтобы удерживать его вне зоны заголовка и строки состояния.

На iOS headerVisible={false} оставляет свайп назад включённым, пока popOnSwipe и popOnBack равны true. Принятый интерактивный жест «назад» завершает редактирование в момент начала перехода, поэтому клавиатура скрывается во время перетаскивания. Если переход отменён, фокус возвращается тому же редактору, если его не занял другой элемент управления.

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

#Уход со страницы

popOnBack={false} удерживает пользователя на странице, например пока в форме есть несохранённые изменения. Пока значение равно false, свайп назад отключён, а кнопки и клавиши «назад» вызывают onBackPress, не снимая страницу. Спросите пользователя, а затем снимите страницу самостоятельно:

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 вызывается при каждом нажатии «назад», в том числе когда страница снимается, поэтому он же сообщает, что пользователь вернулся назад. Чтобы отключить только жест свайпа и оставить кнопки «назад», используйте popOnSwipe={false}. Само подтверждение — это AlertDialog.

#Заметки по платформам

  • iOS: UINavigationController с системным заголовком, кнопкой «назад» и свайпом назад; headerLargeTitle — это большой заголовок UIKit. Изменения стека, поступившие во время выполняющегося перехода, применяются после его завершения.
  • Android: стек с тулбаром. Системная кнопка «назад» снимает верхнюю страницу, с предиктивным «назад» на версиях Android, где оно есть; описание стрелки «вверх» для скринридеров — это headerBackTitle нижней страницы или системное. Чтобы обрабатывать кнопку «назад» самостоятельно, см. BackHandler в BackHandler.
  • Mac Catalyst: как на iOS.
  • Десктопы (macOS, Windows, Linux, GPUI) и веб: каждая страница показывает свой header вверху, а контент под ним. На десктопах «назад» выполняют Escape, Alt+Left и кнопка «назад» мыши, в вебе — кнопка «назад» браузера.

Для контента, показываемого поверх стека и его заголовка, например листа или полноэкранного сценария, используйте Dialog.