自定义组件¶
标准组件不够用时,自己写一个。组件就是一个嵌入了 ui.Box 的结构体,再按需要实现几个接口:要画东西就实现 Draw,要接按键就实现 HandleKey。框架每帧用类型断言找这些接口,没有注册表,也没有基类方法要覆盖。
这一页从头写一个滑块 Slider,再写一个电池图标 Battery,把用到的接口、画布和动画逐个讲清楚。
一个滑块¶

small := widget.Font(ui.CurrentTheme.SmallFont)
level := widget.NewLabel("Volume 40%", small)
volume := NewSlider(4, 10, OnSlide(func(from, to int) {
ui.Do(func() { level.SetText("Volume " + strconv.Itoa(to*10) + "%") })
}))
bass := NewSlider(5, 10)
左右键移动滑块,按住连发时一格一格走;上下键照常移动焦点。光标不罩住整行,而是只套在圆钮上,跟着它走。下面几节就是 Slider 的全部代码。
类型与构造函数¶
// Slider picks one of steps+1 positions with left and right.
type Slider struct {
ui.Box
value, steps int
knob anim.Value // in steps; springs toward value
onChange func(from, to int)
}
func NewSlider(value, steps int, opts ...ui.Option) *Slider {
s := &Slider{value: value, steps: steps}
s.knob.Jump(float32(value))
s.knob.SetSpring(anim.Snappy)
s.knob.SetPrecision(0.01) // the value is in steps, not pixels
s.SetFocusable(true)
s.SetPadding(ui.CurrentTheme.RowPadding)
ui.Apply(s, opts) // last, so options override the defaults
return s
}
// OnSlide sets the handler run with the old and new step after the user
// moves the knob.
func OnSlide(fn func(from, to int)) ui.Option {
return func(d ui.Node) { d.(*Slider).onChange = fn }
}
func (s *Slider) Value() int { return s.value }
// SetValue moves the knob without calling the handler.
func (s *Slider) SetValue(v int) {
s.value = min(max(v, 0), s.steps)
s.knob.Set(float32(s.value))
}
- 嵌入
ui.Box。ui.Node接口只有一个未导出的方法,只有嵌入了ui.Box的类型才满足它。Box带着父子链接、尺寸、内边距和动画偏移;要把子节点排成一行或一列,就改为嵌入ui.Flex(ListItem就是这样)。 - 构造函数收
...ui.Option,ui.Apply放最后。先设好默认值,再应用选项,调用方传的ui.Width、ui.Padding才能覆盖默认值。所有标准组件都是这个顺序,见选项。 - 自己的选项就是
func(ui.Node)。OnSlide断言出*Slider再写字段,用在别的节点上会 panic,和widget包的选项一样。 - 默认内边距从主题取。这里用
RowPadding,所以它和列表行左右对齐,换主题时一起变,见主题与单位。 - 状态走 Set/Get。
SetValue是代码里改值的入口,按约定它不调用回调。
改了状态之后要告诉框架:影响尺寸的调用 ui.Relayout(),只影响外观的调用 ui.Invalidate()。SetValue 两个都没调,因为它只改了 anim.Value 的目标,而帧循环在任何 anim.Value 还在动时都会重绘,见动画。
TinyGo 下不要用这些方法名
嵌入 ui.Box 或 ui.Flex 的类型,如果声明了和它们未导出方法同名的方法:box、set、visible、split、join、margins、crossAuto,TinyGo 0.42 和 0.43 在把它转成接口时会崩溃(index out of range [0] with length 0)。主机上的 Go 编译正常,所以要到 tinygo build 才会发现。给自己的方法换个名字,例如 Slider 的 move、knobX。
能力接口¶
ui 包定义了一组只有一两个方法的接口,组件实现哪个,就获得哪种能力。都在 UI goroutine 上调用。
| 接口 | 方法 | 什么时候调用 |
|---|---|---|
Painter |
Draw(c gfx.Canvas) |
绘制自己,在子节点之前 |
OverPainter |
DrawOver(c gfx.Canvas) |
在子节点之后再画一层,例如滚动条、加载点 |
Updater |
Update(dt float32) |
每帧一次,节点在树上且可见时;dt 是秒 |
Measurer |
Measure(max Vec2) Vec2 |
宽或高是 Auto 时问内容尺寸,不含内边距 |
Layouter |
MeasureChildren(max Vec2) Vec2ArrangeChildren(content Rect) |
自己测量、摆放子节点,Flex、Grid 都是 |
KeyHandler |
HandleKey(e *KeyEvent) bool |
按键从焦点节点往根冒泡 |
KeyPreviewer |
PreviewKey(e *KeyEvent) bool |
按键从根往焦点节点下行,先于 HandleKey |
PointerHandler |
HandlePointer(e *PointerEvent) bool |
触摸从指针下的节点往根冒泡 |
Focuser |
FocusChanged(focused bool) |
自己获得或失去焦点 |
Revealer |
Reveal(d Node) |
某个后代获得焦点,滚动容器借此把它滚进视野 |
ArrowScroller |
ArrowScroll(e *KeyEvent, next Node) bool |
焦点移动之前,焦点节点的祖先先拿到方向键 |
CursorShaper |
CursorShape(r Rect) Rect |
获得焦点时每帧一次,改光标的矩形 |
ContentHost |
Content() Node |
ui.Add、ui.RemoveChildren 改去操作返回的内部节点 |
Leaf |
Leaf() |
标记不接受子节点,ui.Attach 到它上面会 panic |
ChildRemover |
ChildRemoved(child Node) |
子节点被移除,包括子节点移除自己 |
几条规则:
- 只有可聚焦的节点能拿到焦点和按键。在构造函数里
SetFocusable(true),或者让调用方传ui.Focusable()。 Draw和DrawOver收到的画布已经平移到节点左上角(含OffsetX、OffsetY的动画偏移);只有SetClip(true)的节点才会被裁剪到自己的矩形里。SetBackground、SetBorder的底色和边框由Box先画好,Draw画在它们上面。Measure的返回值是像素,max是内边距以内还剩的空间。宽高都是固定值或比例时不会调用它。HandleKey在Press上返回true,就认领了这个键,之后的Repeat和Release直接送到这个节点,不再冒泡。返回false交给祖先,最后由框架处理:方向键移动焦点,Back 关闭当前页面或弹窗。分发路径见按键分发。Updater只在需要逐帧推进时实现,例如计时器、跑马灯。只是让一个数值动起来,用anim.Value就够了,不需要Update。
测量与绘制¶
// knobRadius is 3 units in pixels, so the knob grows with the theme font.
func knobRadius() int16 { return ui.Round(3 * ui.Unit()) }
func (s *Slider) Measure(max ui.Vec2) ui.Vec2 {
return ui.Vec2{X: max.X, Y: float32(2*knobRadius() + 3)}
}
// knobX is the knob's centre in the node's own pixels, where it is now.
func (s *Slider) knobX() float32 {
c := s.ContentRect()
r := float32(knobRadius())
return c.X + r + (c.W-2*r)*s.knob.Get()/float32(s.steps)
}
func (s *Slider) Draw(c gfx.Canvas) {
t := ui.CurrentTheme
area := s.ContentRect().Pixels()
cy := area.Y + area.H/2
track := gfx.Rect{X: area.X, Y: cy - 2, W: area.W, H: 5}
c.StrokeRoundRect(track, 2, t.Foreground)
kx := ui.Round(s.knobX())
track.W = kx - track.X
c.FillRoundRect(track, 2, t.Foreground)
r := knobRadius()
c.FillCircle(kx, cy, r+1, t.Background)
c.FillCircle(kx, cy, r, t.Foreground)
}
Slider 的宽度要多少给多少,高度刚好放下圆钮。几个容易弄混的地方:
- 单位和像素。
Padding、Margin、Gap、Radius用的是单位,Measure、Draw里用的是像素。ui.Unit()是一个单位等于多少像素,默认字体下是 1,换大字体时变大。圆钮半径写成3 * ui.Unit(),所以字体放大时它跟着放大,见单位。 ContentRect()已经扣掉了内边距,是节点自己坐标系里的像素,类型是ui.Rect(float32)。画图前用Pixels()转成gfx.Rect,它对四条边分别取整,相邻的矩形取整后仍然相邻。knobX用s.knob.Get(),即圆钮此刻的位置,而不是目标位置,这样画出来的就是动画中的样子。- 颜色取自
ui.CurrentTheme,不要写死。单色屏上Background和Foreground就是灭和亮;先用Background画一圈稍大的圆,再画圆钮,圆钮和填充条之间就留出一道缝。
画布¶
gfx.Canvas 是屏幕的一个平移、裁剪过的视图。它是值类型:Translate 和 Clip 返回新的画布,原来的不变,所以嵌套绘制不用保存和恢复状态。坐标全是 int16 像素,以画布原点为准。
| 方法 | 作用 |
|---|---|
Translate(dx, dy) |
原点移动 dx、dy,返回新画布 |
Clip(r) |
只允许画在 r 以内,返回新画布 |
Absolute() |
原点回到屏幕左上角,保留裁剪 |
Origin()、Bounds() |
原点的屏幕坐标;裁剪区在本地坐标里的矩形 |
Visible(r) |
r 是否有一部分在裁剪区内,用来跳过画不出来的东西 |
Pixel(x, y, col) |
一个点 |
HLine、VLine |
一像素宽的横线、竖线 |
Line(x0, y0, x1, y1, col) |
任意方向的直线(Bresenham),画勾、叉这类小图标 |
FillRect、StrokeRect |
实心、空心矩形 |
FillRoundRect、StrokeRoundRect |
圆角矩形,radius 是像素 |
FillCircle、StrokeCircle |
圆心加半径 |
Text(f, x, y, s, col) |
一行字,y 是基线,返回最后一个字形之后的 x |
InvertRect、InvertRoundRect |
把区域里的像素反色,屏幕实现了 gfx.Inverter 才有效 |
CanInvert() |
反色是否有效 |
文字按基线定位。要在一个盒子里垂直居中,照 Label 的算法:
gfx.TextWidth(f, s) 量文字宽度,可以拿来右对齐或在 Measure 里报尺寸。字体是 tinyfont 的位图字体,主题里有 Font 和 SmallFont 两种。
反色只在实现了 gfx.Inverter 的屏幕上生效,其他屏幕上 InvertRect 什么也不做。需要反色效果的组件先问 CanInvert(),不能反色时换一种画法,光标就是这样:能反色时把焦点节点反色,不能时画一个 Accent 色的轮廓。哪些屏幕能反色见接入硬件。
按键¶
func (s *Slider) HandleKey(e *ui.KeyEvent) bool {
dir := 0
switch e.Key {
case ui.KeyLeft:
dir = -1
case ui.KeyRight:
dir = 1
default:
return false // up and down still move focus
}
switch {
case e.Phase == ui.Press:
// Feedback only: lean toward the key.
s.knob.Set(float32(s.value) + 0.3*float32(dir))
case e.Fires():
s.move(dir)
default:
// Canceled, or the Release after repeats that already moved.
s.knob.Set(float32(s.value))
}
return true // claim the key, so Repeat and Release come here too
}
func (s *Slider) move(dir int) {
from := s.value
s.SetValue(from + dir)
if s.value != from && s.onChange != nil {
go s.onChange(from, s.value) // never on the UI goroutine
}
}
这段照着框架的交互约定写:
Press只给反馈。圆钮朝按键方向探出 0.3 格,表示“收到了”,值不变。e.Fires()时才动。它在没被取消、前面也没有连发的Release上为真,按住时的每个Repeat上也为真。按住右键就一格一格走,松手时不会多走一格。- 其余情况把圆钮放回原位。被取消的松开(触摸变成拖动)和连发之后的松开都走这里。
- 上下键返回
false,交给框架移动焦点,所以两个滑块之间能上下切换。 - 回调用
go调用。用户回调永远不在 UI goroutine 上跑,慢回调不会卡住动画;回调里改树要包在ui.Do里,就像使用示例里更新level那样,见线程模型。
Slider 认领左右键,所以焦点在滑块上时左右键不再移动焦点。如果滑块放在横向的 Scroll 里,这也意味着它会挡住横向滚动,设计布局时要考虑到。
改变光标¶
// CursorShape wraps the cursor round the knob instead of the whole row.
func (s *Slider) CursorShape(r ui.Rect) ui.Rect {
d := float32(2*knobRadius() + 5)
return ui.Rect{X: r.X + s.knobX() - d/2, Y: r.Y + (r.H-d)/2, W: d, H: d}
}
焦点节点实现了 ui.CursorShaper 时,光标每帧把节点的矩形交给 CursorShape,用它返回的矩形代替。参数和返回值都是屏幕像素坐标;参数已经被开了裁剪的祖先裁过,滚出一半的行只给可见的那部分。
Slider 返回圆钮周围的一个小方块,光标就只罩住圆钮。因为用的是 knobX() 此刻的位置,圆钮动,光标的目标也跟着动;光标四条边各有自己的弹簧(ui.CursorLead、ui.CursorTrail),会朝运动方向先拉长再收拢。Button 的长按确认也是这个接口:按住时从左边把光标一点点切掉。
光标的按下收缩、换焦点时的拉伸、跨屏幕的跳跃都在 CursorShape 的结果上叠加,组件不用自己处理,见光标。
动画¶
Slider 的圆钮是一个 anim.Value:一个朝目标弹过去的 float32。零值就能用,静止在 0,用 anim.Default 弹簧。
| 调用 | 作用 |
|---|---|
v.Set(x) |
以弹簧动画移向 x |
v.Jump(x) |
立即到 x,停止运动;构造函数里设初值用它 |
v.Get() |
此刻的值,画图时用 |
v.Target() |
正在去的值 |
v.SetSpring(s) |
换弹簧,例如 anim.Snappy |
v.SetPrecision(p) |
离目标多近算停下;默认 0.05 适合像素,0 到 1 的进度要更小 |
v.Bump(px)、v.Kick(vel) |
给一个冲量,弹出去再回来 |
Set 让值加入调度器的活动列表,帧循环每帧推进所有活动值,只要有一个还在动就重绘整屏,停下后自动退出列表。所以组件只要在 Draw 里读 Get(),不用实现 Updater,也不用调用 ui.Invalidate()。Value 被调度器用指针引用,开始动之后不能再复制;把它作为字段放在通过指针使用的组件结构体里就没问题。弹簧参数和预设见动画。
要让整个节点动,不必自己存偏移:Box 自带 OffsetX() 和 OffsetY() 两个 anim.Value,绘制时叠加在布局位置上,子节点跟着一起动。标准组件失败时左右抖一下,就是 OffsetX().Kick(...)。
事件回调与 widget.Control¶
标准组件的 OnClick、OnChange、加载点和失败抖动都来自嵌入的 widget.Control。但 Control 里保存回调的字段、启动回调的 run、推进加载计时的 tick 都是未导出的,widget 包以外的代码碰不到。在你自己的包里嵌入 widget.Control:
| 能做 | 不能做 |
|---|---|
类型上有 OnClick 方法,widget.OnClick(fn) 选项也能用在它上面,回调会被存下来 |
调用存下来的回调:字段和 run 都未导出,存了也不会执行 |
用 OnChange:Control 不知道你的值类型,widget.OnChange(fn) 和 OnChange 方法都会 panic |
|
用 Busy()、Loading():没有回调在跑,它们永远是 false |
所以在别的包里写组件,有两条路。
一、交给标准组件处理激活。把自己的节点放进 ListItem 的尾部(或放在一个 Button 旁边),点击、加载点、失败抖动都由 ListItem 的 Control 负责,自己的组件只管显示:

