元素

View

View 是每个界面的基本构件:一个原生容器(iOS 和 Mac Catalyst 上是 UIView,Android 上是 ViewGroup),用 flexbox 或 grid 布局子元素,绘制背景、边框、阴影和渐变,并处理按压。按钮、卡片、列表行和整个界面都是 view。Rust 用 view() 创建,TSX 用从 nsh 导入的 <View>。

el-view.tsx
el-view.tsx运行中
import { mount, signal, Text, View } from "nsh";
import { button, buttonText, desktop, focusRing, Inspector, Panel, Stage, ui, wide } from "./palette";

function App() {
  const presses = signal(0);
  const size = signal("");
  const views = () => (
    <>
      <View
        onPress={() => presses.update((n) => n + 1)}
        {...button("accent", { align: "center" })}
      >
        <Text style={buttonText("accent")}>activeStyle opacity · pressed {presses()}×</Text>
      </View>

      <View
        onPress={() => presses.update((n) => n + 1)}
        activeStyle={{ backgroundColor: ui().border }}
        style={{ ...ui().card, backgroundImage: "none", borderRadius: 16, padding: 16 }}
        hoverStyle={{ opacity: 0.88 }}
        focusStyle={focusRing()}
      >
        <Text style={{ color: ui().text, fontSize: wide() ? 14 : 16 }}>activeStyle background ({desktop ? "click" : "tap"} and hold)</Text>
      </View>

      <View class="flex-row gap-3">
        <View class="flex-1 h-20 rounded-xl bg-[#ff6170] hover:bg-[#ff7d89] active:bg-[#d94d5b] items-center justify-center" focusStyle={focusRing()} onPress={() => {}}>
          <Text class="text-[#13160c] font-semibold">active:</Text>
        </View>
        <View class="flex-1 h-20 rounded-xl bg-[#45d49a] items-center justify-center shadow-lg">
          <Text class="text-[#0c0d0f] font-semibold">shadow-lg</Text>
        </View>
        <View
          class="flex-1 h-20 rounded-xl items-center justify-center"
          style={{ backgroundImage: `linear-gradient(135deg, ${ui().violet}, ${ui().info})` }}
        >
          <Text class="font-semibold" style={{ color: ui().page }}>gradient</Text>
        </View>
      </View>
    </>
  );
  const measured = (height?: number) => (
    <View
      onLayout={({ bounds }) => size.set(`${Math.round(bounds.size.width)} × ${Math.round(bounds.size.height)} pt`)}
      style={{
        flex: height === undefined ? 1 : undefined,
        height,
        borderRadius: 12,
        borderWidth: 2,
        borderStyle: "dashed",
        borderColor: ui().faint,
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      <Text style={{ color: ui().muted }}>onLayout: {size()}</Text>
    </View>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page }}>
      {() =>
        wide() ? (
          <View style={{ flex: 1, padding: 20, paddingTop: 24, gap: 14 }}>
            <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>view</Text>
            <Text style={{ color: ui().muted }}>Click and hold the views; resize the window to see onLayout</Text>
            <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
              <Stage width={480} gap={16}>
                {views()}
                {measured(120)}
              </Stage>
              <Inspector>
                <Panel title="onPress">
                  <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>pressed {presses()}×</Text>
                </Panel>
                <Panel title="onLayout" grow>
                  <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{size()}</Text>
                </Panel>
              </Inspector>
            </View>
          </View>
        ) : (
          <View style={{ flex: 1, width: "100%", maxWidth: 640, alignSelf: "center", padding: 20, paddingTop: 24, gap: 16 }}>
            <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>view</Text>
            {views()}
            {measured()}
          </View>
        )
      }
    </View>
  );
}

mount(App);
use nsh_core::prelude::*;

