找回密码
立即注册
搜索
发回帖 发新帖

5195

积分

0

好友

661

主题
发表于 18 分钟前 | 查看: 2| 回复: 0

在 WinForm 上位机 里,“启用哪些采集通道”“订阅哪些报警等级”“给角色分配哪些权限”这类配置,用 CheckedListBox 很顺手:勾选状态一目了然,也不会像 ListBox 高亮多选那样,单击别处就被清空。

用好它的关键有四条:

  • 设置 CheckOnClick = true,否则用户要点两次才能勾上;
  • ItemCheck 在状态改变之前触发,要用 e.NewValue 判断新状态;
  • 需要读取完整的勾选结果时,用 BeginInvoke 推迟到状态更新之后;
  • 勾选状态按业务主键保存在模型里,不要依赖控件索引。
clbChannels.CheckOnClick = true;

clbChannels.ItemCheck += (s, e) =>
{
    // 此时 CheckedItems 还是旧状态
    BeginInvoke(() =>
        lblCount.Text = $"已启用 {clbChannels.CheckedItems.Count} 个通道");
};

还有一个经常被忽略的点:CheckedItems 里不仅有“已勾选”的项,也包含“不确定(Indeterminate)”状态的项。 后面会具体说明。

一、CheckedListBox 和 ListBox 多选是两套东西

上一篇讲 ListBox 多选时提到过:CheckedListBox 的 SelectionMode 只能是 None 或 One,设成多选模式会抛 ArgumentException。

它内部其实有两种独立状态:

状态 读取方式 含义
选中(高亮) SelectedIndex / SelectedItem 当前焦点所在行,最多一项
勾选 CheckedItems / CheckedIndices / GetItemCheckState 每项的复选框状态

很多“勾选读不对”的问题,都是把这两种状态混在一起了。比如用 SelectedItem 判断用户勾了什么,结果拿到的只是最后点过的那一行。

业务逻辑只看勾选状态,高亮选中只用于“查看详情”这类辅助用途。

二、几个反直觉的行为,以及背后的原因

1. 为什么要点两次才能勾上

CheckOnClick 默认是 false。这时第一次单击只是把这一行设为选中(高亮),第二次单击才切换复选框。这个设计沿用的是早期 Windows 列表控件的交互习惯。

对现在的用户来说,这几乎总被当成 Bug。除非有特殊理由,创建控件后第一件事就是把它改成 true。

2. ItemCheck 触发时,状态还没变

这是 CheckedListBox 最大的坑。ItemCheck 的命名看起来像“已勾选”,实际上它是一个变更前通知,设计目的是让你有机会修改甚至否决这次变化:

// ❌ 用户刚勾上第 3 项,这里打印的数量还不包含它
clbChannels.ItemCheck += (s, e) =>
{
    Debug.WriteLine(clbChannels.CheckedItems.Count);
};

事件参数里有三样东西:

  • e.Index:正在变化的项;
  • e.CurrentValue:变化前的状态;
  • e.NewValue:即将变成的状态,可读可写。

把 e.NewValue 改回 e.CurrentValue,就相当于阻止了这次勾选。这一点后面做“禁止勾选某些项”时会用到。

WinForms 的 CheckedListBox 没有提供“勾选之后”的事件(ListView 有 ItemChecked,这里没有)。所以“状态改变后统一处理”需要自己构造时机。

3. 代码调用 SetItemChecked 也会触发 ItemCheck

SetItemChecked 和 SetItemCheckState 内部走的是同一条路径,同样会触发 ItemCheck。于是常见的几个问题就出现了:

  • 加载配置时恢复勾选,结果触发了一串“用户勾选了 xx”的业务逻辑;
  • 全选 200 项,事件触发 200 次;
  • 在 ItemCheck 里调用 SetItemChecked 修改别的项,会递归触发 ItemCheck,写“全选项联动”时很容易绕进去。

4. 勾选状态记在索引上,不在数据上

CheckedListBox 的勾选状态是控件内部按项维护的,和你的数据对象没有关系。这带来两个后果:

  • 设置 DataSource 或清空重建 Items 后,所有勾选状态全部丢失;
  • 做搜索过滤时,被过滤掉的项连同勾选状态一起消失,用户清空搜索框后,之前勾的全没了。

