元素

元素

Native Shell 用一小组元素构建每个界面:View、Text、Image 等原语,以及 Button、Dialog、NavStack 等基础组件。每个原语都对应一个原生视图(iOS 和 Mac Catalyst 上是 UIView 子类,Android 上是 View,桌面上是 AppKit、GTK 或 Win32 视图),由 NSH 运行时创建并布局。应用显示的一切都由它们组成:应用的按钮、卡片、列表行和页面都是它自己的组件,用这些元素绘制、由应用设置样式,因此应用的外观完全由应用决定。

这些页面上的每个示例都同时给出 Rust 和 TSX:选择一个标签页,页面上的所有示例都会随之切换。

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

fn app() -> impl IntoElement {
    let count = signal(0_u32);
    view()
        .flex_1()
        .items_center()
        .justify_center()
        .gap_3()
        .child(text(move || format!("已点击 {} 次", count.get())).text_2xl())
        .child(
            view()
                .rounded_lg()
                .bg(rgb(0x2563eb))
                .px_4()
                .py_2()
                .active(|s| s.opacity(0.6))
                .on_press(move || count.update(|n| *n += 1))
                .child(text("点我").text_color(white())),
        )
}

在 Rust 中,原语是 nsh_core::ui::prelude 中的函数(view()、text(..)、image(..)),基础组件来自 nsh_base crate。样式是有类型的方法(.flex_1()、.gap_3()、.px(px(20.0))、.bg(..)),状态样式接收一个闭包(.active(|s| s.opacity(0.6))),监听器是接收事件的闭包(.on_press(move || ..)、.on_change(move |text: &str| ..)),.child(..) 添加子元素。一段 UI 就是一个返回 impl IntoElement 的普通函数,只运行一次;它在闭包里读取的 signal(text(move || ..)、.bg(move || ..))会更新所绑定的内容。参见用 Rust 构建。

在 TSX 中,每个标签都使用 PascalCase,并指向作用域中的绑定:原语从 nsh 导入,基础组件从 nsh/base 导入。只有 <If> 和 <Each> 是无需导入的全局组件。<view> 这样的小写标签会导致构建错误,因为 TypeScript 只能对照每个名称一个的全局类型来检查它,List、Each 这类泛型组件就无法推断条目类型。编译器通过导入识别这些元素并就地创建原生视图,所以导入它们在运行时没有任何开销。参见 JSX 与组件。

import { Canvas, Image, Input, List, Overlay, Scroll, Text, Video, View, WebView } from "nsh";
import { Button, Dialog, Menu, NavPage, NavStack, Popover, SafeAreaView, Sheet, Tooltip } from "nsh/base";

#原语

TSX 从 nsh 导入,Rust 从 nsh_core::ui::prelude 导入。

元素Rust原生视图(iOS / Android)页面
<View>view()UIView / ViewGroupView
<Text>text(content)CoreText 视图 / StaticLayout 视图Text
<Image>image(src)带图片图层的视图 / 图片视图Image
<Scroll>scroll()UIScrollView / ScrollViewScroll
<Input>input()UITextField 或 UITextView / EditTextInput
<List>virtual_list(count, |at| row)复用单元格的 UIScrollView / ScrollViewList
<Canvas>canvas(painter)回放 2D 绘制的视图Canvas
<WebView>web_view(url)、web_view_html(html, base)WKWebView / android.webkit.WebViewWebView
<Video>video(url)AVPlayerLayer / TextureView 上的 MediaPlayerVideo
<Overlay>overlay()窗口(或所在对话框)之上的视图,相对锚点放置Overlay
<If>、<Each>when(..)、each(..)、dynamic(..)控制流:它们选出的元素控制流
<InputAccessoryView>(nsh/ios)input_accessory_view(id)iOS 上键盘上方的工具栏nsh/ios

#Base

TSX 从 nsh/base 导入,Rust 从 nsh_base crate 导入。它们负责行为、状态、焦点和无障碍,外观由应用决定。

