布局¶
每个节点的位置和大小由布局算出来:父节点给出一块区域,子节点按自己的尺寸、锚点和边距在里面占一块。布局用 float32 计算,弹簧推动的位置可以停在两个像素之间,画的时候才取整。
有三种容器:ui.Stack 按锚点摆放子节点,ui.Flex 把子节点排成一行或一列,ui.Grid 按列排成表格。组件也是节点,大多数组件内部就是这三种容器之一。
尺寸¶
宽和高各是一个 ui.Size,三种取值:
| 写法 | 含义 |
|---|---|
ui.Px(v) |
固定 v 像素,不随单位缩放 |
ui.Rel(f) |
父节点内容区(去掉内边距和自己的外边距之后)的 f 倍 |
ui.Auto |
默认值,按内容定 |
Auto 的内容尺寸这样算:实现了 ui.Measurer 的叶子(如 Label)报告文字大小;实现了 ui.Layouter 的容器(Flex、Grid)汇总子节点;其他节点取所有可见子节点(连同外边距和位置偏移)的外接范围。最后再加上内边距。
| 选项 | 作用 |
|---|---|
ui.Width(s)、ui.Height(s) |
设一个方向 |
ui.Fill() |
两个方向都是 Rel(1) |
ui.FillWidth()、ui.FillHeight() |
一个方向 Rel(1) |
对应的方法是 SetWidth、SetHeight、Width()、Height()。
滚动容器沿滚动方向给子节点的空间是 ui.Unbounded(2^24),无穷大的几分之几没有意义,所以这个方向上的 Rel 按 Auto 处理。把 List 放进 List 时就会遇到,见嵌套滚动。
锚点与原点¶
在 Stack 和其他不自己排布子节点的容器里,子节点靠两个点定位:
- 锚点(
Anchor):父节点内容区里的一点; - 原点(
Origin):子节点自己身上的一点,它被放到锚点上。
两者都是 ui.Vec2,{0, 0} 是左上角,{1, 1} 是右下角。ui.Align(v) 把锚点和原点设成同一个值,这是贴边、贴角最常用的写法:ui.Align(ui.BottomRight) 让节点的右下角对准父节点的右下角。

t := ui.CurrentTheme
spots := []ui.Vec2{ui.TopLeft, ui.TopCenter, ui.TopRight, ui.BottomRight, ui.BottomCenter, ui.BottomLeft}
names := []string{"TopLeft", "TopCenter", "TopRight", "BottomRight", "BottomCenter", "BottomLeft"}
tag := widget.NewLabel(names[0], widget.Font(t.SmallFont),
ui.Border(t.Foreground), ui.Radius(t.Radius), ui.Padding(ui.XY(3, 0)),
ui.AnimateLayout()) // glide to the new spot instead of jumping
at := 0
move := widget.NewButton("Move", ui.Align(ui.Center), widget.OnClick(func() {
ui.Do(func() {
at = (at + 1) % len(spots)
tag.SetAnchor(spots[at]) // the point of the parent...
tag.SetOrigin(spots[at]) // ...and the point of the tag placed on it
tag.SetText(names[at])
})
}))
stage := ui.NewStack(ui.Fill(), ui.Padding(ui.All(2)), ui.Children(move, tag))
预定义的九个点:
| 左 | 中 | 右 | |
|---|---|---|---|
| 上 | ui.TopLeft |
ui.TopCenter |
ui.TopRight |
| 中 | ui.CenterLeft |
ui.Center |
ui.CenterRight |
| 下 | ui.BottomLeft |
ui.BottomCenter |
ui.BottomRight |
锚点和原点可以不同:ui.Anchor(ui.BottomCenter), ui.Origin(ui.TopCenter) 把节点挂在父节点下边缘的外面。ui.Position(x, y) 在此基础上再平移 x、y 像素。
用到的选项和方法:ui.Anchor、ui.Origin、ui.Align、ui.Position,SetAnchor、SetOrigin、SetPosition 及对应的读取方法。
什么时候锚点不起作用
Flex 和 Grid 自己排布子节点,忽略子节点的锚点、原点和位置。ui.Push 推入的页面总是铺满整屏,页面根节点的锚点也不起作用,要在页面里放一个 Stack 再对齐。
外边距与内边距¶
外边距(Margin)是节点外面留的空,内边距(Padding)是节点边框和内容之间留的空。两者都是 ui.Insets,以单位计,绘制时乘 ui.Unit() 变成像素,见主题与单位。
| 写法 | 结果 |
|---|---|
ui.All(v) |
四边都是 v |
ui.XY(x, y) |
左右 x,上下 y |
ui.LTRB(l, t, r, b) |
分别指定 |
Insets 还有 Horizontal()(左加右)、Vertical()(上加下)和 Scale(f)(每边乘 f)。节点的 Px 和 Rel 尺寸包含内边距,Auto 尺寸是内容加内边距。
自定义组件画内容时用 ContentRect(),它返回去掉内边距的区域,已经换算成像素,坐标相对节点自己。
行、按钮、弹窗共用的内边距放在主题里(RowPadding、ButtonPadding、PanelPadding),自己排版时也从主题取,界面才统一。
Stack¶
ui.NewStack(opts...) 是最简单的容器,子节点各自按锚点定位,互相重叠,后加的画在上面。它适合角标、浮在内容上的提示,或者让一个节点在一块区域里居中。
Flex¶
ui.NewRow 把子节点从左到右排,ui.NewColumn 从上到下排。沿排列方向叫主轴,另一个方向叫交叉轴。