bat := NewBattery(0.3)
row := widget.NewListItem("Battery", widget.Trailing(bat), widget.OnClick(func() {
time.Sleep(time.Second) // read the fuel gauge
ui.Do(func() { bat.SetLevel(0.85) })
}))
Battery 只实现了 Measure、Draw 和 Leaf,没有一行按键代码:
// Battery draws a charge level from 0 to 1. It takes no keys: the row it
// sits in handles activation.
type Battery struct {
ui.Box
level anim.Value
}
func NewBattery(level float32, opts ...ui.Option) *Battery {
b := &Battery{}
b.level.Jump(level)
b.level.SetPrecision(0.002)
ui.Apply(b, opts)
return b
}
func (b *Battery) SetLevel(l float32) { b.level.Set(l) }
func (b *Battery) Leaf() {} // refuses children
func (b *Battery) Measure(max ui.Vec2) ui.Vec2 {
return ui.Vec2{X: 18 * ui.Unit(), Y: 9 * ui.Unit()}
}
func (b *Battery) Draw(c gfx.Canvas) {
fg := ui.CurrentTheme.Foreground
r := b.ContentRect().Pixels()
body := gfx.Rect{X: r.X, Y: r.Y, W: r.W - 2, H: r.H}
c.StrokeRoundRect(body, 2, fg)
c.FillRect(gfx.Rect{X: body.Right(), Y: r.Y + r.H/4, W: 2, H: r.H / 2}, fg)
fill := body.Inset(2)
// The spring overshoots a little; keep the fill inside the body.
fill.W = ui.Round(float32(fill.W) * min(max(b.level.Get(), 0), 1))
c.FillRect(fill, fg)
}
回调超过 150ms 时,ListItem 把尾部的电池滑走、换成加载点;回调在 ui.Do 里改了电量,返回后电池滑回来,填充条再弹到新电量。
二、自己管回调。像 Slider 那样存一个函数字段,在 UI goroutine 上判断好该不该调用,然后 go 出去。这样没有加载点,也不会在回调运行期间屏蔽激活;需要这些时,自己记一个 busy 标志,在回调结束时用 ui.Do 清掉它。
不要把标准组件按值嵌入
widget.NewListItem、widget.NewButton 在构造时创建内部的子节点并把它们挂到自己身上,初始化方法也是未导出的。在别的包里按值嵌入 widget.ListItem 得到的是一个没有标题、不可聚焦的空行;复制 *NewListItem(...) 的结果则会让子节点的父指针指向原来那个对象。用组合,不要用嵌入。