تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

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 دالة الابن مرة لكل عملية دفع ويعرض النتيجة صفحةً. ويحتفظ بتلك الصفحة ما دام مسارها في المكدس، فتحتفظ الصفحة بحالتها (الإشارات، وموضع التمرير، والمدخلات) بينما تغطيها صفحات أخرى.
  • في Rust أبناء المكدس هم صفحاته: nav_page() الموضوعة في nav_stack() تُدفع، وما يُحذف منها يُخرج من المكدس. احتفظ بالمسارات في إشارة (signal) وارسم الصفحات باستخدام each(..)، مع تعريفها بمفتاح المسار، لتحتفظ الصفحات السفلية بحالتها.
  • كل عملية دفع هي صفحة مستقلة، بما في ذلك دفع قيمة المسار نفسها مرتين.
  • عندما يغادر المستخدم صفحة بالطريقة الأصلية (زر الرجوع، أو السحب للرجوع، أو رجوع Android، أو مفاتيح الرجوع في أنظمة سطح المكتب، أو رجوع المتصفح)، تُزال الصفحة. في TSX يزيل المكدس مسارها من الحالة، فيطابق nav.routes() دائمًا ما يراه المستخدم. في Rust تُبلّغ الصفحة عن on_dismiss، وهناك تحذف مسارها (والمسارات التي فوقه).
  • في TSX، تحصل دالة الابن التي تُرجع شيئًا غير <NavPage> على صفحة بخيارات افتراضية. أرجع <NavPage> لتعيين رأسها وخياراتها الأخرى.

#createNavStack

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

ينشئ حالة مكدس يبدأ بـ routes، من الأسفل إلى الأعلى. أنشئه مرة واحدة، مثلًا على مستوى الوحدة (module)، ومرّره إلى <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 قيمة المسار نفسها ودالة وصول (accessor) إلى موضعه في المكدس.

#خصائص 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لا شيءالألوان نفسها على شكل فئات (classes): 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: شريط الحالة بينما تكون الصفحة في الأعلى. انظر شريط الحالة.
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 فتحات (slots): عناصر عادية تقرأ الإشارات وتعالج الضغطات وتتحدث أثناء عرض الصفحة. 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 صحيحين. تنهي إيماءة الرجوع التفاعلية المقبولة التحرير مع بدء الانتقال، فيُغلق لوحة المفاتيح أثناء السحب. وإلغاء الانتقال يعيد التركيز إلى المحرر نفسه ما لم يأخذه عنصر تحكم آخر.

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 وزر الرجوع في الفأرة إلى الرجوع على أنظمة سطح المكتب، وزر الرجوع في المتصفح على الويب.

للمحتوى المعروض فوق المكدس ورأسه، مثل ورقة (sheet) أو تدفق بملء الشاشة، استخدم Dialog.