10 · 自定义组件¶
扩展只有三条合法路径。默认走路径 A;不要先去抄官方 Button 源码。
路径 A — 声明式组合(首选)¶
用官方积木 + Box + Style + 回调:
func Tag(key, text string, selected bool, onToggle func()) ui.Widget {
return ui.Box(ui.Text(text)).
Key(key).
Label(text).
Style(
ui.PaddingX(12).PaddingY(6).Radius(999).
Background(ui.TokenSurfaceSecondary).
Cursor(ui.CursorPointer).
When(ui.Hovered, ui.Background(ui.TokenSurfaceTertiary)).
When(ui.If(selected),
ui.Background(ui.TokenAccent).
TextColor(ui.TokenAccentForeground),
).
When(ui.Pressed, ui.Scale(0.98, 0.98)),
).
OnClick(onToggle)
}
在 View 里:
| 关注点 | 用法 |
|---|---|
| 外观 | Style + When + Token |
| 交互 | Key + OnClick / Disabled / Label |
| 结构 | Row / Column / 官方控件 |
| 业务 | 回调 → send |
目标:80% 以上自定义停在这里。
路径 B — 复合组件协议(与官方同构)¶
需要多 Part、variant/size、领域 props 时,走与 Button 相同的装配:
示意(与 examples/custom_widgets 一致):
type customTrigger struct {
key string
label string
pressed bool
onClick func()
style ui.Style
}
func (b customTrigger) Layout(ctx *ui.Context, gtx layout.Context) layout.Dimensions {
state := ui.UseState[customTriggerState](ctx, b.key)
for state.click.Clicked(gtx) {
if b.onClick != nil {
b.onClick()
}
}
focused := gtx.Focused(&state.click)
styleState := ui.StyleState{
Hovered: state.click.Hovered(),
Pressed: state.click.Pressed(),
Focused: focused,
FocusVisible: ctx.FocusVisible(&state.click, focused),
Selected: b.pressed,
}
defaults := ui.Width(190).Height(36).PaddingX(12).Radius(6).
Background(ui.TokenSurfaceSecondary).
Cursor(ui.CursorPointer).
Part(ui.PartLabel, ui.FontSize(13)).
When(ui.Hovered, ui.Background(ui.TokenSurfaceTertiary)).
When(ui.FocusVisible, ui.Outline(2, 1, ui.TokenFocus))
resolved := ui.ResolveStyle(ctx, gtx, b.key, styleState, defaults, b.style)
label := ui.ResolveStylePart(ctx, gtx, b.key, ui.PartLabel, styleState, defaults, b.style)
content := ui.WidgetFunc(func(ctx *ui.Context, gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return ui.LayoutResolvedStyle(ctx, gtx, label, ui.Text(b.label))
})
})
return ui.LayoutInteractiveResolvedStyle(ctx, gtx, resolved, content, func(gtx layout.Context, visual layout.Widget) layout.Dimensions {
return state.click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return visual(gtx)
})
})
}
要点:
- 值类型 + fluent 配置
- 实现
ui.Widget - 交互状态放在
UseState,点击回调只发送意图 - 样式通过
ResolveStyle/ResolveStylePart解析 - 外壳只经 Host;不私建边框栈
- 回调只
send,不改 Model - 焦点使用稳定的
widget.Clickable和FocusVisible
完整可运行代码:examples/custom_widgets。
路径 C — 画布自绘¶
在 Host 内容区 内画领域像素(图表曲线、特殊可视化):
func ChartContent() ui.Widget {
return ui.WidgetFunc(func(ctx *ui.Context, gtx layout.Context) layout.Dimensions {
// 使用 Gio op 绘制内容;不要在这里重做卡片边框/阴影外壳
return layout.Dimensions{Size: gtx.Constraints.Min}
})
}
规则:
- 外壳(margin/padding/背景/圆角/阴影)仍归 Style + Host
- 自绘代码只负责内容
- 需要点击态时,外层仍用路径 A/B 包一层交互
见 examples/custom_widgets 与相关测试/示例。
自定义绘制辅助¶
路径 C 只负责领域内容。需要绘制渐变、提前测量文本或处理低层指针时, 优先复用公共辅助 API:
brush, ok := ui.ResolveBrush(ctx, ui.LinearGradient(
ui.ColorStop(0, ui.TokenAccent),
ui.ColorStop(1, ui.TokenDanger),
))
if ok {
ui.DrawBrush(gtx, image.Rect(0, 0, 160, 32), 6, brush)
}
size := ui.MeasureText(ctx, gtx, ui.Text("预览").Size(14).MaxLines(1))
_ = size
需要在组件组合中测量任意子控件时,可实现可选的 ui.Measurable 接口;否则
使用 ui.MeasureWidget。测量过程不会注册绘制或输入操作,适合 AutoWidth
这类 intrinsic sizing:
Measure 实现必须只返回尺寸,不读取或消费当前帧的输入事件。
type measuredBadge struct{}
func (measuredBadge) Measure(_ *ui.Context, gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: image.Pt(gtx.Dp(48), gtx.Dp(20))}
}
交互画布可以用 AddPointerArea 注册命中区域,
NextPointerEvent 读取事件,IsPrimaryPointerPress 判断主按下,
GrabPointer 保持拖拽期间的事件路由。需要让局部波纹或焦点装饰越过子组件
自身裁剪时使用 LayoutVisualOverflow。若装饰会被外层滚动或分栏视口裁掉,
再用 LayoutVisualOutset 申报其 top/right/bottom/left 安全边距;有 Style
外壳的组件可使用 VisualOutset。它们都不是 Popover 或 Portal 的替代品。
反模式¶
| 不要 | 原因 |
|---|---|
import internal/components/... |
破坏封装,升级即碎 |
| 在组件里保存业务 Model | 状态双源 |
| 无 Key 却依赖 hover 保留 | 槽位不保留 |
| 组件内第二套盒模型 API | 与 Style 冲突 |
| 教用户「复制 Button 源码改」 | 应走公开协议 |
UseState¶
ui.UseState / UseStateWith 仅用于瞬态交互(例如某手势中间量),且依赖显式 Key。
业务数据仍放 Model。
下一步¶
- 11-多窗口
- 可运行示例:
examples/custom_widgets