---
title: "UI 树与布局 — zenit Zig UI 文档"
description: "用节点构造器和 Flex 风格布局，建立 zenit 界面的空间模型。"
url: https://zenit.z.express/zh/docs/guide/ui-tree
language: zh-CN
alternate_en: https://zenit.z.express/docs/guide/ui-tree.md
alternate_es: https://zenit.z.express/es/docs/guide/ui-tree.md
alternate_ja: https://zenit.z.express/ja/docs/guide/ui-tree.md
alternate_ko: https://zenit.z.express/ko/docs/guide/ui-tree.md
alternate_fr: https://zenit.z.express/fr/docs/guide/ui-tree.md
alternate_de: https://zenit.z.express/de/docs/guide/ui-tree.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# UI 树与布局

用节点构造器和 Flex 风格布局，建立 zenit 界面的空间模型。

## 心智模型

挂载函数创建一棵真实、常驻的 `*ui.Node` 树，只运行一次。容器决定子节点如何排列，叶子节点承载文本、图片或图标。之后每一帧，框架只从「脏」的节点重新进入管线：布局、生成绘制项、交给 Metal。

FRAME PIPELINE

每个样式字段都有一个脏级别。改背景只重新生成绘制项；改宽度会让父节点也重排。树干净且没有动画时，整帧 GPU 提交会被跳过。

## 常用构造器

