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

التوثيق

التنقل

يوفّر nsh_base (في TSX: nsh/base) مكدس تنقل أصليًا وحوارات أصلية. مكدس التنقل (nav_stack() في Rust وNavStack في TSX) هو UINavigationController على iOS، مع ترويسته والسحب للرجوع والعناوين الكبيرة، ومكدس مع شريط أدوات ورجوع تنبؤي على Android؛ أما على أنظمة سطح المكتب والويب فتعرض كل صفحة الترويسة التي يرسمها تطبيقك. تحتفظ بالمسارات في حالة تطبيقك؛ ويعرض المكدس كل مسار كصفحة ويُبقيها مركّبة، مع حالتها، ما دامت في المكدس.

في Rust، استورد المُنشئات بأسمائها بجانب prelude (use nsh_base::{dialog, nav_page, nav_stack};؛ وتنبيهات النظام هي nsh_base::alert_dialog()). المسارات هي signal، وأبناء المكدس هم صفحاته، صفحة لكل مسار، وتُحدَّد بالمفتاح (each): تُضاف الصفحة لدفعها وتُزال لإخراجها. في TSX، يحمل createNavStack المسارات ويعرضها NavStack.

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())))
        },
    ))
}

#حالة المكدس

في Rust، المسارات هي signal خاص بك (signal(vec![..])، أو global::<Routes>() لما يصل إليه التطبيق كله)، وتقوم بالدفع والإخراج والاستبدال بتحديثه: routes.update(|r| r.push(route)) وr.pop() وr.truncate(1) وroutes.set(..). يخبر مفتاح كل صفحة (هنا نص Debug الخاص بالمسار) المكدس أي صفحة هي أي، فتحتفظ الصفحات التي يبقى مسارها بحالتها؛ أعطِ المسارات التي قد تتكرر مفتاحًا فريدًا. تعامل مع on_dismiss لكل صفحة: لقد رجع المستخدم أصليًا، ويغادر المسار (مع ما فوقه) signal الخاص بك.

في TSX، يُنشئ createNavStack(routes) حالة المكدس من مصفوفة مسارات، من الأسفل إلى الأعلى. المسار قيمة من اختيارك (نص، أو كائن، أو اتحاد مميَّز كما في الأعلى)، وكل دفع هو صفحة مستقلة، بما في ذلك دفع المسار نفسه مرتين. أنشئ الحالة مرة واحدة، على مستوى الوحدة أو في المكوّن الجذري، واستوردها أينما تنتقل الصفحات.

العضوالوصف
routes()المسارات، من الأسفل إلى الأعلى (تفاعلية).
push(route)يدفع صفحة.
pop(count?)يُخرج count من الصفحات (الافتراضي 1)؛ وتبقى صفحة واحدة على الأقل.
popToRoot()يرجع إلى الصفحة الأولى.
replace(route)يستبدل الصفحة العليا.
setRoutes(routes)يستبدل الصفحات، مثلًا بعد تسجيل الدخول؛ والمسار الذي يبقى (القيمة نفسها) يحتفظ بصفحته وحالتها.
remove(route)يزيل أعلى صفحة من route، أينما كانت.

للبدء بعدة صفحات في العمق، مرّر مسارات أكثر: createNavStack<Route>([{ name: "inbox" }, { name: "message", id: 3 }]).

عندما يغادر المستخدم صفحة أصليًا (زر الرجوع في الترويسة، أو السحب للرجوع على iOS، أو زر الرجوع أو إيماءته في Android، أو مفاتيح الرجوع وزر الفأرة في أنظمة سطح المكتب، أو زر الرجوع في المتصفح)، يزيل مكدس TSX ذلك المسار من حالته نيابةً عنك، فيطابق routes() دائمًا ما هو على الشاشة؛ أما صفحة Rust فتبلّغ عن ذلك عبر on_dismiss، كما في الأعلى.

يحمل المسار ما تحتاجه الصفحة. تدفع تطبيقات المعرض مسارات تحمل ما يكوّن الصفحة، وهذا يُبقي نوع المسار صغيرًا عندما تكثر الصفحات:

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

في Rust، nav_stack() هو view أبناؤه صفحاته، من الأسفل إلى الأعلى؛ نسّقه كأي view (.flex_1()). في TSX:

