文档

导航

nsh_base(TSX:nsh/base)提供原生的导航栈和原生的对话框。导航栈(Rust 中是 nav_stack(),TSX 中是 NavStack)在 iOS 上是 UINavigationController,带有它的标题栏、侧滑返回和大标题;在 Android 上是带工具栏、支持预测性返回的页面栈;在桌面和 Web 上,每个页面显示应用自己绘制的标题栏。路由保存在你的状态中;栈把每个路由渲染为一个页面,只要路由还在栈里,页面就保持挂载,状态也随之保留。

在 Rust 中,在 prelude 旁按名字导入这些构建函数(use nsh_base::{dialog, nav_page, nav_stack};;系统提示框是 nsh_base::alert_dialog())。路由是一个 signal,栈的子元素就是它的页面,每个路由一个,按 key 保持(each):放入页面即压入,移出即弹出。在 TSX 中,createNavStack 保存路由,NavStack 渲染它们。

use nsh_base::{nav_page, nav_stack};
use nsh_core::ui::prelude::*;

#[derive(Debug, Clone, PartialEq)]
enum Route {
    Inbox,
    Message(u32),
}

/// 桌面和 Web 上页面的标题栏;iOS 和 Android 显示系统标题栏,取自 `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),
            };
            // 用户以原生方式返回:移除这个路由(以及它上面的路由)。
            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(..)。每个页面的 key(这里是路由的 Debug 文本)告诉栈哪个页面是哪个,所以路由保留的页面会保留状态;可能重复的路由要给一个唯一的 key。处理每个页面的 on_dismiss:用户以原生方式返回了,这个路由(连同它上面的路由)要从你的 signal 中移除。

在 TSX 中,createNavStack(routes) 用一个从底到顶的路由数组创建栈的状态。路由可以是任何值(字符串、对象,或者像上面那样的可辨识联合类型),每次 push 都是一个独立的页面,同一个路由 push 两次也一样。状态只创建一次,放在模块顶层或根组件中,在需要导航的页面里导入它。

成员说明
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 报告。

路由携带页面需要的数据。gallery 示例应用推送的路由直接保存创建页面的函数,页面很多时路由类型依然很小:

use std::rc::Rc;

use nsh_core::ui::prelude::*;

/// 一个压入的页面:它的 key(在栈中唯一)和创建它的函数。
#[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
    }
}

/// 压入的页面,从底到顶:整个应用共用一个 signal。
#[derive(Default, Clone)]
pub struct Routes(pub Vec<Route>);

pub fn routes() -> Signal<Routes> {
    global::<Routes>()
}

// 在别处: routes().update(|r| r.0.push(Route { key: "settings".into(), page: Rc::new(settings) }));

在 Rust 中,nav_stack() 是一个视图,它的子元素就是它的页面,从底到顶;像任何视图一样设置样式(.flex_1())。在 TSX 中:

属性类型说明
stateNavStackState<R>要显示的路由。
children(route: R, index: Accessor<number>) => JSX.Element渲染一个路由;返回 <NavPage> 以设置它的标题栏。
style、class栈自身的样式(通常是 flex: 1)。

渲染函数在每个路由被压入时运行一次;route 是原始值,index() 是它当前的位置。返回值成为页面内容。返回 <NavPage> 可以设置标题栏;返回其他内容时会自动包在一个使用默认选项的页面里。

只要路由还在栈中,页面就保持挂载:返回时,上一个页面与离开时完全相同,包括滚动位置、输入框和 signal。路由离开栈时,页面的 cleanup 处理函数会运行。

页面(Rust 中是 nav_page(),TSX 中是 NavPage)包裹页面内容并携带它的标题栏。iOS 和 Android 显示系统标题栏,由标题和标题栏选项设置。桌面和 Web 显示页面的标题栏插槽(.header(..),TSX 的 header),即应用自己的视图,放在页面顶部:与页面同宽,高度由它自己的布局决定,内容位于它下方。像上面的例子那样两者都给,页面在每个平台上都显得自然。所有选项都是响应式的:改变 signal,标题栏随之更新。

在 Rust 中,下表的每个属性都是 nav_page() 上蛇形命名的方法:.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桌面和 Web:页面的标题栏,由应用绘制(标题、返回按钮、工具栏按钮)。
titlestringiOS 和 Android:系统标题栏的标题。
headerVisiblebooleantrueiOS 和 Android:显示系统标题栏。
headerTransparentbooleanfalseiOS 和 Android:内容延伸到标题栏下方。
headerStyle{ backgroundColor?, color?, tintColor? }iOS 和 Android:标题栏背景色、标题颜色(color)和按钮颜色(tintColor)。
headerClassstring用类名设置同样的颜色:bg-*、标题用 text-*、按钮用 tint-*;headerStyle 优先。
headerBackVisiblebooleantrueiOS 和 Android:显示返回按钮。
headerLeftJSX.ElementiOS 和 Android:标题栏开头的视图(替换返回按钮)。
headerRightJSX.ElementiOS 和 Android:标题栏末尾的视图。
headerTitleJSX.ElementiOS 和 Android:替换标题的视图。
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页面的内容视图。