5. DataSource 在设计器里看不到,但可以用

CheckedListBox 的 DataSource、DisplayMember、ValueMember 在属性窗口里被隐藏了,不少人以为它不支持绑定。实际上在代码里设置是可以的,只是勾选状态不参与绑定,不能像 ComboBox 那样把勾选结果双向绑定到模型属性上。

处理“勾选之后”时机的几种方式

方案 优点 代价 / 风险 适用场景
在 ItemCheck 里用 e.NewValue 推算 同步、无延迟 只能推算当前项,统计全体要自己算 只关心当前这一项
BeginInvoke 推迟处理 读到的是完整新状态 批量操作时会排队多次 统计数量、刷新按钮状态
监听 MouseUp / KeyUp 只响应用户操作 漏掉代码修改,键盘逻辑要补全 不推荐作为主方案
模型维护勾选集合 状态可靠、可持久化 需要同步一层 正式业务界面

实际项目里,我一般是第一种和第四种结合:在 ItemCheck 里根据 e.NewValue 更新模型,界面上的统计信息从模型读取,这样根本不需要关心控件状态是否已经更新。

三、怎么做:从基础用法到可复用的勾选管理

WinForm CheckedListBox 勾选状态管理演示界面

1. 最小可用版本:读取勾选结果

先把读取方法统一一下,避免到处写 Cast:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using CheckedListBoxDemo.Models;

namespace CheckedListBoxDemo.Controls
{
    public static class CheckedListBoxExtensions
    {
        public static List<T> GetChecked<T>(this CheckedListBox clb) =>
            Enumerable.Range(0, clb.Items.Count)
                .Where(i => clb.GetItemCheckState(i) == CheckState.Checked)
                .Select(i => (T)clb.Items[i]!)
                .ToList();

        public static void SetChecked(this CheckedListBox clb, Func<int, bool> predicate)
        {
            clb.BeginUpdate();
            try
            {
                for (int i = 0; i < clb.Items.Count; i++)
                {
                    bool target = predicate(i);
                    bool current = clb.GetItemCheckState(i) == CheckState.Checked;
                    if (current != target)
                        clb.SetItemChecked(i, target);
                }
            }
            finally
            {
                clb.EndUpdate();
            }
        }

        public static void CheckAll(this CheckedListBox clb, bool value) =>
            clb.SetChecked(_ => value);

        public static void BindFlags<T>(this CheckedListBox clb, T value)
            where T : struct, Enum
        {
            clb.BeginUpdate();
            try
            {
                clb.Items.Clear();
                long bits = Convert.ToInt64(value);
                foreach (T f in Enum.GetValues<T>())
                {
                    long fb = Convert.ToInt64(f);
                    if (fb == 0) continue;
                    if ((fb & (fb - 1)) != 0) continue;
                    clb.Items.Add(new EnumItem<T>(f), (bits & fb) == fb);
                }
            }
            finally
            {
                clb.EndUpdate();
            }
        }

        public static T GetFlags<T>(this CheckedListBox clb)
            where T : struct, Enum
        {
            long bits = clb.GetChecked<EnumItem<T>>()
                .Aggregate(0L, (acc, item) => acc | Convert.ToInt64(item.Value));
            return (T)Enum.ToObject(typeof(T), bits);
        }
    }
}

这里刻意没有用 CheckedItems,因为它会把 Indeterminate 状态的项也算进去。如果你的界面用到了三态,直接用 CheckedItems.Count 统计“已启用数量”就会多算。只用两态的界面两种写法结果一样,但统一用 GetItemCheckState 更不容易出错。

如果用了 DataSource 绑定 DataTable,Items[i] 是 DataRowView,泛型参数要相应调整。

2. 批量设置勾选:全选、全不选、按条件勾选

和 ListBox 多选一样,批量操作要收口,统一处理重绘和事件:

public static void SetChecked(
    this CheckedListBox clb, Func<int, bool> predicate)
{
    clb.BeginUpdate();
    try
    {
        for (int i = 0; i < clb.Items.Count; i++)
        {
            bool target = predicate(i);
            bool current = clb.GetItemCheckState(i) == CheckState.Checked;
            if (current != target)
                clb.SetItemChecked(i, target);
        }
    }
    finally
    {
        clb.EndUpdate();
    }
}