组件Rust作用页面
<Button>nsh_base::button()带有状态和角色的可按压控件Button
<Checkbox>nsh_base::checkbox()带指示器的勾选 / 未勾选 / 半选控件Checkbox
<Radio>、<RadioGroup>nsh_base::radio()、radio_group()一组只能选一个的选项中的一项,以及这一组Radio
<Tab>、<Tabs>nsh_base::tab()、tabs()标签列表中的一个标签页,以及这个列表Tabs
<Switch>nsh_base::switch()带轨道和滑块的开 / 关控件Switch
<Dialog>nsh_base::dialog()把子元素呈现在其他一切之上Dialog
<NavStack>、<NavPage>nsh_base::nav_stack()、nsh_base::nav_page()原生导航:带有平台标题栏和返回手势的页面栈NavStack
<SafeAreaView>nsh_base::safe_area_view()让内容避开刘海、系统栏和主屏幕指示条SafeAreaView
<KeyboardAvoidingView>nsh_base::keyboard_avoiding_view()让内容保持在软键盘之上KeyboardAvoidingView
AlertDialognsh_base::alert_dialog()系统的提示框和操作表AlertDialog
<TextView>nsh_base::text_view()用文本和视图显示 Markdown 或 HTML 文档TextView
<SelectionArea>nsh_base::selection_area()让其中的文字一起选择SelectionArea
<Popover>nsh_base::popover()锚定在触发元素上、显示在一切之上的内容Popover
<Menu>、<ContextMenu>、<MenuItem>、<MenuSeparator>、<Submenu>nsh_base::menu()、…菜单:桌面上是带级联子菜单的弹出层,手机上是 sheetMenu
<Tooltip>nsh_base::tooltip()悬停或手指按住时显示在触发元素旁的提示Tooltip
<Sheet>nsh_base::sheet()位于屏幕一侧边缘、覆盖在背景层之上的面板Sheet

#通用属性

所有原语都接受下列属性(TSX 中为 CommonProps 类型;Rust 中为 Styled、Interactive 和 Accessible trait)。各元素页面只列出该元素自己的属性。Rust 一列给出对应的构建方法。

#样式

属性Rust类型说明
style有类型的 Styled 方法;.inline_style(style)StyleProp<ViewStyle>(<Text> 和 <Input> 为 TextStyle,<Image> 为 ImageStyle)内联样式,或从左到右合并的样式数组(假值项会被跳过;数组可以嵌套,所以 style={[base, props.style]} 中的 props.style 本身也可以是数组)。优先级高于 class。参见样式参考。
class有类型的 Styled 方法(.flex_row().gap_2().p_4())stringTailwind 类名,在原生侧解析("flex-row gap-2 p-4 dark:bg-zinc-900")。后面的类覆盖前面的类。参见 Tailwind。
classList.style_when(when, |s| ..)Record<string, boolean | null | undefined>值为真时才应用的类({ "opacity-50": busy() })。
disabled.disabled(..)boolean忽略按压(也没有按压反馈),应用禁用样式,禁用输入框等控件,屏幕阅读器也会读出它已停用(accessibilityState.disabled)。

#按压与反馈

按压处理在原生侧完成:由平台识别按压并应用应用设定的按压样式,应用代码只在事后收到通知。只有最内层可按压的视图会响应;在滚动视图内,按下会等待约 130 ms,让滚动有机会先开始。

属性Rust类型默认值说明
onPress.on_press(..)() => void在视图内完成一次点击。
onLongPress.on_long_press(..)() => void手指按住达到 delayLongPress。
onPressDown.on_press_down(..)() => void手指在视图上按下。
onPressUp.on_press_up(..)() => void手指抬起或移出。
delayLongPress.long_press_delay(ms)number500按压变为长按前的毫秒数。
hitSlop.hit_slop(EdgeInsets::all(px(8.0)))number | Partial<Edges>把触摸区域扩展到边界之外,单位为点。
pointerEvents.pointer_events(PointerEvents::BoxNone)"auto" | "none" | "box-none" | "box-only""auto"视图及其子元素接收哪些触摸:"none" 都不接收,"box-none" 只有子元素接收,"box-only" 只有视图本身接收。

