跳转至

07 · 表单与受控状态

表单的核心:

值在 Model 里;控件只展示并回报意图。

最小表单

type Model struct {
    Name string
}

type NameChanged struct{ Name string }

func Update(m *Model, msg Msg) ui.Cmd[Msg] {
    switch msg := msg.(type) {
    case NameChanged:
        m.Name = msg.Name
    }
    return nil
}

func View(_ *ui.Context, m Model, send ui.Send[Msg]) ui.Widget {
    return layout.Column(
        input.New(m.Name).
            Hint("Name").
            OnChange(func(text string) {
                send(NameChanged{Name: text})
            }),
        text.New("Hello, "+m.Name),
    ).Gap(12)
}

受控输入的可运行示例见仓库 examples/inputs,在仓库根目录运行 go run ./examples/inputs。

常见字段模式

Input / TextArea

input.New(m.Email).
    Hint("you@example.com").
    OnChange(func(s string) { send(EmailChanged{s}) })

input.NewTextArea(m.Bio).
    OnChange(func(s string) { send(BioChanged{s}) })

右键编辑菜单

Input / TextArea 自带右键编辑菜单(剪切 / 复制 / 粘贴 / 全选),菜单项会根据 只读、密码与选区状态自动启用或禁用:密码字段不提供剪切和复制,只读字段不提供 剪切和粘贴。快捷键显示跟随平台(Windows / Linux 为 Ctrl,macOS 为 Command), 菜单文案跟随界面语言(中文 / 英文)。

input.Group / GroupTextArea 的字段与 combobox.New 的输入框共用这套 内置菜单,同样支持 .DefaultContextMenu(false) 与 .ContextMenu(...); text.Selectable 自带右键菜单(复制 / 全选)。菜单打开期间选区高亮保持可见, 不会因为菜单抢走焦点而消失。

不需要内置菜单时用 .DefaultContextMenu(false) 关闭;要换成自定义菜单(比如 追加业务动作)时用 .ContextMenu(...),自定义菜单会整体替换内置菜单:

input.New(m.Email).DefaultContextMenu(false)

input.New(m.Email).
    ContextMenu(menu.New("email-menu", []menu.Item{
        {Key: "copy", Label: "复制地址"},
    }))

input.Group 前后缀操作

静态内容使用 Prefix / Suffix;可点击的前后缀使用 PrefixAction / SuffixAction。input.GroupAction 会提供统一的 24dp 操作区域、手型光标和 无障碍名称:

input.Group(input.New(m.Website)).
    SuffixAction(
        input.GroupAction("clear-website", "清理网址").Content(icon.New(lucide.X).Size(16)).
            OnClick(func() { send(ClearWebsite{}) }),
    )

操作区域默认不会抢占输入框焦点;需要在操作后继续聚焦编辑器时,显式调用 .FocusOnActionPress(true)。自定义插槽间距仍可通过 PrefixPadding / SuffixPadding 覆盖。输入编辑区属于 PartContent,需要单独调整光标或样式时, 使用 style.New().Part(style.PartContent, ...) 调整光标、文字或排版,不要覆盖整个组合框的根样式。

Checkbox / Switch

toggle.NewCheckbox("我同意条款").Key("agree").Checked(m.Agree).
    OnChange(func(v bool) { send(AgreeChanged{v}) })

toggle.NewSwitch("深色模式").Key("dark").Checked(m.Dark).
    OnChange(func(v bool) { send(DarkChanged{v}) })

RadioGroup

选项与选中值都来自 Model(或常量列表 + Model 选中 key)。

Select

selector.New(items).Key("state").SelectedKey(m.State).
    Label("State").
    Placeholder("Select one").
    OnChange(func(key string) { send(SetState(key)) })

校验示例:

selector.New(items).Key("required").SelectedKey(m.Choice).
    Required(true).
    Invalid(m.Choice == "").
    ErrorMessage("请选择一项").
    OnChange(...)

输入框组合见仓库 examples/inputs;本章其他字段代码为用法片段。

Open 状态契约(重要)

适用于 Select、Dropdown、ContextMenu、Popover、Modal 等可开合控件:

未调用 Open(...)     → 非受控:控件内部维护 open
Open(bool)           → 受控:open 必须在 Model 中,并处理对应的状态回调
DefaultOpen(...)     → 仅给非受控提供初始种子

非受控(默认)

适合简单页面,不需要从外部关闭面板:

selector.New(items).Key("city").SelectedKey(m.City).
    OnChange(func(key string) { send(SetCity(key)) })
// 不调用 Open(...)

受控

需要与其它 UI 联动、或从外部关闭时:

type Model struct {
    City string
    Open bool
}

selector.New(items).Key("city").SelectedKey(m.City).
    Open(m.Open).
    OnOpenChange(func(open bool) { send(SetOpen(open)) }).
    OnChange(func(key string) { send(SetCity(key)) })

注意: 调用了 Open(...) 却不处理对应的状态回调、不更新 Model,面板会卡住。

Dropdown 使用 OnOpenChangeEvent,通过 event.Open 读取新状态;Select、 ContextMenu、Popover 和 Modal 继续使用 OnOpenChange。

DefaultOpen

popover.New(...).DefaultOpen(true) // 只影响非受控初始状态

校验与错误展示

模式:

  1. Model 持有值与错误信息(或派生:invalid := m.Email == "")。
  2. 控件 .Invalid(true) / .ErrorMessage("...")。
  3. 提交时在 Update 里统一校验,写入错误字段。
input.New(m.Email).
    Invalid(m.EmailError != "").
    // 具体 ErrorMessage API 以该控件 go doc 为准
    OnChange(...)

Label / Description 关联

优先用控件自带的 Label / Description 方法;布局容器也会预注册 Label 与字段的关联,语义不依赖视觉顺序。

自定义复合布局时,尽量先布局关联的 Label,再布局控件,以便同帧语义正确。

提交

button.New("提交").OnClick(func() {
    send(Submit{})
})

func Update(m *Model, msg Msg) ui.Cmd[Msg] {
    switch msg.(type) {
    case Submit:
        if err := validate(m); err != nil {
            m.FormError = err.Error()
            return nil
        }
        return submitCmd(snapshot(m)) // 见第 08 章
    }
    return nil
}

下一步