public static void CheckAll(this CheckedListBox clb, bool value) =>
    clb.SetChecked(_ => value);
  • 状态没变的项不调用 SetItemChecked,减少 ItemCheck 触发次数;
  • BeginUpdate / EndUpdate 只抑制重绘,不抑制事件,事件还需要下一节的标志位配合。

按条件勾选,比如“勾选所有模拟量通道”:

clbChannels.SetChecked(i => ((Channel)clbChannels.Items[i]).IsAnalog);

3. 区分用户勾选和代码勾选

窗体里加一个抑制标志,代码批量修改时跳过逐项处理,结束后统一刷新一次:

private bool _batchChecking;

private void RunBatch(Action action)
{
    _batchChecking = true;
    try { action(); }
    finally { _batchChecking = false; }
    RefreshSummary();
}

private void clbChannels_ItemCheck(object sender, ItemCheckEventArgs e)
{
    if (_batchChecking) return;

    // 用户操作:此时状态尚未改变,推迟到改变之后再统计
    BeginInvoke(RefreshSummary);
}

private void RefreshSummary()
{
    int n = clbChannels.GetChecked<Channel>().Count;
    lblCount.Text = $"已启用 {n} / {clbChannels.Items.Count}";
    btnApply.Enabled = n > 0;
}

调用:

btnCheckAll.Click += (s, e) => RunBatch(() => clbChannels.CheckAll(true));

两个细节:

  • try/finally 不要省,标志位卡在 true 会让后续所有勾选都不再更新界面;
  • BeginInvoke 要求窗体句柄已创建。在构造函数里就触发勾选(比如初始化时恢复配置)可能会抛异常,恢复配置统一用 RunBatch 包起来,正好跳过这条路径。

4. “全选”复选框与列表的双向联动

列表上方放一个 CheckBox 作为“全选”,是非常常见的需求。难点在于两个方向都会互相触发:

  • 点“全选” → 批量勾选列表 → 列表的 ItemCheck 触发;
  • 列表项变化 → 更新“全选”框状态 → “全选”框的 CheckedChanged 触发 → 又去批量勾选列表。

用前面的 RunBatch 加一个同步标志即可:

private bool _syncingHeader;

private void chkAll_CheckedChanged(object sender, EventArgs e)
{
    if (_syncingHeader) return;
    bool value = chkAll.CheckState == CheckState.Checked;
    RunBatch(() => clbChannels.CheckAll(value));
}

private void UpdateHeaderState()
{
    int n = clbChannels.GetChecked<Channel>().Count;
    _syncingHeader = true;
    try
    {
        chkAll.CheckState =
            n == 0 ? CheckState.Unchecked :
            n == clbChannels.Items.Count ? CheckState.Checked :
            CheckState.Indeterminate;
    }
    finally { _syncingHeader = false; }
}

把 UpdateHeaderState() 放进 RefreshSummary() 里调用就形成了闭环。

“全选”框的 ThreeState 保持 false 即可:代码仍然可以把它设为 Indeterminate 来表示部分选中,而用户点击时只会在勾选和未勾选之间切换,不会切到一个“部分选中”的状态让人困惑。

不要把“全选”做成 CheckedListBox 的第一项。 这样做看起来省了一个控件,但索引全部偏移一位,统计、保存、过滤都要特殊处理,而且第一项的 ItemCheck 里修改其他项正是前面说的递归问题。

5. 阻止某些项被勾选

有些通道当前硬件不支持,或者权限项需要更高级别的账号才能修改,希望显示出来但不能勾。CheckedListBox 没有“禁用单项”的属性,可以在 ItemCheck 里否决:

private void clbChannels_ItemCheck(object sender, ItemCheckEventArgs e)
{
    var ch = (Channel)clbChannels.Items[e.Index];
    if (!ch.IsAvailable && e.NewValue == CheckState.Checked)
    {
        e.NewValue = e.CurrentValue;   // 撤销本次变化
        return;
    }
    if (_batchChecking) return;
    BeginInvoke(RefreshSummary);
}