| 构造器 | 用途 |
| --- | --- |
| `ui.box` | 通用容器，完整控制方向、尺寸、间距、背景。默认 direction 为 column，宽高为 fit |
| `ui.hstack / ui.vstack` | 固定 direction 为 row / column 的 box |
| `ui.text / ui.textFmt` | 静态文本 / 订阅 Signal 的格式化文本：`textFmt(cx, scope, fmt, .{signals}, props)` |
| `ui.icon / ui.iconTint / ui.svg / ui.image` | 矢量图标、着色图标、SVG 与位图纹理 |
| `ui.grid` | 按列 / 行轨道排布的网格容器 |
| `ui.spacer` | 宽高都为 grow 的弹性空白 |
| `ui.clickable` | 给任意节点挂上 `on_click` 回调，返回同一个节点 |
| `ui.boxStyled / hstackStyled / vstackStyled / textStyled` | 接收具名样式函数，换主题时自动重放，见[样式与主题](https://zenit.z.express/zh/docs/guide/styling) |

> TIP
> 
> **先找现成组件。** 按钮、输入框、列表等交互控件在 `ui.widgets` 里已经带好状态、焦点与键盘语义；构造器用来搭它们之间的结构。浏览 [组件站](https://zenit.z.express/zh/components)。

## 布局示例

容器先创建，再用 `appendChild` 挂上子节点。样式值优先来自 `cx.tokens`，这样换主题和统一调整间距时不用逐个改字面量。

`card.zig`

```zig
const card = try ui.vstack(cx, .{
    .width = .fixed(360),
    .gap = cx.tokens.space._4,
    .padding = ui.Padding.all(cx.tokens.space._6),
    .background = cx.tokens.color.bg_secondary,
    .corner_radius = cx.tokens.radius.xl,
    .align_items = .stretch,
}, .{});

const header = try ui.hstack(cx, .{
    .gap = cx.tokens.space._2,
    .align_items = .center,
}, .{});

try header.appendChild(cx.allocator, try ui.iconTint(
    cx,
    ui.icons.star,
    cx.tokens.color.accent,
    .{ .width = .fixed(18), .height = .fixed(18) },
));
try header.appendChild(cx.allocator, try ui.text(cx, "Overview", .{}));
try header.appendChild(cx.allocator, try ui.spacer(cx));
try card.appendChild(cx.allocator, header);
```

子节点也可以在构造时以 tuple 传入；`ui.Padding.symmetric(v, h)` 分别设置上下与左右内边距。

`row.zig`

```zig
// Children can also be passed as a tuple at construction time.
const row = try ui.hstack(cx, .{
    .width = .fill(),
    .padding = ui.Padding.symmetric(cx.tokens.space._2, cx.tokens.space._4),
    .gap = cx.tokens.space._3,
}, .{
    try ui.text(cx, "Name", .{}),
    try ui.spacer(cx),
    try ui.text(cx, "42", .{ .color = cx.tokens.color.fg_secondary }),
});

// Any node can take a click handler.
// (model: *Model from cx.bindState; Model.select is fn (*Model) void)
_ = ui.clickable(row, cx.on(Model, model, Model.select));
```

[Video](https://zenit.z.express/media/stories/stack.mp4?v=f9618b3adf)

真实 [Stack](https://zenit.z.express/zh/components/stack) Story：水平、垂直与嵌套容器最终落到同一套 rect、gap、align 与 sizing 规则。

## 尺寸与坐标

宽高是一个 `ui.Sizing` 联合体，四个成员各有一个简写：

| 简写 | 成员 | 含义 |
| --- | --- | --- |
| `.fixed(120)` | `.{ .px = 120 }` | 固定像素 |
| `.fill()` | `.{ .grow = .{} }` | 分配剩余空间；`grow` 带 `min` / `max` 载荷，例如 `.{ .grow = .{ .min = 200 } }` |
| `.pct(50)` | `.{ .percent = 50 }` | 父节点 content box（已扣除 padding）的百分比，取值 0–100 |
| — | `.{ .fit = .{} }` | 由内容决定，box 的默认值，同样可带 min / max |

SIZING

父节点宽度变化时：.fixed 纹丝不动，.pct 跟随 content box 等比变化，.fill 拿走扣除固定尺寸、百分比与 gap 后剩下的全部空间。

布局结果不存在节点字段里。`node.rectFromWorldOrFallback()` 返回上一次布局得到的、相对父节点左上角的矩形；需要窗口坐标时用 `node.globalRect()`，它会沿祖先链累加位置、translate 与 sticky 偏移。

```zig
// Parent-relative rect from the last layout pass.
const local = node.rectFromWorldOrFallback();

// Window coordinates: accumulates ancestors, translate and sticky offsets.
const screen = node.globalRect();
std.log.info("{d}x{d} at ({d}, {d})", .{ screen.w, screen.h, screen.x, screen.y });
_ = local;
```

[Video](https://zenit.z.express/media/stories/layoutbox.mp4?v=4d3c4ba31b)

LayoutBox Story：盒模型、百分比、绝对定位、Flex / Grid 与裁剪，每组都带 padding box 与 content box 参照线。

> TIP
> 
> **先检查父节点。** 对齐或点击区域异常时，问题通常来自父容器的 padding、gap、direction 或 sizing，而不是叶子节点。开发阶段可以用 `ui.devtools.overlay.attach(cx, scope, root, .{})` 打开悬停检查层，直接看到每个节点的矩形，见[调试与检查](https://zenit.z.express/zh/docs/advanced/devtools)。

## 正确更新节点

直接修改节点时，要让框架知道哪一段管线需要重跑。优先用 `setStyle`：它在编译期根据字段选出脏级别，不需要手动 `markLayoutDirty` / `markRenderDirty`。

| 级别 | 典型字段 | 下一帧 |
| --- | --- | --- |
| `.sizing` | `width, height` | 自身与父节点重新布局 |
| `.layout` | `padding, margin, gap, direction, justify, align_items, min/max_*` | 重新布局此节点 |
| `.interaction` | `opacity, translate_*, corner_radius, border, z_index, cursor` | 更新命中索引并重绘 |
| `.render` | `background, shadow, gradient, outline, text_color` | 只重新生成绘制项 |
| `.none` | `tab_index, layout_isolation` | 不触发帧工作 |

`update.zig`

```zig
// setStyle picks the dirty level from the field at compile time.
node.setStyle(cx.allocator, .width, .fixed(240)); // sizing
node.setStyle(cx.allocator, .gap, 12);             // layout
node.setStyle(cx.allocator, .background, next);    // render

// Low-frequency fields live in StyleExt and need a real allocator.
node.setStyle(cx.allocator, .z_index, 10);

// Replace text: dupes the content, frees the previous owned copy,
// and re-measures only if the content actually changed.
try node.setTextContent(cx.allocator, "Updated");

// Hide without unmounting: display:none leaves layout, paint,
// hit-testing, Tab order and the a11y tree; state stays alive.
panel.setDisplay(.none);
panel.setDisplay(.flex); // back, same nodes

// Recolor an icon node whether it is icon-table or image (svgTint) backed.
_ = icon.setTint(cx.tokens.color.accent); // false: node has neither
```

`setText` 会比较新旧文本签名：影响测量时标记 sizing，只影响外观时标记 render，完全相同则什么也不做。所以更新文本后不需要再调用 `markRenderDirty`。

要临时隐藏一段子树，用 `node.setDisplay(.none)`（或在 BoxStyle 里写 `.display = .none`）：节点连同子树不占空间、不计 gap、不绘制，也退出命中测试、Tab 遍历与无障碍树；节点和状态保持存活，`.flex` 即可恢复，不必卸载重建。`setDisplay` 会自己标脏本节点与父节点的布局。只把 opacity 设为 0 虽然也不再命中，但仍然占位、仍在 Tab 顺序里。

> WARNING
> 
> **换文本用 setTextContent。** 不要 `getText()` 后直接把 `content` 指向一个临时字符串再 `setText`：`setText` 不复制内容，旧内容若是 owned 的，`owned` 标记还会跟着拷过来，与新 slice 的所有权混淆。`setTextContent` 会复制新内容、标记 owned，并由框架释放上一份。自己构造 `TextProps` 时用 `props.setContent(cx.allocator, src)`：不超过 16 字节走内联缓冲、零分配，更长的复制为 owned；`setInlineContent` 超过 16 字节会返回 `error.InlineContentTooLong`，不再静默截断。

> NOTE
> 
> **低频样式需要 allocator。** `shadow`、`z_index`、`corner_radius`、`min_width` 等字段存放在按需分配的 StyleExt 里。给这些字段传字面量 `null` 会编译失败；不确定时传 `cx.allocator`。