按压反馈由应用决定:视图被按下时,activeStyle(或 active: 类,Rust 中为 .active(..))在原生侧生效,无需经过应用代码(见下文)。

#状态样式

视图处于某种状态时,在其自身样式之上、由原生侧应用的样式。每一个都是某个 Tailwind 变体的对象形式。后面的状态优先(selected、checked 与 indeterminate,然后是 hover、focus、active);disabledStyle 优先于所有状态,视图被禁用时其他状态样式不生效。

属性Rust类变体生效时机
activeStyle.active(|s| ..)active:被按下时。
hoverStyle.hover(|s| ..)hover:指针悬停在视图上时。
focusStyle.focus(|s| ..)focus:视图获得焦点时。
disabledStyle.disabled_style(|s| ..)disabled:disabled 时。
checkedStyle.checked(|s| ..)、.checked_style(style)checked:accessibilityState.checked 为 true 时(复选框、开关、单选框;"mixed" 不算勾选)。
indeterminateStyle.indeterminate(|s| ..)、.indeterminate_style(style)indeterminate:accessibilityState.checked 为 "mixed" 时(既非勾选也非未勾选的复选框)。
.selected(|s| ..)selected:accessibilityState.selected 为 true 时(选中的 Button、标签页)。
view()
    .rounded_lg()
    .bg(rgb(0x2563eb))
    .px_4()
    .py_2()
    .active(|s| s.opacity(0.6))
    .hover(|s| s.bg(rgb(0x1d4ed8)))
    .on_press(save)
    .child(text("保存").text_color(white()))

#悬停

应用可以得知鼠标移到视图上、在视图上移动和离开视图:macOS、Windows、Linux、GPUI 后端和桌面浏览器都支持;iOS 上是 iPad 的指针或 Mac 的鼠标,Android 上是鼠标或手写笔。悬停样式(hover:、.hover(..))在同样的地方为悬停的视图设置样式。

属性Rust类型说明
onHoverIn.on_hover_in(..)() => void鼠标移到视图或其内部的视图上。
onHoverMove.on_hover_move(..)(e: HoverEvent) => void没有按下按键时,鼠标在视图(或其内部的视图)上移动。
onHoverOut.on_hover_out(..)() => void鼠标离开视图及其内部的视图,或离开窗口。

与 CSS 的 :hover 一样,鼠标在视图上或在其内部任何内容上时,视图都算作被悬停,因此鼠标下方视图外层的视图也会收到 onHoverIn(由外向内;onHoverOut 由内向外)。事件在发生之后才通知应用,触摸从不触发;按着按键移动属于触摸(onTouchMove、响应者系统)。从鼠标下方移除的视图会收到 onHoverOut,在 Linux 和 Windows 上要等到鼠标下一次移动。

HoverEvent 像 GPUI 的 MouseMoveEvent 一样给出鼠标的位置:position 是在视图中的位置(一个 Point,单位为点,已去掉视图的变换),pagePosition 是在视图所在根视图中的位置(与触摸相同),以及按住的 modifiers(control、alt、shift、platform:Apple 平台上是 Command,其他平台是 Windows 键);Rust 中为 position、page_position 和 modifiers。移动事件每帧最多一次,总是最新的那次,并且一定在随后的 onHoverOut 之前到达,因此处理函数可以在每次移动时重绘:

// 图表上的十字线。
let at = signal(None::<Points>);

