元素

NavStack

导航栈一次显示一个页面,并保留它下面的页面:在 iOS 和 Mac Catalyst 上是 UINavigationController,带系统标题栏和侧滑返回;在 Android 上是带工具栏和预测性返回的页面栈。在桌面平台(macOS、Windows、Linux、GPUI)和 Web 上,每个页面在顶部显示应用自己的标题栏,返回键和浏览器的后退按钮会弹出页面。NavStack 是栈,NavPage 是栈中的一个页面。

ui-navigation.tsx
ui-navigation.tsx运行中
// 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 { desktop, size, tint, ui, wide } from "./palette";

function Button(props: { title: string; variant?: "primary" | "secondary"; onPress: () => void }) {
  const primary = () => props.variant !== "secondary";
  return (
    <View
      role="button"
      onPress={props.onPress}
      style={desktop ? { ...(primary() ? ui().primary : ui().raised), height: size.control, alignSelf: "flex-start" } : primary() ? ui().primary : ui().raised}
      class={desktop ? "justify-center rounded-lg px-3 active:opacity-75" : "items-center rounded-xl py-3 active:opacity-75"}
    >
      <Text style={{ fontWeight: "600", fontSize: desktop ? 13 : undefined, color: primary() ? ui().onPrimary : ui().text }}>{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()} class="px-2 py-1 active:opacity-60">
            <Text style={{ fontSize: 17, color: ui().accent }}>‹ Back</Text>
          </View>
        </If>
      </View>
      <Text numberOfLines={1} style={{ 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 }}>
      {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 }}
    >
      {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() ? {} : { backgroundColor: tint(ui().text, 0.04) }}
                style={{
                  height: size.row + 12,
                  paddingHorizontal: 12,
                  borderRadius: 8,
                  justifyContent: "center",
                  gap: 2,
                  backgroundColor: selected() === id() ? tint(ui().accent, 0.14) : "transparent",
                }}
              >
                <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 numberOfLines={1} style={{ 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::ui::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 和返回按钮的系统标题栏;在桌面平台和 Web 上,它显示 header。只面向手机的应用可以省略 header;只面向桌面的应用可以省略系统标题栏的选项。导航指南讲解一个完整应用的导航:栈的状态、对话框、Android 返回键和标签页。

#栈的工作方式

  • 栈中的页面对应路由:由你选择的值,从栈底到栈顶排列。路由通常指明页面并携带它的参数。
  • 在 TSX 中,路由保存在 createNavStack 创建的状态里。NavStack 每次推入时调用一次子函数,并把结果显示为一个页面。只要路由还在栈中,它就保留这个页面,所以页面被其他页面覆盖时也会保留自己的状态(信号、滚动位置、输入内容)。
  • 在 Rust 中,栈的子元素就是它的页面:放入 nav_stack() 的 nav_page() 被推入,移除的被弹出。把路由保存在一个信号里,用按路由设置 key 的 each(..) 渲染页面,下面的页面就会保留各自的状态。
  • 每次推入都是一个独立的页面,同一个路由值推入两次也是如此。
  • 用户以原生方式离开页面时(返回按钮、侧滑返回、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);

属性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 的子函数接收路由值本身,以及一个表示它在栈中位置的 accessor。

NavPage 是栈中的一个页面。它包含页面的内容、它在桌面平台和 Web 上的 header,以及 iOS 和 Android 上系统标题栏的选项。在 Rust 中,nav_page() 返回这个页面,它的构建方法就是属性名的蛇形写法。

属性Rust类型默认值说明
header.header(..)JSX.Element无桌面平台和 Web:页面的标题栏,放在页面顶部,与页面同宽,高度由它的布局决定,内容位于其下方。
title.title(..)string无iOS 和 Android:系统标题栏的标题。
headerVisible.header_visible(..)booleantrueiOS 和 Android:显示系统标题栏。
headerTransparent.header_transparent(..)booleanfalseiOS 和 Android:内容延伸到透明的系统标题栏之下。
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"页面进入和离开的方式。
statusBar.status_bar(..){ foreground?, hidden?, animated? }无iOS 和 Android:页面位于顶部时的状态栏。见状态栏。
style、class样式方法无页面的样式。
children.child(..)JSX.Element无内容。
testID.test_id(..)string无在测试和 nsh test --trace 中找到这个页面。

事件Rust说明
onBackPress.on_back_press(..)用户在页面上按了返回(标题栏的返回按钮、Android 返回键、桌面平台上的 Escape、Alt+左方向键或鼠标的后退键、浏览器的后退),无论 popOnBack 是否让它弹出。
onDismiss.on_dismiss(..)用户弹出了页面(返回按钮、侧滑);在 TSX 中它的路由已被移除,在 Rust 中在这里移除它。

#标题栏插槽

header、headerLeft、headerRight 和 headerTitle 是插槽:普通的元素,可以读取信号、处理按压,并在页面显示期间更新。header 是应用在桌面平台和 Web 上的整个标题栏;其余三个放进 iOS 和 Android 的系统标题栏。

use nsh_base::nav_page;
use nsh_core::ui::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 上位于不透明的系统标题栏之下,在桌面平台和 Web 上位于 header 之下。使用 headerTransparent、headerLargeTitle 或 headerVisible={false} 时,在 iOS 和 Android 上内容从页面顶部开始;用 SafeAreaView 或 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::ui::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 editing")
                .destructive("Discard")
                .show()
                .await;
            if choice.ok().flatten().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,否则为系统的描述。要自己处理返回键,见 nsh/android 中的 BackHandler。
  • Mac Catalyst:与 iOS 相同。
  • 桌面平台(macOS、Windows、Linux、GPUI)和 Web:每个页面在顶部显示它的 header,内容位于其下方。桌面平台上 Escape、Alt+左方向键和鼠标的后退键返回,Web 上是浏览器的后退按钮。

要在栈及其标题栏之上显示内容,比如 sheet 或全屏流程,使用 Dialog。