t := ui.CurrentTheme
small := widget.Font(t.SmallFont)
row := ui.NewRow(ui.Gap(2), ui.Padding(ui.XY(4, 2)))
for _, s := range []string{"1", "2", "3"} {
ui.Add(row, widget.NewLabel(s, small, ui.Border(t.Foreground), ui.Radius(t.Radius),
ui.Padding(ui.XY(5, 1)), ui.AnimateLayout()))
}
aligns := []ui.MainAlign{ui.MainStart, ui.MainCenter, ui.MainEnd, ui.MainSpaceBetween}
align := widget.NewChoice([]string{"Start", "Center", "End", "Between"}, 0, small,
widget.OnChange(func(from, to int) {
ui.Do(func() { row.SetMainAlign(aligns[to]) })
}))
子节点之间的间距用 ui.Gap(g) 或 SetGap(g) 设,单位是单位。主轴上的空余空间按 MainAlign 分配:
| 值 | 效果 |
|---|---|
ui.MainStart |
默认,靠起点 |
ui.MainCenter |
居中 |
ui.MainEnd |
靠终点 |
ui.MainSpaceBetween |
首尾贴边,空余空间平分到间隙里 |
交叉轴上按 CrossAlign 放:
| 值 | 效果 |
|---|---|
ui.CrossStretch |
默认,交叉轴尺寸是 Auto 的子节点撑满;尺寸固定的子节点靠起点 |
ui.CrossStart |
靠起点 |
ui.CrossCenter |
居中 |
ui.CrossEnd |
靠终点 |
选项是 ui.Main(m)、ui.Cross(c),方法是 SetMainAlign、SetCrossAlign。Axis() 返回方向(ui.Horizontal 或 ui.Vertical),SetAxis 可以在运行时把行改成列。隐藏的子节点不参与排列,也不占间距。
分配剩余空间¶
ui.Grow(g) 让子节点按比例分走主轴上的剩余空间:一个 Grow(1) 拿走全部,两个 Grow(1) 平分,Grow(2) 和 Grow(1) 按二比一分。

t := ui.CurrentTheme
small := widget.Font(t.SmallFont)
row := ui.NewRow(ui.Gap(2), ui.Padding(ui.XY(4, 2)))
boxes := make([]*widget.Label, 3)
for i, s := range []string{"A", "B", "C"} {
boxes[i] = widget.NewLabel(s, small, widget.TextAlign(0.5), ui.Border(t.Foreground),
ui.Radius(t.Radius), ui.Padding(ui.XY(5, 1)), ui.AnimateLayout())
ui.Add(row, boxes[i])
}
grow := widget.NewToggles([]string{"Grow A", "Grow B", "Grow C"}, small,
widget.OnChange(func(from, to uint32) {
ui.Do(func() {
for i, b := range boxes {
b.SetGrow(float32(to >> i & 1)) // bit i is chip i
}
})
}))
只要有一个子节点在长,MainAlign 就不起作用,因为没有剩余空间可分。
内容超出主轴长度时:
- 没有子节点在长:每个子节点保持自己的尺寸,排到容器外面去。容器设了裁剪就被切掉,否则会画到容器外面。
- 有子节点在长:超出的部分从会长的子节点身上扣,最多扣到 0。一行里标题
Grow(1)、右边是开关,标题太长时缩短的是标题,开关不会被挤出去;ListItem就是这样排的,被截短的标题在这一行聚焦时由跑马灯滚动显示。
在 Flex 里填满剩余空间用 Grow,不用 Rel
Rel 相对的是整个容器的内容区,不是兄弟节点剩下的空间。一列里放一个标题,再放一个 Height(Rel(1)) 的列表,两者加起来比容器高。给列表 ui.Grow(1) 才能让它占满标题下面的部分。(widget.List 默认是 Rel(1),加上 Grow(1) 之后超出的部分从它身上扣,所以也能用。)
Grid¶
ui.NewGrid 把子节点一行一行填进若干列。列宽用 ui.Columns(...) 或 SetColumns(...) 给出,每列是一个 ui.Track:
| 写法 | 含义 |
|---|---|
ui.Fixed(px) |
固定宽度,像素 |
ui.Fr(n) |
固定列和间距分完之后,剩下的宽度按 n 的比例分 |