view()
    .h(px(200.0))
    .on_hover_move(move |e: &HoverEvent| at.set(Some(e.position.x)))
    .on_hover_out(move || at.set(None))
    .child(when(move || at.get().is_some(), move || {
        view()
            .absolute()
            .left(move || at.get().unwrap_or_default())
            .top_0()
            .bottom_0()
            .w(px(1.0))
            .bg(rgb(0x888888))
    }))

#工具提示

tooltip 为任意元素加上提示:在桌面平台(macOS、Windows、Linux、GPUI 后端和桌面浏览器)上,指针在元素上停留半秒后显示在它上方,指针离开或按下时隐藏。手机和平板不显示。提示从不接收指针或按压,所以元素的行为与没有它时完全一样。

属性Rust类型说明
tooltip.tooltip(..)string | JSX.Element文字,显示在用主题 popover 令牌(bg-popover、text-popover-foreground、border-border,外观由主题决定)绘制的小气泡中;或一个元素,按原样显示(nsh/base 的 Tooltip 就是工具提示本身)。
view().on_press(save).tooltip("保存文档").child(text("💾"))

需要自己的外观、位置、延迟或事件时,把 nsh/base 的 Tooltip 作为 tooltip 传入,与 GPUI 的 .tooltip(..) 相同。

#上下文菜单

onContextMenu 告诉视图:鼠标的辅助按钮在它上面按下了——右键单击、macOS 上的 Control 单击、触控板的双指单击、iPad 指针的辅助点按;在浏览器里还有键盘的菜单键。在这里打开应用自己的菜单;在 TSX 中,Menu.popup 显示系统菜单:

// 应用自己的菜单,放在点击的位置。
let menu_at = signal(None::<Point<Points>>);

view()
    .on_context_menu(move |e: &ContextMenuEvent| menu_at.set(Some(e.page_position)))
    .child(text(row.name.clone()))
属性Rust类型说明
onContextMenu.on_context_menu(..)(e: ContextMenuEvent) => void辅助按钮在视图或其内部的视图上按下。

ContextMenuEvent 包含视图中的 position、根视图中的 pagePosition(Menu.popup 放置菜单的位置:Menu.popup(items, e.pagePosition))以及 modifiers;Rust 中为 position、page_position 和 modifiers。从指针下方的视图向外,最近的一个监听者收到事件。如果比它更近的是文本输入框,或带有编辑菜单的可选中文本(Windows、GPUI 后端、浏览器),则显示它们自己的菜单。平台在按钮按下时根据已挂载的内容做出决定,因此处理函数在事后得到通知,不需要取消任何东西。disabled 的视图把这次点击留给外层最近的、未停用的监听者。在触摸屏上用长按打开菜单:用同一个函数处理 onLongPress。

#键盘焦点

在 macOS 和 Web 上,Tab 在可按压的视图(以及输入框)之间移动键盘焦点,按 Return 或空格会按下获得焦点的视图,焦点样式(focus:、.focus(..))为它设置样式。点击会按下视图但不让它获得焦点,所以焦点样式只在键盘聚焦时生效。

属性Rust类型默认值说明
focusable.focusable(..)booleantrue可按压的视图是否是 Tab 的停靠点;false 把它留给指针(关闭浮层的背景层、用方向键切换的列表行)。
view()
    .rounded_lg()
    .p_3()
    .focus(|s| s.bg(rgb(0xe0f2fe)))
    .on_press(open)
    .child(text("打开"))

// 浮层的背景层:点击关闭浮层,Tab 直接越过它进入内容。
view().absolute().inset_0().focusable(false).on_press(close)

#布局与标识

属性Rust类型说明
onLayout.on_layout(|frame: &Bounds<Points>| ..)(bounds: Bounds) => void每次布局改变视图位置尺寸后,给出相对父视图的 frame { origin, size }。
ref.element_ref(&handle)YNode | (node: YNode) => void原生节点(参见 ref 方法)。ref={el} 赋值给变量,ref={fn} 调用函数。
nativeID.native_id(id)string为视图命名,供其他元素引用,例如输入框引用的 InputAccessoryView。
testID.test_id(id)stringUI 测试使用的标识(在 Android 上是视图的 resource id)。
key.key(key)string元素在兄弟元素之间的标识。

