本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

NavStack

導覽堆疊(navigation stack)一次顯示一個頁面,並保留其下方的頁面:在 iOS 與 Mac Catalyst 上為 UINavigationController,具備系統標頭與滑動返回;在 Android 上則是帶有工具列與預測式返回(predictive back)的頁面堆疊。在桌面平台(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 返回按鈕與分頁。

#堆疊的運作方式

  • 堆疊中的頁面是路由(route):由你自行選擇的值,從堆疊底部排到頂部。路由通常會指明頁面並攜帶其參數。
  • 在 TSX 中,路由存放於 createNavStack 建立的狀態中。NavStack 會針對每次 push 呼叫一次其子函式,並將結果顯示為頁面。只要其路由仍在堆疊中,就會保留該頁面,因此頁面在被其他頁面覆蓋時會保有自己的狀態(signal、捲動位置、輸入內容)。
  • 在 Rust 中,堆疊的子元素就是它的頁面:放入 nav_stack() 的 nav_page() 會被 push,被移除的則會被 pop。請將路由保存在 signal 中,並使用 each(..) 依路由作為 key 來渲染頁面,這樣下方的頁面才能保有狀態。
  • 每次 push 都是獨立的頁面,即使同一個路由值被 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): voidPush 一個頁面。
pop(count?: number): voidPop count 個頁面(預設為 1)。始終至少會保留一個頁面。
popToRoot(): voidPop 回到第一個頁面。
replace(route: R): void取代頂部頁面。
setRoutes(routes: R[]): void取代這些頁面;保留下來的路由(相同的值)會保有其頁面與狀態。
remove(route: R): void移除 route 中最上方的頁面(以 === 比較),無論它位於堆疊中的哪個位置。

在 Rust 中,相同的操作就是對你的路由 signal 進行修改:routes.update(|r| r.push(route)) 為 push,r.pop() 為 pop,r.truncate(1) 為 pop 回到其中保留的第一個頁面,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 是堆疊中的一個頁面。它包含頁面的內容、在桌面平台與網頁上的 header,以及 iOS 與 Android 上系統標頭的選項。在 Rust 中,nav_page() 會回傳該頁面,其 builder 方法即為屬性名稱的 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:此頁面位於頂部時,系統分頁列會離開,並在轉場中移動——與頁面使用相同的動畫,且分頁列不會被重建;頁面 pop 後它會回來。
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無以 class 表示的相同顏色:bg-*、標題用的 text-*、按鈕用的 tint-*(在 Rust 中為 .bg(..)、.text_color(..)、.tint_color(..),為隨外觀變化的主題色)。headerStyle 的優先順序高於它們。
popOnBack.pop_on_back(..)booleantrue返回按鈕與滑動返回會 pop 該頁面。請參閱離開頁面。
popOnSwipe.pop_on_swipe(..)booleantrue互動式返回手勢會 pop 該頁面:iOS 的滑動返回、跟隨手指的 Android 預測式返回。無論如何,返回按鈕都會 pop。
animation.animation(..)"default" | "slide" | "fade" | "none""default"頁面進入與離開的方式。
transitionShader.transition_shader(TransitionShader::new(program)){ source: string; uniforms?: Record<string, ShaderUniform>; duration?: number }無頁面透過 WGSL shader 進入與離開,取代其 animation:shade(uv) 會將下方頁面讀取為 sample_from、此頁面讀取為 sample_to,而 nsh.progress 會在 duration 毫秒內(預設 350)從 0 變化到 1,並在頁面離開時從 1 回到 0。當系統要求減少動態效果時,頁面會改用自己的 animation。請參閱 Shader。
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+Left 或滑鼠的返回按鈕、瀏覽器的返回),無論 popOnBack 是否允許 pop。
onDismiss.on_dismiss(..)使用者 pop 了該頁面(返回按鈕、滑動);在 TSX 中其路由已被移除,在 Rust 中則於此處移除。

#標頭插槽

header、headerLeft、headerRight 與 headerTitle 是插槽(slot):即普通元素,可讀取 signal、處理按下事件,並在頁面顯示期間更新。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 上,當 popOnSwipe 與 popOnBack 為 true 時,headerVisible={false} 會保持滑動返回啟用。一旦接受互動式返回手勢,編輯狀態會在轉場開始時結束,因此鍵盤會在拖曳期間收起。取消轉場後,若沒有其他控制項取得焦點,則會恢復同一個編輯器的焦點。

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

#離開頁面

popOnBack={false} 會讓使用者停留在頁面上,例如表單有未儲存的變更時。當它為 false 時,滑動返回會關閉,而返回按鈕與按鍵會呼叫 onBackPress 但不會 pop。請先詢問使用者,再自行 pop:

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 會在每次按下返回時被呼叫,包括頁面被 pop 時,因此它也會告訴你使用者已返回。若只想關閉滑動手勢而保留返回按鈕,請使用 popOnSwipe={false}。確認本身則是 AlertDialog。

#平台說明

  • iOS:UINavigationController 搭配系統標頭、返回按鈕與滑動返回;headerLargeTitle 是 UIKit 的大標題。在轉場進行期間送達的堆疊變更,會在轉場結束時套用。
  • Android:帶有工具列的堆疊。系統返回按鈕會 pop 頂部頁面,在支援的 Android 版本上採用預測式返回;向上箭頭供螢幕閱讀器使用的說明為下層頁面的 headerBackTitle,或系統預設值。若要自行處理返回按鈕,請參閱 BackHandler 中的 BackHandler。
  • Mac Catalyst:與 iOS 相同。
  • 桌面平台(macOS、Windows、Linux、GPUI)與網頁:每個頁面在頂部顯示其 header,內容位於其下方。在桌面平台上,Escape、Alt+Left 與滑鼠的返回按鈕可返回上一頁;在網頁上則使用瀏覽器的返回按鈕。

若要在堆疊及其標頭之上顯示內容,例如 sheet 或全螢幕流程,請使用 Dialog。