---
title: "UI 트리와 레이아웃 — zenit Zig UI 문서"
description: "노드 생성자와 Flex 스타일 레이아웃으로 zenit의 공간 모델을 구성합니다."
url: https://zenit.z.express/ko/docs/guide/ui-tree
language: ko
alternate_en: https://zenit.z.express/docs/guide/ui-tree.md
alternate_zh: https://zenit.z.express/zh/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_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` 트리를 한 번만 만듭니다. 컨테이너는 자식의 배치 방식을 정하고, 리프는 텍스트, 이미지, 아이콘을 담습니다. 이후 매 프레임마다 프레임워크는 dirty 노드에서만 파이프라인에 다시 진입합니다. 레이아웃, display item으로 페인트, Metal로 전달의 순서입니다.

FRAME PIPELINE

모든 스타일 필드에는 dirty 레벨이 있습니다. 배경 변경은 페인트만 다시 생성하고, 너비 변경은 부모까지 다시 레이아웃합니다. 트리가 깨끗하고 실행 중인 애니메이션이 없으면 GPU 제출 전체를 건너뜁니다.

## 생성자

| 생성자 | 용도 |
| --- | --- |
| `ui.box` | 방향, 크기, 간격, 배경을 완전히 제어하는 범용 컨테이너. 기본값은 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/ko/docs/guide/styling) 참고 |

> TIP
> 
> **먼저 위젯을 찾아보세요.** 버튼, 입력 필드, 목록 등 `ui.widgets`의 컨트롤은 이미 상태, 포커스, 키보드 의미를 갖추고 있습니다. 생성자는 그 사이의 구조를 만드는 데 씁니다. [컴포넌트 사이트](https://zenit.z.express/ko/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/ko/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, .{})`로 호버 인스펙터를 켜서 각 노드의 사각형을 바로 볼 수 있습니다. [DevTools](https://zenit.z.express/ko/docs/advanced/devtools)를 참고하세요.

## 노드 업데이트

노드를 직접 바꿀 때는 파이프라인의 어느 부분을 다시 실행할지 프레임워크가 알아야 합니다. `setStyle`을 우선 사용하세요. 컴파일 시점에 필드로부터 dirty 레벨을 고르므로 수동 `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-dirty, 외형만 바뀌면 render-dirty, 동일하면 아무것도 하지 않습니다. 따라서 텍스트를 업데이트한 뒤 `markRenderDirty`를 호출할 필요가 없습니다.

서브트리를 잠시 숨기려면 `node.setDisplay(.none)`를 호출하세요(또는 BoxStyle에 `.display = .none` 설정). 노드와 서브트리는 공간을 차지하지 않고 gap에도 포함되지 않으며 그려지지 않고, 히트 테스트, Tab 순회, 접근성 트리에서도 빠집니다. 노드와 상태는 살아 있으며 `.flex`로 되돌릴 수 있어 언마운트 후 재구성할 필요가 없습니다. `setDisplay`는 노드와 부모의 레이아웃을 스스로 dirty로 표시합니다. 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` allocator를 넘기면 컴파일 오류가 납니다. 확실하지 않다면 `cx.allocator`를 넘기세요.
