10 · 自定义组件¶
扩展只有四条合法路径。默认走路径 A;需要换结构时走路径 D;不要先去抄官方 Button 源码。
下文示例统一假定已通过 activeTheme := core.ActiveTheme(ctx) 取得当前主题值,
颜色直接引用主题的 Colors 字段(主题是值,不是查询——见 architecture)。
路径 A — 声明式组合(首选)¶
用官方积木 + Box + Style + 回调:
func Tag(key, text string, selected bool, onToggle func()) ui.Widget {
return layout.Box(text.New(text)).
Key(key).
Label(text).
Style(
style.New().Padding(12, 6).Radius(999).
Background(style.Solid(activeTheme.Colors.SurfaceSecondary)).
Cursor(style.CursorPointer).
On(style.Hovered, style.New().Background(style.Solid(activeTheme.Colors.SurfaceTertiary))).
On(style.Selected,
style.New().Background(style.Solid(activeTheme.Colors.Tones[theme.TonePrimary].Fill)).
TextColor(style.Solid(activeTheme.Colors.Tones[theme.TonePrimary].OnFill)),
).
On(style.Pressed, style.New().Scale(0.98)),
).
OnClick(onToggle)
}
在 View 里:
| 关注点 | 用法 |
|---|---|
| 外观 | Style + On / Rules + Token |
| 交互 | Key + OnClick / Disabled / Label |
| 结构 | Row / Column / 官方控件 |
| 业务 | 回调 → send |
目标:80% 以上自定义停在这里。
路径 B — 复合组件协议(与官方同构)¶
需要多 Part、variant/size、领域 props 时,走与 Button 相同的装配。解析入口在
github.com/qianniancn/flowui/style/resolve(不是 style —— style 是纯声明机制,
需要上下文或主题值的入口都在它旁边这个子包里),下文按 resolve 引用:
resolve.Resolve 一次成型:解析并施加过渡。若组件在测量期也要解析样式(多数复合组件都要,
否则测量与渲染会各算一次过渡),改用静态对 + 过渡对,让过渡状态每帧只施加一次:
root := resolve.ResolveStatic(ctx, styleState, defaults, instance)
if resolve.HasTransitions(root) {
root = resolve.ApplyTransitions(ctx, gtx, b.key, root)
}
部件同理:resolve.ResolvePartStatic + resolve.ApplyPartTransitions。两条路径共用同一个
控件 key,所以混用也不会算成两个身份。
下面是自定义组件的示意:
type customTrigger struct {
key string
label string
pressed bool
onClick func()
style style.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 := style.State{
Hovered: state.click.Hovered(),
Pressed: state.click.Pressed(),
Focused: focused,
FocusVisible: ctx.FocusVisible(&state.click, focused),
Selected: b.pressed,
}
defaults := style.New().Width(190).Height(36).Padding(12).Radius(6).
Background(style.Solid(activeTheme.Colors.SurfaceSecondary)).
Cursor(style.CursorPointer).
Part(style.PartLabel, style.New().FontSize(13)).
On(style.Hovered, style.New().Background(style.Solid(activeTheme.Colors.SurfaceTertiary))).
On(style.FocusVisible, style.New().Outline(2, 1, style.Solid(activeTheme.Colors.Ring)))
resolved := resolve.Resolve(ctx, gtx, b.key, styleState, defaults, b.style)
label := resolve.ResolvePart(ctx, gtx, b.key, style.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 layout.Resolved(ctx, gtx, label, text.New(b.label))
})
})
return layout.InteractiveResolved(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,点击回调只发送意图 - 样式通过
resolve.Resolve/resolve.ResolvePart解析(在测量期也解析的组件用ResolveStatic/ResolvePartStatic+ApplyTransitions/ApplyPartTransitions) - 外壳只经 Host;不私建边框栈
- 回调只
send,不改 Model - 焦点使用稳定的
widget.Clickable和FocusVisible
以上为扩展 API 的用法片段,需接入应用的 View 与消息循环。
路径 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 包一层交互
公开组件的行为测试可参考仓库 widget/button 和 widget/input。
路径 D — 无外观 Base¶
需要完全不同的结构(整行设置卡、仪表式滑杆、自定义触发器),但想保留指针、
键盘、焦点和无障碍时,用 widget/base:
base.Toggle("sync", model.Sync).
Label("同步文件").
OnChange(func(next bool) { send(SetSync{next}) }).
Style(style.New().FillWidth().Padding(14).Radius(12).
Background(style.Solid(activeTheme.Colors.SurfaceSecondary))).
Content(func(state base.ToggleState) ui.Widget {
return layout.Row(
indicator(state.Checked, state.Hovered),
text.New("同步文件"),
)
})
base.Toggle / base.Slider 只负责行为、状态和语义,不画任何像素:
Content 收到的 ToggleState / SliderState 提供 Checked、Hovered、
Dragging、FocusVisible 等,用来画自己的视觉。Content 必填,Style 决定
交互盒;值仍由 Model 持有,用户意图通过 OnChange 回传。
官方 Checkbox / Slider 的默认外观仍然可用。Base 面向的是"结构也不同"的
设计系统;只换颜色、圆角或阴影时,路径 A 和主题 token 已经够用。
自定义绘制辅助¶
路径 C 只负责领域内容。需要绘制渐变、提前测量文本或处理低层指针时, 优先复用公共辅助 API:
brush, ok := resolve.Brush(style.LinearGradient(
style.ColorStop(0, style.Solid(activeTheme.Colors.Tones[theme.TonePrimary].Fill)),
style.ColorStop(1, style.Solid(activeTheme.Colors.Tones[theme.ToneDanger].Fill)),
))
if ok {
draw.Fill(gtx, image.Rect(0, 0, 160, 32), 6, brush)
}
size := text.Measure(ctx, gtx, text.New("预览").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 保持拖拽期间的事件路由。需要让局部波纹或焦点装饰越过子组件
自身裁剪时使用 layout.VisualOverflow。若装饰会被外层滚动或分栏视口裁掉,
再用 layout.VisualOutset 申报其 top/right/bottom/left 安全边距;有 Style
外壳的组件可使用 style.VisualOutset。它们都不是 Popover 或 Portal 的替代品。
反模式¶
| 不要 | 原因 |
|---|---|
import widget/<component>/... |
破坏封装,升级即碎 |
| 在组件里保存业务 Model | 状态双源 |
| 无 Key 却依赖 hover 保留 | 槽位不保留 |
| 组件内第二套盒模型 API | 与 Style 冲突 |
| 教用户「复制 Button 源码改」 | 应走公开协议 |
UseState¶
ui.UseState / UseStateWith 仅用于瞬态交互(例如某手势中间量),且依赖显式 Key。
业务数据仍放 Model。
下一步¶
- 11-多窗口
- 基础应用入口可参考仓库
examples/counter