#驱动动画值的手势

这些属性把手势绑定到原生动画值,值在 UI 线程上变化,每帧都不经过应用代码。参见触摸与手势。

属性Rust类型说明
pan.pan_values(Some(&x), Some(&y)){ x?: AnimatedValue; y?: AnimatedValue }拖动驱动这些值(单位为点)。
onPanStart.on_pan_start(..)(e: PanEvent) => void拖动开始。
onPanEnd.on_pan_end(..)(e: PanEvent) => void拖动结束,带有松手时的速度(例如用来启动 decay)。
pinch.pinch_value(&scale){ scale?: AnimatedValue }双指捏合驱动 scale。
onPinchStart / onPinchEnd.on_pinch_start(..) / .on_pinch_end(..)(e: { scale: number; velocity: number }) => void捏合开始 / 结束。
rotation.rotation_value(&angle){ angle?: AnimatedValue }双指旋转驱动 angle(角度)。
onRotationStart / onRotationEnd.on_rotation_start(..) / .on_rotation_end(..)(e: { rotation: number; velocity: number }) => void旋转开始 / 结束。

PanEvent 为 { translationX, translationY, velocityX, velocityY, valueX?, valueY? }(Rust 中为 translation、velocity_x、velocity_y、value_x、value_y):自开始以来的位移、以点每秒计的速度,以及此刻所绑定值的数值(拖动可能在动画中途抓住一个值)。

#原始触摸与响应者系统

对于上面属性覆盖不到的手势,每个元素都支持触摸与响应者属性: onTouchStart、onTouchMove、onTouchEnd、onTouchCancel(以及对应的 …Capture 形式)、 onStartShouldSetResponder、onMoveShouldSetResponder(以及 …Capture)、onResponderGrant、onResponderReject、 onResponderStart、onResponderMove、onResponderEnd、onResponderRelease、onResponderTerminate 和 onResponderTerminationRequest。每个处理函数都会收到一个 GestureResponderEvent ({ identifier, locationX, locationY, pageX, pageY, target, timestamp, touches, changedTouches, nativeEvent, stopPropagation() });…ShouldSetResponder 回调还会收到 gestureState(dx、dy、vx、vy 等)。 onResponderGrant 返回 true 可阻止原生滚动视图接管触摸。panActivation ({ activeOffsetX, activeOffsetY, failOffsetX, failOffsetY })让视图只在平台激活它的拖动后才成为响应者, 滚动视图里的拖动因此把另一个轴留给滚动视图(触摸与手势)。 nsh/gesture 中的 PanResponder 就构建在它们之上。在 Rust 中,.on_touch(..) 接收每次触摸(TouchEvent),.claim_touches(..) 决定视图何时接管触摸,.on_responder_grant(..)、.on_responder_move(..)、.on_responder_release(..) 等跟随响应者状态。

#无障碍

属性Rust类型说明
accessible.accessible(..)boolean视图对辅助技术而言是一个整体元素,子元素不再单独暴露。
accessibilityLabel.label(..)string屏幕阅读器朗读的内容。没有标签的视图会朗读其中的文本。
accessibilityHint.hint(..)string激活该元素会发生什么。
role.role(AccessibilityRole::Button)AccessibilityRole元素是什么("button"、"header"、"link"、"switch" 等)。
accessibilityState.accessibility_state(..){ disabled?, selected?, checked?, busy?, expanded? }辅助技术报告的状态(checked 可以是 "mixed")。
accessibilityValue{ min?, max?, now?, text? }范围类或文本类元素的值。
accessibilityElementsHiddenboolean对辅助技术隐藏该视图及其子树。
importantForAccessibility"auto" | "yes" | "no" | "no-hide-descendants"是否暴露该视图。
accessibilityActions{ name: string; label?: string }[]提供给辅助技术的自定义操作。
onAccessibilityAction.on_accessibility_action(..)(e: { actionName: string }) => void用户触发了 accessibilityActions 中的某个操作。

