跳转至

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

Tag("tag-go", "Go", m.Selected, func() {
    send(ToggleTag{})
})
关注点 用法
外观 Style + When + Token
交互 Key + OnClick / Disabled / Label
结构 Row / Column / 官方控件
业务 回调 → send

目标:80% 以上自定义停在这里。

路径 B — 复合组件协议(与官方同构)

需要多 Part、variant/size、领域 props 时,走与 Button 相同的装配:

UseState → ResolveStyle / ResolveStylePart →
LayoutInteractiveResolvedStyle(或 LayoutResolvedStyle)

示意(与 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)
        })
    })
}

要点:

  1. 值类型 + fluent 配置
  2. 实现 ui.Widget
  3. 交互状态放在 UseState,点击回调只发送意图
  4. 样式通过 ResolveStyle / ResolveStylePart 解析
  5. 外壳只经 Host;不私建边框栈
  6. 回调只 send,不改 Model
  7. 焦点使用稳定的 widget.ClickableFocusVisible

完整可运行代码: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。它们都不是 PopoverPortal 的替代品。

反模式

不要 原因
import internal/components/... 破坏封装,升级即碎
在组件里保存业务 Model 状态双源
无 Key 却依赖 hover 保留 槽位不保留
组件内第二套盒模型 API 与 Style 冲突
教用户「复制 Button 源码改」 应走公开协议

UseState

ui.UseState / UseStateWith 仅用于瞬态交互(例如某手势中间量),且依赖显式 Key。 业务数据仍放 Model。

下一步