跳转至

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 ui.Column(
        ui.Input("name", m.Name).
            Hint("Name").
            OnChange(func(text string) {
                send(NameChanged{Name: text})
            }),
        ui.Text("Hello, "+m.Name),
    ).Gap(12)
}

示例:examples/form

常见字段模式

Input / TextArea

ui.Input("email", m.Email).
    Hint("you@example.com").
    OnChange(func(s string) { send(EmailChanged{s}) })

ui.TextArea("bio", m.Bio).
    OnChange(func(s string) { send(BioChanged{s}) })

InputGroup 前后缀操作

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

ui.InputGroup(ui.Input("website", m.Website)).
    SuffixAction(
        ui.InputGroupAction("clear-website", "清理网址", ui.Icon(lucide.X).Size(16)).
            OnClick(func() { send(ClearWebsite{}) }),
    )

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

Checkbox / Switch

ui.Checkbox("agree", m.Agree).
    Label("我同意条款").
    OnChange(func(v bool) { send(AgreeChanged{v}) })

ui.Switch("dark", m.Dark).
    OnChange(func(v bool) { send(DarkChanged{v}) })

RadioGroup

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

Select

ui.Select("state", m.State, items).
    Label("State").
    Placeholder("Select one").
    OnChange(func(key string) { send(SetState(key)) })

多选:

ui.SelectMultiple("countries", m.Countries, items).
    OnSelectionChange(func(keys []string) {
        send(SetCountries(keys))
    })

校验示例:

ui.Select("required", m.Choice, items).
    Required(true).
    Invalid(m.Choice == "").
    ErrorMessage("请选择一项").
    OnChange(...)

示例:examples/selectsexamples/checkboxesexamples/switchesexamples/inputs

Open 状态契约(重要)

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

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

非受控(默认)

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

ui.Select("city", m.City, items).
    OnChange(func(key string) { send(SetCity(key)) })
// 不调用 Open(...)

受控

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

type Model struct {
    City string
    Open bool
}

ui.Select("city", m.City, items).
    Open(m.Open).
    OnOpenChange(func(open bool) { send(SetOpen(open)) }).
    OnChange(func(key string) { send(SetCity(key)) })

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

Dropdown 使用 OnOpenChangeEvent,通过 event.Open 读取新状态;SelectContextMenuPopoverModal 继续使用 OnOpenChange

DefaultOpen

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

校验与错误展示

模式:

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

Label / Description 关联

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

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

提交

ui.Button("submit", ui.Text("提交")).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
}

下一步