跳转至

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 里:

Tag("tag-go", "Go", m.Selected, func() {
    send(ToggleTag{})
})
关注点 用法
外观 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 引用:

UseState → resolve.Resolve / resolve.ResolvePart →
layout.InteractiveResolved(或 layout.Resolved)

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)
        })
    })
}

要点:

  1. 值类型 + fluent 配置
  2. 实现 ui.Widget
  3. 交互状态放在 UseState,点击回调只发送意图
  4. 样式通过 resolve.Resolve / resolve.ResolvePart 解析(在测量期也解析的组件用 ResolveStatic / ResolvePartStatic + ApplyTransitions / ApplyPartTransitions)
  5. 外壳只经 Host;不私建边框栈
  6. 回调只 send,不改 Model
  7. 焦点使用稳定的 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