small := widget.Font(ui.CurrentTheme.SmallFont)
grid := ui.NewGrid(ui.Columns(ui.Fixed(34), ui.Fr(1), ui.Fr(1)), ui.Gap(2),
ui.Padding(ui.XY(2, 1)))
for _, room := range []string{"Hall", "Desk", "Porch"} {
ui.Add(grid,
widget.NewLabel(room, small),
widget.NewButton("On", small),
widget.NewButton("Off", small),
)
}
- 不设列时只有一列
Fr(1)。 - 每行和这一行最高的子节点一样高。
- 宽是
Auto的子节点撑满列宽,高是Auto的撑满行高,所以按钮的宽度跟着列走。 ui.Gap同时是行距和列距,以单位计。- 隐藏的子节点不占格子,后面的子节点往前补。
方向键在格子之间按位置找最近的节点,不需要额外设置,见输入与焦点。
显示与隐藏¶
SetVisible(false) 或构造时的 ui.Hidden() 隐藏一个节点。隐藏的节点不占空间、不画、不调用 Update,也不能持有焦点:焦点在里面时会移到最近的可见节点。

small := widget.Font(ui.CurrentTheme.SmallFont)
network := widget.NewListItem("Network",
widget.Trailing(widget.NewLabel("Home", small)))
wifi := widget.NewCheckBox("Wi-Fi", true, widget.OnChange(func(from, to bool) {
ui.Do(func() { network.SetVisible(to) }) // hidden rows take no space
}))
bluetooth := widget.NewCheckBox("Bluetooth", false, ui.AnimateLayout())
list := widget.NewList(ui.Children(wifi, network, bluetooth))
network 隐藏之后,Bluetooth 这一行因为设了 ui.AnimateLayout() 而滑上去,而不是直接跳上去。回调里用 to 而不是去读复选框,因为 OnChange 已经把新旧值都传进来了。
位置变化时滑动¶
默认情况下,布局变了,节点下一帧就出现在新位置。ui.AnimateLayout()(或 SetAnimateLayout(true))让它滑过去:重新布局时,新旧位置的差被加进节点的 OffsetX()、OffsetY(),这两个动画值再用弹簧回到 0。
- 只有位置会滑,宽高立即变成新值。
- 偏移用的是
OffsetX()、OffsetY()自己的弹簧,默认是anim.Default。有些组件为了抖动换了弹簧,例如Button的OffsetX用anim.Bouncy,它滑动时也会带一点回弹。 - 子节点相对父节点绘制,父节点滑动时子节点跟着走,不需要各自设置。
- 第一次布局没有旧位置,不会滑。节点被
ui.Remove后再加回树里,也算第一次。
裁剪、背景与边框¶
| 选项 | 方法 | 作用 |
|---|---|---|
ui.Clip() |
SetClip(bool) |
节点和子节点只画在自己的矩形里,矩形外的部分也不响应触摸 |
ui.Background(c) |
SetBackground(c) |
用颜色 c 填满矩形 |
ui.Border(c) |
SetBorder(c)、Bordered() |
描一像素宽的边框 |
ui.Radius(r) |
SetRadius(r) |
背景和边框的圆角,以单位计 |
背景和边框在节点自己的 Draw 之前画,不需要组件自己处理。聚焦的节点被裁剪祖先遮住一部分时,光标只框住看得见的那部分。
Button、ListItem 都开了裁剪,里面的文字滑动或滚动时不会画到边框外。
重新布局¶
Box 和各组件的 Set 方法在值真的变了时会自己调用这两个函数:影响尺寸或位置的(SetWidth、SetPadding、SetText、增删节点)调用 Relayout,只影响外观的(SetBackground、SetClip)调用 Invalidate。用现成组件时不需要手动调用;自定义组件改了影响测量结果的字段要调用 Relayout,只改了画法调用 Invalidate。
Relayout 不区分是谁变了,下一帧从根开始整棵树重新布局。单片机屏幕上的树很小,追踪哪些子树变脏要多写的代码比省下的时间更多,所以 lilt 没有做脏标记。代价是同一帧里调用多少次 Relayout 都只排一次,但只要调用一次,就是整棵树。
一次布局里同一个节点可能被测量不止一次(Flex 先汇总尺寸再摆放),所以 Measure 要快、不要分配内存。自己实现 ui.Layouter 时可以用导出的 ui.Measure(n, max)、ui.Arrange(n, rect) 和 ui.ArrangeFree(n, area) 测量和摆放子节点,见自定义组件。