这种做法的问题是视觉上看不出哪些项不可用,用户点了没反应会以为程序卡了。比较完整的做法是开启 DrawMode 自绘,把不可用项画成灰色;但 CheckedListBox 的自绘支持并不友好,复选框需要自己用 CheckBoxRenderer 画。如果这类需求较多,换成 ListView(CheckBoxes = true)或 DataGridView 的复选框列会更省事。

一个折中办法是在显示文本上标注,例如 "AI-07(未接线)",配合否决逻辑,成本最低。

6. 勾选状态保存在模型里:解决过滤和刷新丢失

前面提到勾选状态绑在控件索引上,一旦重建列表就丢失。解决思路是:模型里用一个 HashSet 记录已勾选的主键,控件只是它的一个视图。

private readonly HashSet<string> _checkedKeys = new();
private List<Channel> _allChannels = new();

private void clbChannels_ItemCheck(object sender, ItemCheckEventArgs e)
{
    if (_batchChecking) return;
    var ch = (Channel)clbChannels.Items[e.Index];

    if (e.NewValue == CheckState.Checked) _checkedKeys.Add(ch.Address);
    else _checkedKeys.Remove(ch.Address);

    RefreshSummary();   // 统计读 _checkedKeys,不需要 BeginInvoke
}

这里用 e.NewValue 直接更新模型,统计数据从 _checkedKeys 读取,完全绕开了“控件状态还没更新”的问题,也就不再需要 BeginInvoke。

然后用一个方法根据过滤条件重建列表,并从模型恢复勾选:

private void ApplyFilter(string keyword)
{
    var visible = _allChannels
        .Where(c => string.IsNullOrEmpty(keyword) || c.Name.Contains(keyword))
        .ToList();

    RunBatch(() =>
    {
        clbChannels.BeginUpdate();
        try
        {
            clbChannels.Items.Clear();
            foreach (var c in visible)
                clbChannels.Items.Add(c, _checkedKeys.Contains(c.Address));
        }
        finally { clbChannels.EndUpdate(); }
    });
}

Items.Add(item, isChecked) 这个重载可以在添加时直接设置勾选状态,比添加后再循环 SetItemChecked 更简洁。

这样处理后:

  • 搜索过滤时,隐藏项的勾选状态保存在 _checkedKeys 里,清空搜索框后全部恢复;
  • 从 PLC 重新加载通道列表,只要地址不变,勾选就能保留;
  • “已启用数量”统计的是全部通道,而不只是当前可见的,这一点要在界面上说清楚,否则用户看到“已启用 12 个”、列表里只勾了 3 个,会产生疑惑。

注意:使用了这种模型方式后,前面的“全选”应该明确语义: 是全选当前可见项,还是全选所有项?两种都合理,但必须二选一并在按钮文字上体现,例如“全选(当前筛选结果)”。批量操作时也要同步更新 _checkedKeys,因为 RunBatch 会跳过 ItemCheck 中的模型更新逻辑。

7. 和 Flags 枚举互转:权限、报警等级配置

报警订阅、权限配置这类需求,常常最终存成一个 [Flags] 枚举或整数位掩码。CheckedListBox 和它天然契合:

[Flags]
public enum AlarmLevel
{
    None    = 0,
    Info    = 1,
    Warning = 2,
    Error   = 4,
    Fatal   = 8
}

public static void BindFlags<T>(this CheckedListBox clb, T value)
    where T : struct, Enum
{
    clb.Items.Clear();
    long bits = Convert.ToInt64(value);
    foreach (var f in Enum.GetValues<T>())
    {
        long fb = Convert.ToInt64(f);
        if (fb == 0) continue;              // 跳过 None
        clb.Items.Add(f, (bits & fb) == fb);
    }
}

public static T GetFlags<T>(this CheckedListBox clb)
    where T : struct, Enum
{
    long bits = clb.GetChecked<T>().Aggregate(0L, (acc, f) => acc | Convert.ToInt64(f));
    return (T)Enum.ToObject(typeof(T), bits);
}

调用:

clbAlarm.BindFlags(config.SubscribedLevels);
// 保存时
config.SubscribedLevels = clbAlarm.GetFlags<AlarmLevel>();