الخاصيةالنوعالوصف
stateNavStackState<R>المسارات المراد عرضها.
children(route: R, index: Accessor<number>) => JSX.Elementيعرض مسارًا؛ أعد <NavPage> لترويسته.
style، classنمط المكدس نفسه (عادةً flex: 1).

تُنفَّذ دالة العرض مرة واحدة لكل مسار، عند دفعه؛ route هي القيمة العادية، وindex() موضعه الحالي. ما تعيده يصبح محتوى الصفحة. أعد <NavPage> لضبط الترويسة؛ وأي شيء آخر يُغلَّف في صفحة بخيارات افتراضية.

تبقى الصفحات مركّبة ما دام مساراتها في المكدس: الرجوع يعرض الصفحة السابقة تمامًا كما تُركت، بموضع تمريرها ومدخلاتها وsignals الخاصة بها. تُنفَّذ معالجات cleanup الخاصة بالصفحة عندما يغادر مسارها المكدس.

تغلّف الصفحة (nav_page() في Rust، NavPage في TSX) محتوى الصفحة وتحمل ترويستها. يعرض iOS وAndroid ترويسة النظام، التي تُضبط بالعنوان وخيارات الترويسة. أما أنظمة سطح المكتب والويب فتعرض خانة ترويسة الصفحة (.header(..)، وفي TSX header)، وهي views من تصميمك، في أعلى الصفحة: بعرض الصفحة وبالارتفاع الذي تتخذه في التخطيط، والمحتوى تحتها. أعطِ الصفحة الاثنين، كما يفعل المثال أعلاه، فتبدو مألوفة في كل مكان. جميع الخيارات تفاعلية: غيّر signal فتتبعه الترويسة.

في Rust، كل خاصية أدناه هي دالة في nav_page() بصيغة 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(..)؛ وأبناؤها هم محتواها.

الخاصيةالنوعالافتراضيالوصف
headerJSX.Elementأنظمة سطح المكتب والويب: ترويسة الصفحة، يرسمها التطبيق (عنوان، وزر رجوع، وأزرار شريط الأدوات).
titlestringiOS وAndroid: عنوان ترويسة النظام.
headerVisiblebooleantrueiOS وAndroid: تظهر ترويسة النظام.
headerTransparentbooleanfalseiOS وAndroid: يمتد المحتوى تحت الترويسة.
headerStyle{ backgroundColor?, color?, tintColor? }iOS وAndroid: خلفية الترويسة، ولون العنوان (color) ولون الأزرار (tintColor).
headerClassstringمثل classes: bg-*، وtext-* للعنوان، وtint-* للأزرار؛ ويتغلب headerStyle عليها.
headerBackVisiblebooleantrueiOS وAndroid: يظهر زر الرجوع.
headerLeftJSX.ElementiOS وAndroid: views في بداية الترويسة (تحل محل زر الرجوع).
headerRightJSX.ElementiOS وAndroid: views في نهاية الترويسة.
ios={{ headerRightSystemItem }}"add" | "done" | "close" | "edit" | "compose" | "search" | "share" | "trash" | "save" | "cancel"iOS: زر شريط UIKit الأصلي في نهاية الترويسة، بمظهر النظام ورمزه أو تسميته، بدلًا من views الخاصة بـheaderRight؛ والضغط عليه هو ضغط على أول view قابل للضغط في headerRight، فأبقِ واحدًا هناك. يعرض Android headerRight كما هو.
ios={{ headerLeftSystemItem }}مثل headerRightSystemItemiOS: الأمر نفسه، في بداية الترويسة، ويضغط headerLeft.
ios={{ headerRightSymbol }}stringiOS: زر شريط UIKit الأصلي يعرض رمز SF Symbol بذلك الاسم ("slider.horizontal.3") بدلًا من views الخاصة بـheaderRight؛ والضغط عليه هو ضغط على أول view قابل للضغط في headerRight، ويقرأ VoiceOver تسمية ذلك الـview. يتغلب headerRightSystemItem عليه؛ والاسم الذي لا تملكه SF Symbols يعرض headerRight كما هو. يعرض Android headerRight كما هو.
ios={{ headerLeftSymbol }}stringiOS: الأمر نفسه، في بداية الترويسة، ويضغط headerLeft.
headerTitleJSX.ElementiOS وAndroid: views تحل محل العنوان.
headerLargeTitlebooleanfalseiOS: عنوان كبير ينكمش داخل الشريط عند تمرير المحتوى.
headerBackTitlestringتسمية زر الرجوع الذي يعود إلى هذه الصفحة (iOS؛ ويُسمعها سهم الأعلى في Android لقارئات الشاشة).
animation"default" | "slide" | "fade" | "none""default"كيف تدخل الصفحة وتغادر.
statusBar{ foreground?, hidden?, animated? }iOS وAndroid: شريط الحالة عندما تكون الصفحة في الأعلى (شريط الحالة).
popOnSwipebooleantrueتُخرج إيماءة الرجوع التفاعلية (السحب للرجوع في iOS، والرجوع التنبؤي في Android) الصفحة. وتُخرجها أزرار الرجوع في الحالتين.
popOnBackbooleantrueكل طريقة للرجوع تُخرج الصفحة: أزرار الرجوع، ورجوع Android، ومفاتيح الرجوع في أنظمة سطح المكتب، وزر الرجوع في المتصفح، والسحب.
onBackPress() => voidضغط المستخدم على الرجوع في الصفحة، سواء سمح popOnBack بإخراجها أم لا.
onDismiss() => voidأخرج المستخدم الصفحة؛ وقد أُزيل مسارها بالفعل.
style، classعرض محتوى الصفحة.