fn app() -> impl IntoElement {
    let liked = signal(false);
    view()
        .flex_1()
        .p_6()
        .gap_4()
        .bg(rgb(0xf6f8fa))
        .child(
            view()
                .flex_row()
                .items_center()
                .gap_3()
                .p_4()
                .rounded(px(16.0))
                .bg(white())
                .shadow_md()
                .child(view().size(px(40.0)).rounded_full().bg(rgb(0x8250df)))
                .child(text("一张卡片").flex_1().text_base())
                .child(
                    view()
                        .hit_slop(EdgeInsets::all(px(8.0)))
                        .active(|s| s.opacity(0.5))
                        .on_press(move || liked.update(|l| *l = !*l))
                        .child(text(move || if liked.get() { "♥" } else { "♡" }).text_size(px(22.0))),
                ),
        )
}

#布局

view 用 flexbox 布局子元素,默认值为:flexDirection: "column"、flexShrink: 0、alignContent: "flex-start"、position: "relative"。display: "grid"(Rust 中为 .grid())切换为 CSS grid,display: "block" 切换为块布局。尺寸单位是点。参见布局和样式参考。

display: "flex" 遵循 CSS:无论在样式中,还是作为 flex 类和 Rust 的 .flex(),它都是横向排列,除非设置了方向(flexDirection: "column"、flex-col、.flex_col())。未设置 display 的视图把子元素纵向排列。

#属性

View 有一个自己的属性 selectable(Rust 中为 .selectable(true)):视图中的文字作为一个选区一起选择(文字选择)。其余是通用属性:样式(style、class、classList;Rust 中为 Styled 方法)、disabled,按压属性(onPress、onLongPress、onPressDown、onPressUp、delayLongPress、hitSlop),状态样式(activeStyle、hoverStyle、focusStyle、disabledStyle、checkedStyle),onMouseEnter / onMouseMove / onMouseLeave、onContextMenu,onFileDragEnter / onFileDragLeave / onFileDrop,focusable、onLayout、ref、nativeID、testID,手势属性(pan、pinch、rotation 及其事件),触摸与响应者属性,无障碍属性,以及 ios / android / desktop。

#可选择的视图

设置 selectable 后,选区按阅读顺序跨越视图中的 Text 元素和 TextView;设置了 selectable={false} 的文字不参与,可选择视图中的另一个可选择视图属于外层视图。各平台的手势参见文字选择。

<View selectable class="gap-3 p-4">
  <Text>One selection runs from here…</Text>
  <Text>…to here.</Text>
</View>

#按压反馈

带有按压处理函数的 view 就是可按压的。它的反馈由应用决定:按下时的样式,在手指按下期间于原生侧生效,不经过应用代码。hoverStyle、focusStyle 和 disabledStyle 对其他状态同样如此:

// 整个视图淡化。
view().on_press(open).active(|s| s.opacity(0.6))

// 显示背景色;group_active 设置子元素的样式。
view()
    .group()
    .on_press(open)
    .active(|s| s.bg(rgb(0xe5e7eb)))
    .child(text("打开").group_active(|s| s.opacity(0.7)))

// 按下时改变任意样式。
view().on_press(open).bg(rgb(0x2563eb)).active(|s| s.bg(rgb(0x1e40af)).scale(0.95))

在 Android 上,android={{ ripple: { color: "#0000001f" } }}(Rust 中为 .ripple(Ripple { .. }))会加上 Material 水波纹(可用 borderless、radius 和 foreground 调整)。

disabled 会从原生视图上移除按压监听(既不响应按压,也没有反馈),并应用禁用状态的样式:

view()
    .on_press(submit)
    .disabled(move || !valid.get())
    .bg(rgb(0x16a34a))
    .disabled_style(|s| s.opacity(0.4))

#事件