参见无障碍。

#平台专属属性

在 TSX 中,只在某个平台上存在的属性放在以该平台命名的对象里,这样共享属性在各平台上保持一致。只有当前平台的对象会被发送,其他的会被忽略。Rust 的构建器以方法的形式提供它们,在各自的平台上生效。

属性说明
ios={{…}}iOS 和 Mac Catalyst。<Input>、Dialog 和 WebView 有各自的键。
android={{…}}Android。所有元素都支持 ripple、elevation 和 accessibilityLiveRegion(见下表)。
desktop={{…}}桌面平台和桌面浏览器;在移动端被忽略。所有元素都支持 windowDrag(见下表)。
web={{…}}浏览器;在其他平台被忽略。
android 键Rust类型说明
ripple.ripple(Ripple { .. }){ color?, borderless?, radius?, foreground? }按下时的 Material 水波纹。
elevation.elevation(px(2.0))number根据视图形状生成的 Material 阴影(单位为点)。
accessibilityLiveRegion"none" | "polite" | "assertive"视图变化如何播报给 TalkBack。
desktop 键Rust类型说明
windowDrag.window_drag(..)boolean按住视图拖动窗口,像按住标题栏一样;false 让可拖动视图中的一部分保持可点击。见自定义标题栏。
use nsh_core::Ripple;

view()
    .rounded_lg()
    .bg(white())
    .p_4()
    .elevation(px(2.0))
    .ripple(Ripple { color: Some(rgba(0x00000022).into()), ..Ripple::default() })
    .on_press(save)
    .child(text("保存"))

参见平台相关代码。

#ref 方法

在 TSX 中 ref 收到一个 YNode;在 Rust 中,element_ref() 创建一个 ElementRef 句柄,由 .element_ref(&handle) 附加到元素上。它们的方法向原生视图发送命令,与同一轮中先前的改动按顺序执行。各元素页面会说明哪些方法适用。

方法Rust适用于说明
focus() / blur()focus() / blur()<Input>获取焦点(弹出键盘)/ 失去焦点。
setSelection(start, end?)set_selection(start, end)<Input>选中 start..end(UTF-16 单位);省略 end 时为光标。
scrollTo({ x?, y?, animated? })scroll_to(point, animated)<Scroll>、List滚动到某个偏移量。
zoomTo({ scale, x?, y?, animated? })zoom_to(scale, center, animated)可缩放的 <Scroll>围绕内容中的某点缩放到 scale。
scrollToOffset({ offset, animated? })scroll_to_offset(offset, animated)List沿列表方向滚动到某个偏移量。
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })scroll_to_index(index, position, offset, animated)List把某一项滚动到可见区域。
scrollToEnd({ animated? })scroll_to_end(animated)List滚动到内容末尾。
reload()、goBack()、goForward()、stopLoading()、injectJavaScript(script)、postMessage(data)reload()、go_back()、go_forward()、stop_loading()、inject_java_script(..)、post_message(..)WebView控制页面。
seek(seconds)seek(seconds)Video跳转到某个时间点。
command(name, args?)command(name, args)原生组件调用已注册原生组件的命令(nsh/native)。
let field = element_ref();

view()
    .on_press(move || field.focus())
    .child(input().element_ref(&field).placeholder("点击这一行开始输入"))

#为什么处理函数不会阻塞 UI

主线程从不等待应用代码。当系统需要立即得到答复时(这段文本能否修改、返回键能否返回上一页、这个 sheet 能否关闭),NSH 会根据事先设置好的属性(maxLength、popOnBack、closeOnSwipe、pointerEvents 等)在原生侧作答,事件随后才送达应用。正因如此,这类行为都是属性,而不是处理函数的返回值。