عناصر الترويسة هي views عادية، فيمكن أن تكون قابلة للضغط وتفاعلية:

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

ترويسة نظام ملوّنة (في Rust، يأخذ header_style_with أنماطًا مُنوَّعة الأنواع: bg وtext_color وtint_color، وهو لون أزرار الترويسة؛ وألوان السمة تتبع المظهر):

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

الصفحة التي لا تُظهر ترويسة ما زالت تحيط بها أشرطة النظام؛ فأضف حشوة لمحتواها بحسب هوامش المنطقة الآمنة:

use nsh_base::nav_page;

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

#المناطق الآمنة تحت الترويسة

داخل الصفحة، المتغيرات env(safe-area-inset-*) وهوامش الصفحة (Scope::insets في Rust، safeAreaInsets() في TSX) هي التي تقع تحت ترويسة النظام، داخل الصفحة. مع ترويسة شفافة، يبدأ المحتوى من أعلى الصفحة ويكون الهامش العلوي هو ارتفاع الترويسة، فالحشوة بمقدار env(safe-area-inset-top) تُبقي النص بعيدًا عنها بينما تملأ صورة الخلفية الصفحة كلها. مع عنوان كبير، ضع scroll view مباشرة في الصفحة ليتمكن العنوان من الانطواء عند التمرير. على أنظمة سطح المكتب والويب، يبدأ المحتوى تحت خانة ترويسة الصفحة.

#إبقاء المستخدم في الصفحة: popOnBack

لسؤال المستخدم قبل التخلي عن تغييرات غير محفوظة، اضبط pop_on_back (في TSX popOnBack) على false ما دام هناك ما قد يضيع، وتعامل مع ضغطة الرجوع (.on_back_press(..)، وفي TSX onBackPress)، وأخرج الصفحة عندما يوافق المستخدم:

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

مع pop_on_back(false) (popOnBack={false})، تبقى الصفحة عند كل طريقة للرجوع (أزرار الرجوع، وزر Android وإيماءته، ومفاتيح الرجوع في أنظمة سطح المكتب، وزر الرجوع في المتصفح، والسحب)، ويخبرك حدث ضغطة الرجوع بأن المستخدم حاول. أما الإخراج من الشيفرة (بإزالة المسار، nav.pop()) فيُخرج الصفحة كالمعتاد.

pop_on_swipe(false) (popOnSwipe={false}) هو الخيار الأخف: يعطّل الإيماءة التفاعلية فقط (السحب للرجوع في iOS، ورسوم الرجوع التنبؤي في Android)، وتبقى أزرار الرجوع تُخرج الصفحة.

#الحوارات