事件Rust载荷触发时机
onPress.on_pressPressEvent(Rust 中无)在视图内完成一次点击。
onLongPress.on_long_pressPressEvent(Rust 中无)手指按住达到 delayLongPress(500 ms)。
onPressDown / onPressUp.on_press_down / .on_press_upPressEvent(Rust 中无)手指按下 / 抬起或移出。
onMouseEnter / onMouseLeave.on_mouse_enter / .on_mouse_leaveMouseEvent鼠标移到视图(或其内部的视图)上 / 离开视图或窗口(见悬停);onMouseLeave 的位置是鼠标最后所在之处。
onMouseMove.on_mouse_moveMouseEvent鼠标在视图(或其内部的视图)上移动,无论是否按着按键;或鼠标停在那里、没有按键按下时按下或松开了修饰键:position、pagePosition(Point)、modifiers 和 pressedButton("left"、"right"、"middle"、"back"、"forward" 或 null);每帧最多一次。
onContextMenu.on_context_menuContextMenuEvent辅助按钮在视图上按下(见上下文菜单)。
onFileDragEnter / onFileDragLeave.on_file_drag_enter / .on_file_drag_leave无本地文件拖入 / 离开当前桌面拖放目标。
onFileDrop.on_file_drop{ uris: string[] }(FileDropEvent)本地文件或目录被放到桌面拖放目标。
onLayout.on_layout{ bounds }(Bounds<Points>)相对父视图的 frame 发生变化。
onPanStart / onPanEnd.on_pan_start / .on_pan_endPanEvent通过 pan 绑定的拖动开始 / 结束;系统取消时 cancelled 为 true。
onPinchStart / onPinchEnd.on_pinch_start / .on_pinch_end{ scale, velocity }通过 pinch 绑定的捏合开始 / 结束。
onRotationStart / onRotationEnd.on_rotation_start / .on_rotation_end{ rotation, velocity }通过 rotation 绑定的旋转开始 / 结束。
onAccessibilityAction.on_accessibility_action{ actionName }执行了一个自定义无障碍操作。

触摸与响应者事件:参见通用属性。

#桌面文件拖放

在 AppKit、GTK、Win32 和 GPUI 上,带有 onFileDrop 的视图可以接收从其他应用拖入的本地文件和目录。onFileDragEnter 和 onFileDragLeave 跟踪当前拖放目标;onFileDrop 接收 { uris: string[] },顺序与原生文件列表一致。每项都是绝对 file: URL,空格和非 ASCII 字符使用 URL 转义。请解析 URL 获取文件系统路径。

let hovering = signal(false);
let files = signal(Vec::<String>::new());

view()
    .min_h(px(160.0))
    .p_6()
    .on_file_drag_enter(move || hovering.set(true))
    .on_file_drag_leave(move || hovering.set(false))
    .on_file_drop(move |event: &FileDropEvent| {
        files.set(event.uris.clone());
        hovering.set(false);
    })
    .child(text(move || if hovering.get() { "松开以接收文件" } else { "将文件拖到这里" }))
    .child(text(move || files.get().join("\n")))

最近的、已启用且监听 onFileDrop 的祖先视图接收文件,搜索范围止于模态根节点。disabled 会移除文件拖入、离开和放下的监听;离开、取消、完成或移除目标会清除当前拖放状态。原生侧根据已挂载的状态同步接受 Copy 操作。应用决定如何读取、打开或传输收到的文件。

#ref 方法

普通 view 的 ref 是 ViewRef:focus()、blur() 和 measure()。ref(Rust 中为 .element_ref(&handle))拿到它的节点,例如可以传给 setAccessibilityFocus(nsh 的无障碍 API),或者测量它(Rust 中为 handle.measure())。

#文本中的视图

放在文本里的 view 是行内附件:它坐落在基线上、随文字一起换行,并且可以单独响应按压。参见 Text。

#平台说明

  • boxShadow 在所有平台上都会绘制;android={{ elevation }} 则改用 Android 的 Material 阴影。在 Android 上阴影需要背景色(背景透明的视图不会投下阴影)。
  • view 上的 filter:iOS 支持 opacity()、不超过 1 的 brightness() 和 drop-shadow()(图片支持所有颜色滤镜和 blur());Android 从 API 31 起全部支持,低于该版本支持颜色滤镜。
  • backdropFilter: "blur(…)" 在 iOS 上是系统材质(近似效果),在 Android 上需要 API 31。
  • Android 绘制 translate、scale 和 rotate 变换;斜切(skew()、skewX()、skewY())在其他平台上生效。
  • borderCurve: "continuous"(.border_curve(BorderCurve::Continuous))在两个平台上都会绘制平滑过渡到边的圆角(如同 iOS 应用图标)。
  • cursor 样式在有指针的场合生效(Mac Catalyst、连接指针设备的 iPad、桌面平台)。