标题栏中的项目就是普通视图,所以可以按压,也是响应式的:

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

不显示标题栏的页面四周仍有系统栏;用 SafeAreaView 给内容留出边距:

use nsh_base::{nav_page, safe_area_view};

nav_page()
    .header_visible(false)
    .child(safe_area_view().flex_1().p(px(16.0)) /* … */)

#标题栏下的安全区域

在页面内部,SafeAreaView 和页面的 inset(Rust 中是 Scope::insets,TSX 中是 safeAreaInsets())是系统标题栏下方、页面之内的 inset。使用透明标题栏时,内容从页面顶部开始,顶部 inset 等于标题栏高度,因此 SafeAreaView 能让文字避开标题栏,同时背景图铺满整个页面。使用大标题时,把滚动视图直接放在页面里,标题才能随滚动收起。在桌面和 Web 上,内容从页面的标题栏插槽下方开始。

#把用户留在当前页面:popOnBack

要在丢弃未保存的修改前先询问用户,就在有内容可能丢失时把 pop_on_back(TSX 的 popOnBack)设为 false,处理返回事件(.on_back_press(..),TSX 的 onBackPress),并在用户同意后再返回:

use nsh_base::{alert_dialog, nav_page};
use nsh_core::ui::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 editing")
                .destructive("Discard")
                .show()
                .await;
            if choice == Ok(Some("Discard".into())) {
                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 预测性返回动画),返回按钮仍会返回。

#对话框

对话框(Rust 中是 nsh_base 的 dialog(),TSX 中是 Dialog)在其他所有内容之上呈现内容,处于独立的原生窗口层:iOS 上是被 present 的视图控制器,Android 上是一个对话框窗口,桌面和 Web 上是覆盖整个窗口的一层。它的外观由你决定。在 Rust 中,对话框在视图中时(放在 when 里)就会呈现,它的子元素铺满它:把背景(backdrop)和弹出层(popup)画成视图。在 TSX 中,class 和 style 设置容纳子元素的弹出层,backdropClass 或 backdropStyle 在它后面加一层背景,用 open 控制它:

use nsh_base::dialog;
use nsh_core::ui::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 返回键、在表单上下滑。
                    .on_cancel(move || open.set(false))
                    .child(
                        // 背景和弹出层都是应用自己的视图。
                        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 报告关闭,所以在那里把对话框移出视图;应用自己画的背景用它自己的按压处理关闭对话框,如上所示。在 TSX 中,onOpenChange(false, reason) 通知你,所以在那里把 open 设为 false。要先确认,就把对应开关设为 false 并处理取消事件(on_cancel、onCancel,它报告每一次尝试),然后自己关闭对话框。在 iOS 上,.presentation(ModalPresentation::PageSheet) 或 FormSheet(presentation="pageSheet" 或 "formSheet")呈现一个保留后面内容可见的表单。

全部属性见 Dialog。在对话框内部,SafeAreaView 和 inset 都是对话框自己的。系统对话框(带操作的提示框、操作表)请用 nsh_base(TSX:nsh/base)的 AlertDialog。

#Android 返回键

在 Android 上,返回键(或返回手势)按以下顺序处理:

  1. 最上层的对话框:报告取消(on_cancel、onCancel),close_on_escape / closeOnEscape 为 true(默认)时关闭。
  2. 你的返回监听器(Rust 中是 on_back_press,TSX 中是 nsh/android 的 BackHandler),最近添加的最先执行。
  3. 最上层的栈:栈顶页面报告返回事件,pop_on_back / popOnBack 为 true 时弹出。
  4. 都没有处理:应用退出。

监听器处理了按键时返回 true,栈就不会弹出。注册了监听器时,Android 的返回手势不会预览弹出效果(手势结束后应用才给出答复),所以只在监听器可能处理按键时才注册它:

use nsh_core::ui::prelude::*;

pub fn editor() -> impl IntoElement {
    let selecting = signal(false);
    // 只在选择时监听:其他时候由栈返回,并带有手势的预览。
    effect(move || {
        if selecting.get() {
            on_back_press(move || {
                selecting.set(false);
                true
            });
        }
    });
    // …
    view()
}

在 effect 中创建的监听器一直有效,直到 effect 再次运行或它的组件消失;在 TSX 中,调用 BackHandler.onBackPress 返回的函数也会停止它。在 TSX 中,BackHandler.exitApp() 退出应用;在 iOS 上也可以导入 nsh/android,它的调用什么都不做,所以同一份代码可以在两个平台上运行。见平台特定代码。

#标签页与其他布局

标签栏是应用自己的界面,用基础元素绘制:一排可按压的视图加一个保存当前标签的 signal;用 when / <If>(或 TSX 中的函数子元素)渲染选中标签的内容。要在切换时保留每个标签的状态,给每个标签一个独立的栈和路由,并让它们都保持挂载,用 display: none 隐藏未选中的标签。

#相关内容