关于 makepad-2.0-layout
面向 Makepad UI 开发者的布局系统参考,说明基于 turtle 的尺寸计算与子项排列,而非 CSS Flexbox 或 Grid。涵盖滚动容器、间距、对齐和弹出层定位。
主要功能
- 01Walk 尺寸与 Layout 排列
- 02流向、间距、内边距和对齐
- 03滚动容器选择与配置
- 04弹出层的绝对定位
- 05常见布局问题排查
适用场景
- 01搭建纵向页面或横向工具栏
- 02为长列表配置滚动区域
- 03将弹出菜单锚定到触发控件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zhanghandong/ makepad-skills --skill makepad-2.0-layout安装后在 Claude Code 中输入/makepad-2.0-layout确认已加载。
附带文件references/layout-patterns.md
详细介绍
Makepad 2.0 布局系统
Makepad 使用 布局乌龟 系统——不是 CSS flexbox,也不是 CSS grid。布局乌龟会依次遍历子项,并根据两个核心概念放置每个控件:
- Walk ——控件如何调整自身尺寸(宽度、高度、外边距)
- Layout ——容器如何排列子项(流向、间距、内边距、对齐)
每个容器控件(View、SolidView、RoundedView、ScrollYView 等)都同时具有 Walk 属性(自身尺寸)和 Layout 属性(如何排列子项)。
Walk 系统(控件尺寸)
Walk 控制单个控件如何在父级容器中占用空间。
width / height
| 语法 | 含义 |
|---|---|
width: Fill | 填充所有剩余的水平空间(默认值) |
width: Fit | 收缩以适应内容 |
width: 200 | 固定为 200 像素 |
width: Fill{min: 100 max: 500} | 填充并应用限制 |
width: Fit{max: Abs(300)} | 适应内容,最大为 300px |
height: Fill | 填充所有剩余的垂直空间(默认值) |
height: Fit | 收缩以适应内容 |
height: 100 | 固定为 100 像素 |
use mod.prelude.widgets.*
// Fill: takes all available width
View{
width: Fill height: Fit
flow: Down
Label{text: "I stretch to fill the width"}
}
// Fit: shrinks to content
View{
width: Fit height: Fit
padding: 10
Label{text: "I am only as wide as this text"}
}
// Fixed: exact pixel size
View{
width: 300 height: 200
Label{text: "I am exactly 300x200 pixels"}
}
// Constrained Fill: fills but within bounds
View{
width: Fill{min: 200 max: 600} height: Fit
flow: Down padding: 16
Label{text: "I fill available space but stay between 200-600px"}
}
关键:容器上的 height: Fit
这是 Makepad 中最常见的布局错误。
默认高度为 Fill。当你的输出在 Fit 容器中渲染时,Fit 内部的 Fill 会产生循环依赖,最终解析为 0 像素。
整个 UI 都会变得不可见。
规则:除非父级具有固定高度或 Fill 高度,否则务必为每个 View、SolidView、RoundedView 及类似容器设置 height: Fit。
// CORRECT -- height: Fit makes the container visible
View{
width: Fill height: Fit
flow: Down padding: 10
Label{text: "I am visible"}
}
// WRONG -- defaults to height: Fill, resolves to 0px, invisible
View{
width: Fill
flow: Down padding: 10
Label{text: "I am invisible (0px tall)"}
}
可以使用 height: Fill 的例外情况:
- 位于固定高度的父级内部:
View{
height: 400
View{
height: Fill
Label{text: "I fill the 400px parent"}
}
}
- 位于最终连接到已知尺寸(例如 Window 主体)的
height: Fill链中。 - ScrollYView 始终使用
height: Fill,因为它必须填充父级才能滚动。
margin
Margin 会在控件外部添加间距。
// Uniform margin on all sides
Label{text: "Hello" margin: 10}
// Selective margin with Inset
Label{
text: "Indented"
margin: Inset{top: 5 bottom: 5 left: 20 right: 20}
}
// Zero margin (note the trailing dot for float literal)
Label{text: "Flush" margin: 0.}
Layout 系统(子项排列)
Layout 控制容器如何放置其子项。
flow(方向)
| 语法 | 含义 | CSS 等效项 |
|---|---|---|
flow: Right | 从左到右排列为单行(默认值) | flex-direction: row |
flow: Down | 从上到下排列为单列 | flex-direction: column |
flow: Overlay | 将子项叠放在彼此上方 | position: absolute 叠放 |
flow: Flow.Right{wrap: true} | 从左到右排列并换行 | flex-wrap: wrap |
flow: Flow.Down{wrap: true} | 从上到下排列并换行 | 列换行 |
use mod.prelude.widgets.*
// Vertical stack (most common)
View{
width: Fill height: Fit
flow: Down spacing: 10
Label{text: "First"}
Label{text: "Second"}
Label{text: "Third"}
}
// Horizontal row
View{
width: Fill height: Fit
flow: Right spacing: 10
Label{text: "Left"}
Label{text: "Center"}
Label{text: "Right"}
}
// Overlay -- children stacked on top of each other
View{
width: Fill height: 200
flow: Overlay
Image{width: Fill height: Fill fit: ImageFit.Biggest}
View{
width: Fill height: Fit
align: Align{x: 0.5 y: 1.0}
padding: 10
Label{text: "Caption overlay" draw_text.color: #fff}
}
}
// Wrapping flow -- like a tag cloud or grid of cards
View{
width: Fill height: Fit
flow: Flow.Right{wrap: true}
spacing: 8
padding: 10
Label{text: "Tag 1" margin: 4}
Label{text: "Tag 2" margin: 4}
Label{text: "Tag 3" margin: 4}
Label{text: "Tag 4" margin: 4}
}
spacing
子项之间的间隔。单个数值会统一应用于所有方向。
View{
flow: Down spacing: 12
Label{text: "12px gap below me"}
Label{text: "12px gap above and below me"}
Label{text: "12px gap above me"}
}
padding
容器边缘与子项之间的内部间距。
// Uniform padding
View{
width: Fill height: Fit
padding: 20
Label{text: "20px padding on all sides"}
}
// Selective padding with Inset
View{
width: Fill height: Fit
padding: Inset{top: 10 bottom: 10 left: 24 right: 24}
Label{text: "Different padding per side"}
}
align(子项对齐)
Align 会将子项放置在容器的剩余空间内。 每个轴的取值范围为 0.0(起始端)到 1.0(末端)。
对齐参考表
| 简写 | 等效写法 | 描述 |
|---|---|---|
Center | Align{x: 0.5 y: 0.5} | 在两个轴上居中 |
HCenter | Align{x: 0.5 y: 0.0} | 水平居中,顶部对齐 |
VCenter | Align{x: 0.0 y: 0.5} | 左对齐,垂直居中 |
TopLeft | Align{x: 0.0 y: 0.0} | 左上角(默认值) |
Align{x: 1.0 y: 0.0} | -- | 右上角 |
Align{x: 0.0 y: 1.0} | -- | 左下角 |
Align{x: 1.0 y: 1.0} | -- | 右下角 |
Align{x: 0.5 y: 1.0} | -- | 底部居中 |
use mod.prelude.widgets.*
// Center everything
View{
width: Fill height: 300
align: Center
Label{text: "I am centered"}
}
// Horizontal center only (children flow from top)
View{
width: Fill height: Fit
flow: Down
align: HCenter
Label{text: "I am horizontally centered"}
}
// Vertically center children in a horizontal row
View{
width: Fill height: 60
flow: Right spacing: 10
align: Align{y: 0.5}
Label{text: "Vertically centered" draw_text.text_style.font_size: 14}
Label{text: "Small text" draw_text.text_style.font_size: 9}
}
clip_x / clip_y
控制是否裁剪溢出的内容。
// Clip overflow (default behavior)
View{
width: 200 height: 100
clip_x: true
clip_y: true
Label{text: "Very long text that will be clipped at the container boundary"}
}
// Allow overflow to be visible
View{
width: 200 height: 100
clip_x: false
clip_y: false
Label{text: "This text can overflow beyond the container"}
}
重要边界: clip_x: false / clip_y: false 只允许局部子项在其父级外部绘制。它们不会将该子项变成真正的窗口级叠加层。如果 UI 元素是需要独立于局部布局树浮动显示的弹出窗口/菜单/工具提示,请使用顶层 Modal/叠加层所有者,而不要依赖局部溢出。
覆盖层弹出窗口:walk.abs_pos 与 margin
在覆盖层(Modal、工具提示层、弹出窗口所有者)中进行弹出窗口式定位时,
优先使用 walk.abs_pos,而不是运行时调整 margin。
margin用于布局间距,最适合微调常规流中的子元素。walk.abs_pos是用于覆盖层式定位的显式 turtle 锚点。button.area().clipped_rect(cx)可获取触发控件实际的屏幕空间矩形, 其中包含view_shift和裁剪效果。- 对于覆盖层内容,先计算弹出窗口的绝对屏幕空间目标位置,再将其赋给
popup.walk.abs_pos = Some(dvec2(x, y))。
let button_rect = button.area().clipped_rect(cx);
let popup_pos = dvec2(button_rect.pos.x, button_rect.pos.y - 294.0);
if let Some(mut popup) = self.view(cx, ids!(popup)).borrow_mut() {
popup.walk.abs_pos = Some(popup_pos);
}
经验法则:
- 常规布局树中的弹出窗口,只需轻微溢出:使用局部子元素 +
clip_x/clip_y: false - 锚定到按钮但视觉上位于组件外部的弹出窗口:使用顶层覆盖层 +
walk.abs_pos
常见错误: 使用 script_apply_eval! 为覆盖层内容设置 margin.top / margin.left,
并期望弹出窗口坐标保持稳定。这通常会产生误导性的结果,因为你仍在与布局系统协商,
而不是显式锚定弹出窗口。
Inset 语法
Inset 类型同时用于 padding 和 margin。它支持两种形式:
// Bare number -- uniform on all four sides
padding: 10
margin: 5
// Inset struct -- specify individual sides
padding: Inset{top: 10 bottom: 10 left: 20 right: 20}
margin: Inset{top: 0 bottom: 8 left: 0 right: 0}
// Zero (use trailing dot for float literal)
margin: 0.
// You can omit sides you do not need -- they default to 0
padding: Inset{left: 16 right: 16}
padding 和 margin 都接受相同的 Inset 语法。Padding 位于容器内部,
margin 位于容器外部。
可滚动容器
Makepad 提供三种可滚动视图变体。它们继承所有 View 属性, 并增加滚动条行为。
| Widget | 滚动方向 | 典型用途 |
|---|---|---|
ScrollYView | 仅垂直 | 长列表、页面内容 |
ScrollXView | 仅水平 | 宽表格、时间线 |
ScrollXYView | 两个方向 | 地图、画布、大型内容 |
use mod.prelude.widgets.*
// Vertical scrolling -- the most common pattern
// Note: ScrollYView uses height: Fill (not Fit) to define the scroll viewport
ScrollYView{
width: Fill height: Fill
flow: Down padding: 10 spacing: 8
Label{text: "Item 1"}
Label{text: "Item 2"}
Label{text: "Item 3"}
Label{text: "Item 4"}
Label{text: "Item 5"}
Label{text: "Item 6"}
}
// Horizontal scrolling
ScrollXView{
width: Fill height: 60
flow: Right spacing: 10 padding: 10
align: Align{y: 0.5}
Label{text: "Tab 1"}
Label{text: "Tab 2"}
Label{text: "Tab 3"}
Label{text: "Tab 4"}
}
// Both-axis scrolling
ScrollXYView{
width: Fill height: Fill
Label{text: "Large content that can be scrolled in both directions"}
}
如何选择:
ScrollYView—— 页面主体、列表、垂直内容。90% 的情况下你都需要这个。ScrollXView—— 水平标签栏、代码滚动、时间线视图。ScrollXYView—— 2D 画布、地图、电子表格式内容。
重要: 可滚动视图使用 height: Fill(而不是 height: Fit),因为它们
需要固定的视口来进行滚动。内部内容会延伸到视口之外。
Filler(间隔控件)
Filler{} 等同于 View{width: Fill height: Fill}。它会推开相邻元素。
关键规则:仅在 width: Fit 的相邻元素之间使用 Filler。
请勿在 width: Fill 的相邻元素旁使用 Filler{}。两者会争夺剩余空间,
各占 50%,并导致文本被裁剪。
use mod.prelude.widgets.*
// CORRECT: Filler between Fit siblings
View{
width: Fill height: Fit
flow: Right
align: Align{y: 0.5}
Label{text: "Left side"}
Filler{}
Label{text: "Right side"}
}
// WRONG: Filler next to a Fill sibling -- text gets clipped
View{
width: Fill height: Fit
flow: Right
Label{width: Fill text: "This gets clipped to half width"}
Filler{}
Label{text: "Tag"}
}
// CORRECT alternative: width: Fill naturally pushes Fit siblings
View{
width: Fill height: Fit
flow: Right
View{
width: Fill height: Fit
flow: Down
Label{text: "Title takes remaining space"}
Label{text: "Subtitle"}
}
Label{text: "Tag"}
}
常见布局模式
垂直页面布局
use mod.prelude.widgets.*
View{
width: Fill height: Fit
flow: Down spacing: 16 padding: 20
Label{text: "Page Title" draw_text.color: #fff draw_text.text_style.font_size: 18}
Label{text: "Subtitle text" draw_text.color: #aaa draw_text.text_style.font_size: 12}
Hr{}
Label{text: "Body content goes here" draw_text.color: #ddd}
}
水平工具栏
use mod.prelude.widgets.*
SolidView{
width: Fill height: 44
flow: Right spacing: 8
padding: Inset{left: 12 right: 12}
align: Align{y: 0.5}
draw_bg.color: #2a2a3d
ButtonFlatter{text: "File"}
ButtonFlatter{text: "Edit"}
ButtonFlatter{text: "View"}
Filler{}
ButtonFlat{text: "Run"}
}
自动换行的卡片网格
use mod.prelude.widgets.*
let Card = RoundedView{
width: 180 height: Fit
padding: 12 flow: Down spacing: 6
draw_bg.color: #334
draw_bg.border_radius: 8.0
title := Label{text: "Card" draw_text.color: #fff draw_text.text_style.font_size: 12}
body := Label{text: "Content" draw_text.color: #aaa draw_text.text_style.font_size: 10}
}
View{
width: Fill height: Fit
flow: Flow.Right{wrap: true}
spacing: 10 padding: 16
Card{title.text: "Design" body.text: "UI mockups"}
Card{title.text: "Backend" body.text: "API endpoints"}
Card{title.text: "Testing" body.text: "Unit tests"}
Card{title.text: "Deploy" body.text: "CI/CD pipeline"}
}
居中内容
use mod.prelude.widgets.*
View{
width: Fill height: 400
align: Center
flow: Down spacing: 12
Label{text: "Welcome" draw_text.color: #fff draw_text.text_style.font_size: 24}
Label{text: "Click below to get started" draw_text.color: #aaa}
Button{text: "Get Started"}
}
分栏面板(侧边栏 + 内容)
use mod.prelude.widgets.*
// Simple approach with fixed sidebar
View{
width: Fill height: Fill
flow: Right
SolidView{
width: 250 height: Fill
draw_bg.color: #1a1a2e
flow: Down padding: 12 spacing: 8
Label{text: "Navigation" draw_text.color: #fff draw_text.text_style.font_size: 14}
Label{text: "Home" draw_text.color: #aaa}
Label{text: "Settings" draw_text.color: #aaa}
Label{text: "About" draw_text.color: #aaa}
}
View{
width: Fill height: Fill
flow: Down padding: 20 spacing: 10
Label{text: "Main Content" draw_text.color: #fff draw_text.text_style.font_size: 16}
Label{text: "Page body here" draw_text.color: #ddd}
}
}
// Resizable approach with Splitter
Splitter{
axis: SplitterAxis.Horizontal
align: SplitterAlign.FromA(250.0)
a := sidebar
b := main_content
}
sidebar := SolidView{
width: Fill height: Fill
draw_bg.color: #1a1a2e
flow: Down padding: 12
Label{text: "Sidebar" draw_text.color: #fff}
}
main_content := View{
width: Fill height: Fill
flow: Down padding: 20
Label{text: "Content" draw_text.color: #fff}
}
固定页眉 + 可滚动主体 + 固定页脚
use mod.prelude.widgets.*
View{
width: Fill height: Fill
flow: Down
// Fixed header
SolidView{
width: Fill height: Fit
padding: Inset{top: 12 bottom: 12 left: 20 right: 20}
draw_bg.color: #2a2a3d
flow: Right
align: Align{y: 0.5}
Label{text: "App Title" draw_text.color: #fff draw_text.text_style.font_size: 16}
Filler{}
ButtonFlatter{text: "Settings"}
}
// Scrollable body (height: Fill takes remaining space)
ScrollYView{
width: Fill height: Fill
flow: Down padding: 16 spacing: 10
new_batch: true
Label{text: "Scrollable content item 1" draw_text.color: #ddd}
Label{text: "Scrollable content item 2" draw_text.color: #ddd}
Label{text: "Scrollable content item 3" draw_text.color: #ddd}
Label{text: "Scrollable content item 4" draw_text.color: #ddd}
Label{text: "Scrollable content item 5" draw_text.color: #ddd}
}
// Fixed footer
SolidView{
width: Fill height: Fit
padding: Inset{top: 8 bottom: 8 left: 20 right: 20}
draw_bg.color: #1e1e2e
flow: Right
align: Align{y: 0.5}
Label{text: "Status: Ready" draw_text.color: #888 draw_text.text_style.font_size: 10}
Filler{}
Label{text: "v1.0" draw_text.color: #666 draw_text.text_style.font_size: 10}
}
}
覆盖层 / 模态窗口定位
use mod.prelude.widgets.*
View{
width: Fill height: 400
flow: Overlay
// Base layer -- the page content
View{
width: Fill height: Fill
flow: Down padding: 20
Label{text: "Background page content" draw_text.color: #888}
}
// Overlay layer -- centered modal dialog
View{
width: Fill height: Fill
align: Center
RoundedView{
width: 320 height: Fit
padding: 20 flow: Down spacing: 12
draw_bg.color: #2a2a3d
draw_bg.border_radius: 12.0
new_batch: true
Label{text: "Confirm Action" draw_text.color: #fff draw_text.text_style.font_size: 16}
Label{text: "Are you sure you want to proceed?" draw_text.color: #aaa}
View{
width: Fill height: Fit
flow: Right spacing: 8
align: Align{x: 1.0}
ButtonFlat{text: "Cancel"}
Button{text: "Confirm"}
}
}
}
}
关键规则总结
1. 所有容器都设置 height: Fit(头号错误)
每个 View、SolidView、RoundedView 都必须设置 height: Fit,除非它位于固定高度
或 Fill 高度的父级链中。忘记设置会导致 UI 不可见(0px)。
2. 根容器设置 width: Fill
最外层容器绝不能使用固定像素宽度,否则无法适应可用空间。根元素始终使用
width: Fill。
3. View 同时具有 show_bg 和文本子元素时设置 new_batch: true
当容器设置了 show_bg: true(包括 SolidView、RoundedView 等)且
包含 Label 或其他文本时,应在容器上设置 new_batch: true。否则,
由于 GPU 绘制调用批处理,文本可能会渲染在背景后面。
// CORRECT: new_batch ensures text draws on top of background
RoundedView{
width: Fill height: Fit
padding: 12 flow: Down
draw_bg.color: #334
draw_bg.border_radius: 8.0
new_batch: true
Label{text: "Visible text" draw_text.color: #fff}
}
4. 不要在 width: Fill 的相邻元素旁使用 Filler
Filler 和 width: Fill 的相邻元素会争夺相同的剩余空间,导致各占 50%,
并使文本被裁剪。仅在 width: Fit 的相邻元素之间使用 Filler。
5. ScrollYView 使用 height: Fill,而非 height: Fit
可滚动视图需要固定的视口。在 ScrollYView 上使用 height: Fill,使其填满父级,并在该空间内滚动内容。
文档
- 布局模式示例和完整代码:
./references/layout-patterns.md - Splash 语言手册:
/splash.md - Widget 目录:
/skills/makepad-2.0-widgets/references/widget-catalog.md