在 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 更新模型,界面上的统计信息从模型读取,这样根本不需要关心控件状态是否已经更新。
三、怎么做:从基础用法到可复用的勾选管理

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 的复选框列?