يعرض الحوار (dialog() من nsh_base في Rust، Dialog في TSX) المحتوى فوق كل شيء آخر، في طبقة نافذة أصلية خاصة به: view controller معروض على iOS، ونافذة حوار على Android، وطبقة فوق النافذة كلها على أنظمة سطح المكتب والويب. مظهره من تصميمك. في Rust، يُعرض الحوار ما دام في الـview (داخل when) ويملؤه أبناؤه: ارسم الخلفية الخلفية والنافذة المنبثقة كـviews. في TSX، يُنسّق class وstyle النافذة المنبثقة التي تحمل أبناءه، ويضيف backdropClass أو backdropStyle خلفية خلفها، ويتحكم open فيه:

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 وزر Android للرجوع والضغط على الخلفية (خلفية TSX، أو خلفية الورقة الأصلية في Rust) والسحب للأسفل على الورقة (close_on_escape وclose_on_backdrop_press وclose_on_swipe، وفي TSX closeOnEscape وcloseOnBackdropPress وcloseOnSwipe، وكلها true افتراضيًا). في Rust، يبلّغ on_cancel عن كل محاولة ويبلّغ on_close عن الإغلاق، فاترك الحوار خارج الـview هناك؛ والخلفية التي يرسمها تطبيقك تغلقه بمعالج الضغط الخاص بها، كما في الأعلى. في TSX، يخبرك onOpenChange({ open: false, reason }) بذلك، فاضبط open على false هناك. للتأكيد أولًا، اضبط علَمًا على false وتعامل مع حدث الإلغاء (on_cancel، onCancel)، الذي يبلّغ عن كل محاولة، ثم أغلق الحوار بنفسك. على iOS، يعرض .presentation(ModalPresentation::PageSheet) أو FormSheet (presentation="pageSheet" أو "formSheet") ورقة تُبقي ما خلفها مرئيًا.

راجع Dialog لكل الخصائص. داخل الحوار، المتغيرات env(safe-area-inset-*) والهوامش هي الخاصة بالحوار نفسه. للحوارات النظامية (تنبيه بإجراءات، أو ورقة إجراءات) استخدم AlertDialog من nsh_base (في TSX: nsh/base).

#زر الرجوع في Android

على Android، يُعالَج زر الرجوع (أو إيماءته) بهذا الترتيب:

  1. الحوار الأعلى: يبلّغ عن الإلغاء (on_cancel، onCancel)، ويُغلق ما دام close_on_escape / closeOnEscape هو true (الافتراضي).
  2. مستمعو الرجوع لديك (on_back_press في Rust، BackHandler من nsh/mobile في TSX)، الأحدث إضافةً أولًا.
  3. المكدس الأعلى: تبلّغ صفحته العليا عن ضغطة الرجوع، وتُخرَج ما دام pop_on_back / popOnBack هو true.
  4. إن لم يعالجه أحد: يخرج التطبيق.

يعيد المستمع true عندما يعالج الضغطة، وهذا يمنع المكدس من الإخراج. وما دام هناك مستمع مسجَّل، لا تعرض إيماءة الرجوع في Android معاينة للإخراج (يجيب التطبيق فقط عند انتهاء الإيماءة)، فأضف المستمعين فقط عندما يكون لهم أن يأخذوا الضغطة:

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()
}

يستمر المستمع المنشأ في effect حتى يُنفَّذ الـeffect مجددًا أو يزول مكوّنه؛ وفي TSX، توقفه أيضًا الدالة التي يعيدها BackHandler.on. في TSX، يُخرج BackHandler.exitApp() المستخدم من التطبيق؛ ويمكن استيراد nsh/mobile على iOS أيضًا، حيث تعود استدعاءات BackHandler فورًا، فتعمل الشيفرة نفسها على المنصتين. راجع الشيفرة الخاصة بكل منصة.

#علامات التبويب وتخطيطات أخرى

شريط علامات التبويب هو واجهتك الخاصة، مرسومة من العناصر الأساسية: صف من views قابلة للضغط وsignal يحمل علامة التبويب المحددة؛ اعرض محتوى علامة التبويب المحددة بـwhen / <If> (أو بابن دالة في TSX). للاحتفاظ بحالة كل علامة تبويب عند التبديل، أعطِ كل علامة مكدسها ومساراتها الخاصة وأبقِها كلها مركّبة، مع إخفاء غير النشطة بـdisplay: none.

#ذو صلة