需要注意:

  • 值为 0 的成员要跳过,否则 (bits & 0) == 0 恒成立,None 永远显示为勾选;
  • 组合成员(如 All = Info | Warning | Error | Fatal)也要过滤掉,否则它会作为一个独立选项出现,和单个选项的勾选状态互相矛盾。可以只保留二进制中只有一位为 1 的成员:fb != 0 && (fb & (fb - 1)) == 0;
  • 显示中文可以复用上一篇 ComboBox 里讲的 Description 特性方案,把 Items 换成 EnumItem<T> 对象,并设置 DisplayMember;
  • Enum.GetValues<T>() 需要 .NET 5 及以上,.NET Framework 下改用 Enum.GetValues(typeof(T)).Cast<T>()。

8. 三态:表示“部分启用”

如果列表项本身代表一个分组(比如一个模块下有 8 个通道,部分启用),可以用 Indeterminate:

clbModules.SetItemCheckState(i, CheckState.Indeterminate);

用户点击三态项时,只会在 Checked 和 Unchecked 之间切换,不能通过点击回到 Indeterminate。这通常也符合预期:点一下表示“整组全部启用或全部禁用”。

但要记住两点:

  • CheckedItems 会包含 Indeterminate 项,统计“已启用模块”时要用 GetItemCheckState 区分;
  • 分组和子项的联动逻辑,本质上已经是树形结构了。如果层级超过一层,用 TreeView(CheckBoxes = true)更合适。

四、边界:什么时候不该用 CheckedListBox

适合的场景

  • 选项数量在几个到几百个之间的扁平列表;
  • 勾选结果需要保存为配置:通道启用、报警订阅、导出字段、权限位;
  • 用户需要清楚看到“哪些勾了、哪些没勾”,且不希望误点一下就清空。

不太适合的场景

1. 每项需要展示多列信息。 通道名、地址、类型、当前值都要显示时,CheckedListBox 只能拼接字符串。ListView 的 Details 视图加 CheckBoxes = true,或者 DataGridView 加 DataGridViewCheckBoxColumn,都更合适。ListView 还提供了“勾选之后”的 ItemChecked 事件。

2. 有层级关系。 “模块 → 通道”“菜单 → 功能点”这类结构,用 TreeView 的复选框模式,父子联动逻辑更自然。

3. 选项数量非常大。 CheckedListBox 没有虚拟模式,项很多时加载和全选都会变慢。具体阈值取决于设备,建议在目标工控机上实测。数据量大时,可以先用搜索或分组缩小范围,或改用 ListView 的 VirtualMode,不过虚拟模式下的复选框状态需要自己管理。

4. 需要数据绑定勾选状态。 如果项目整体是 MVVM 风格,希望勾选状态直接绑定到模型的 bool 属性,CheckedListBox 做不到。DataGridView 绑定 BindingList<T>,把勾选列绑到 IsEnabled 属性,会更符合这种架构。

5. 需要禁用、着色单个项。 前面讲过,CheckedListBox 的自绘支持有限,这类需求多了,维护成本会快速上升。

维护成本

本文的方案引入了几条约定:批量勾选走 RunBatch,业务状态以 _checkedKeys 为准,列表重建走 ApplyFilter。最容易出问题的是有人绕过模型,直接调用 SetItemChecked,这时控件和 _checkedKeys 就不一致了。

如果项目里有多个这类配置界面,可以把模型集合、批量抑制、过滤恢复、Flags 互转封装成一个继承 CheckedListBox 的自定义控件,对外只暴露 CheckedKeys 属性和一个在状态更新之后才触发的 CheckedChanged 事件,把“ItemCheck 时机不对”这件事彻底隐藏在控件内部。

写在最后

CheckedListBox 用起来简单,真正需要理清的是两件事:ItemCheck 是变更前事件,以及勾选状态属于控件索引而不属于数据。前者决定了你在哪里读状态,后者决定了你要不要把状态搬到模型里。

如果你正在维护已有的配置界面,可以先检查一下:搜索过滤或刷新列表之后,之前的勾选还在不在;ItemCheck 里是否直接读取了 CheckedItems.Count。

你在做通道或权限配置时,会选 CheckedListBox,还是更倾向用 DataGridView 的复选框列?




上一篇:Discord客户端弃Electron转Rust:内存省70%的GPUI重构路径解析
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-13 14:44 , Processed in 0.104676 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表