mcpField
M
makepad-2.0-layout

makepad-2.0-layout

github.com/zhanghandong设计与 UI75253 次安装29 周前更新
代码仓库

关于 makepad-2.0-layout

面向 Makepad UI 开发者的布局系统参考,说明基于 turtle 的尺寸计算与子项排列,而非 CSS Flexbox 或 Grid。涵盖滚动容器、间距、对齐和弹出层定位。

主要功能

  1. 01Walk 尺寸与 Layout 排列
  2. 02流向、间距、内边距和对齐
  3. 03滚动容器选择与配置
  4. 04弹出层的绝对定位
  5. 05常见布局问题排查

适用场景

  1. 01搭建纵向页面或横向工具栏
  2. 02为长列表配置滚动区域
  3. 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 的例外情况:

  1. 位于固定高度的父级内部:
View{
    height: 400
    View{
        height: Fill
        Label{text: "I fill the 400px parent"}
    }
}
  1. 位于最终连接到已知尺寸(例如 Window 主体)的 height: Fill 链中。
  2. 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(末端)。

对齐参考表
简写等效写法描述
CenterAlign{x: 0.5 y: 0.5}在两个轴上居中
HCenterAlign{x: 0.5 y: 0.0}水平居中,顶部对齐
VCenterAlign{x: 0.0 y: 0.5}左对齐,垂直居中
TopLeftAlign{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

评论

支持 Markdown