简介:这份资源面向桌面应用开发者,提供一种将复选框与组合框整合的自定义控件类,用于在有限界面中完成多选操作,并清晰展示勾选状态。控件适合设置偏好、筛选条件等场景,也适合初中级开发者学习控件封装与用户交互事件处理。压缩包共2个文件,即1个C++源文件与1个头文件,整体约5KB,代码紧凑,便于逐行研读和二次改造。目前已有261人学习浏览。通过阅读源码,可以掌握在组合框列表项中嵌入复选框的实现思路,了解类的公开接口、私有成员和事件响应(如选择改变、勾选改变)如何组织;同时,原实现包含的数据库连接代码经注释或删除即可剥离,这能帮助开发者体会界面逻辑与数据访问解耦的技巧。整体而言,这份小体量资源兼具实用性和教学价值,适合作为控件定制的入门示例或工程参考。
1. 为什么需要「含有checkbox的combox控件类」:多选下拉从来不是原生能力
做 WinForms 时间稍长一点,几乎都会撞上同一个需求:界面上要一个下拉框,每个选项前面带一个 checkbox,让用户能一次性勾选多个值。原生 ComboBox 只支持单选,CheckedListBox 又没有「收起 / 展开」的交互外壳,于是「含有 checkbox 的 combox 控件类」就成了一个被反复搜索和重复实现的东西——这里的 combox 就是 ComboBox 的常见简写。这类控件适合权限分配、多条件筛选、标签选择等配置型界面;在 .NET 6 / 8 的 WinForms 里依然没有现成组件,自己封装一个并不难,但边界条件比想象中多。下面按实现路线、核心代码、参数边界和踩坑记录四个层面,把这个控件类一次讲透,保证你照着能拼出一个能用的版本。
2. 自绘还是嵌套:CheckedComboBox 的实现路线与数据模型设计
2.1 两条路线对比:自绘 DrawItem 和 ToolStripDropDown 嵌套
ComboBox 原生不支持多选,要做的是「看起来是 ComboBox,点开是 CheckedListBox」。常见实现有两条路线。
路线 A:继承 ComboBox,重写 DrawItem,在每一项前手动画一个 checkbox。这条路代码量不大,但复选框的勾选状态要自己维护,而且点击 checkbox 区域时下拉框非常容易收起——因为 ComboBox 在 DropDownList 模式下把鼠标释放当作选中行为,自绘出来的 checkbox 并没有真正的控件焦点。路线 B:把 CheckedListBox 装进 ToolStripDropDown,在 ComboBox 的 DropDown 事件里弹出自定义面板。这条路更可靠,控件本身还保留 ComboBox 的 DataSource / DisplayMember / ValueMember 语义,业务代码不需要改变绑定习惯。
我一般选路线 B,原因是 CheckedListBox 自带了 ItemCheck 事件、CheckedItems 集合和滚动条,复用这些能力能省掉大量状态管理代码。做成控件类而不是窗体内散写逻辑,是因为它要被多个窗体复用:权限配置界面要选角色,筛选面板要选标签,报表条件要选门店,相同的交互重复出现,封装成类才是可持续的做法。
| 对比维度 | 自绘 DrawItem | ToolStripDropDown 嵌套 |
|---|---|---|
| 代码量 | 中 | 中 |
| 复选框状态管理 | 自己维护 | 复用 CheckedListBox |
| 点击复选框导致下拉收起 | 极易出现 | 基本不会 |
| 数据绑定 | 要手写 DisplayMember 解析 | 原生绑定可用 |
| 键盘操作 | 需要自行实现 | 基本可用 |
2.2 数据模型:0 和 1 怎么变成 CheckState
CheckedComboBox 绑定的数据源和普通 ComboBox 一样,通常是一个List<T>。但T里如果只有一个int类型的 0/1 字段,不能直接驱动复选框的勾选状态——CheckedListBox 只认 Items 和 CheckState,它并不会自动把 0 理解成「未勾选」。常见做法是在 DTO 里加一个只读 bool 属性,绑定和显示都用这个属性:
public class TagDto { public int Id { get; set; } public string Name { get; set; } public int IsEnabled { get; set; } // 数据库存的是 0 / 1 public bool IsEnabledChecked => IsEnabled == 1; }这里的关键是:CheckedComboBox 的勾选状态和 int 字段之间没有魔法,必须有一个显式的转换层。加只读属性是最省事的方案,改 DTO 结构比改控件逻辑代价低得多。如果数据源是 DataTable,就不建议在 DTO 层转 bool,等到 DataGridView 那一层再用 TrueValue / FalseValue 处理,第五部分会单独给代码。
2.3 为什么不能把 CheckBox 直接塞进 ComboBox
有一个常见的误解:把若干个 CheckBox 控件 Add 到 ComboBox 里,不就实现多选了吗?实际做不到。ComboBox 的下拉列表属于原生 ListBox 绘制区域,不是控件容器,Add 进去的 CheckBox 不会跟随滚动,也不会随下拉面板显示。所以任何「直接把 checkbox 塞进 combox」的做法,本质都逃不开自绘或嵌套这两条路。这条认知决定了后面所有代码的组织方式:把 CheckedListBox 作为私有字段封装在控件类内部,对外只暴露数据源和勾选结果。
3. 手写一个 CheckedComboBox 控件类:核心代码与事件回调
3.1 控件骨架:ComboBox + ToolStripDropDown + CheckedListBox 的拼装
下面是一个可以直接复制到类库项目里的控件骨架。它继承 ComboBox,内部维护一个 CheckedListBox 和一个 ToolStripDropDown:
using System; using System.Collections.Generic; using System.ComponentModel; using System.Drawing; using System.Runtime.InteropServices; using System.Windows.Forms; public class CheckedComboBox : ComboBox { private readonly CheckedListBox _checkedListBox; private readonly ToolStripDropDown _dropDown; private readonly ToolStripControlHost _host; private bool _opening; private string _separator = "、"; private const int CB_SHOWDROPDOWN = 0x014F; [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); public CheckedComboBox() { DropDownStyle = ComboBoxStyle.DropDownList; _checkedListBox = new CheckedListBox { CheckOnClick = true, IntegralHeight = false, BorderStyle = BorderStyle.None }; _checkedListBox.ItemCheck += OnItemCheck; _host = new ToolStripControlHost(_checkedListBox) { AutoSize = false, Margin = Padding.Empty, Padding = Padding.Empty }; _dropDown = new ToolStripDropDown { AutoClose = true, AutoSize = false }; _dropDown.Items.Add(_host); _dropDown.Closed += (s, e) => SyncText(); } protected override void OnDropDown(EventArgs e) { if (_checkedListBox.Items.Count == 0) return; base.OnDropDown(e); if (_opening) return; _opening = true; try { // 原生下拉列表此时已经弹出,立即通知系统关闭它 SendMessage(Handle, CB_SHOWDROPDOWN, IntPtr.Zero, IntPtr.Zero); int panelWidth = Math.Max(Width, _checkedListBox.PreferredWidth + 20); int panelHeight = Math.Min(200, _checkedListBox.PreferredHeight + 4); _host.Size = new Size(panelWidth, panelHeight); _dropDown.Size = new Size(panelWidth, panelHeight); _dropDown.Show(this, new Point(0, Height)); _checkedListBox.Focus(); } finally { _opening = false; } } }这段代码有几个地方不能改错。DropDownStyle必须设成DropDownList,否则用户可以在文本框里输入字符,破坏多选语义。ToolStripControlHost是连接ToolStripDropDown与CheckedListBox的桥梁,AutoSize = false是为了让面板尺寸完全由代码控制;如果不关掉 AutoSize,_dropDown.Size会被内容撑开,宽度忽大忽小。SendMessage用来压制原生下拉列表,这是整个控件唯一有点「玄学」的地方:OnDropDown触发时原生列表已经展开,必须立刻让它关闭,否则会出现两个面板叠在一起的闪屏。_opening标志是为了防止_dropDown.Show引发的焦点变化再次进入OnDropDown,造成递归展开。
3.2 数据源接线:DataSource / DisplayMember / ValueMember 同步
CheckedListBox 和 ComboBox 是并排存在的两个控件,它们必须指向同一个数据源,否则用户会在 ComboBox 上看到项 A,展开后 CheckedListBox 里却是项 B。解决方式是覆写 ComboBox 的绑定相关方法,同步转发给 CheckedListBox:
protected override void OnDataSourceChanged(EventArgs e) { base.OnDataSourceChanged(e); _checkedListBox.DataSource = DataSource; } protected override void OnDisplayMemberChanged(EventArgs e) { base.OnDisplayMemberChanged(e); _checkedListBox.DisplayMember = DisplayMember; } protected override void OnValueMemberChanged(EventArgs e) { base.OnValueMemberChanged(e); _checkedListBox.ValueMember = ValueMember; }要注意,CheckedListBox 的DataSource属性类型是 object,但实际接受IList或IListSource。List<T>实现了IList,可以直接赋值;DataTable也可以。最容易翻车的是DisplayMember/ValueMember字符串,CheckedListBox 和 ComboBox 两边必须完全一致,比如 ComboBox 写的是ValueMember = "Id",CheckedListBox 却没同步,勾选结果就取不到主键值。所以这三个覆写方法不能省,它是控件能不能「像普通 ComboBox 一样被使用」的关键。
3.3 对外取值:CheckedValues 与 CheckedItemsText
控件使用者最关心的两个输出:勾选了哪些值、界面上显示什么文本。下面两个属性把内部 CheckedListBox 的细节藏住:
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] [Browsable(false)] public List<object> CheckedValues { get { var result = new List<object>(); foreach (var item in _checkedListBox.CheckedItems) { if (string.IsNullOrEmpty(ValueMember)) { result.Add(item); continue; } var prop = TypeDescriptor.GetProperties(item).Find(ValueMember, true); if (prop != null) result.Add(prop.GetValue(item)); else result.Add(item); } return result; } } public string CheckedItemsText { get { var parts = new List<string>(); foreach (var item in _checkedListBox.CheckedItems) { if (string.IsNullOrEmpty(DisplayMember)) { parts.Add(item.ToString()); continue; } var prop = TypeDescriptor.GetProperties(item).Find(DisplayMember, true); parts.Add(prop != null ? prop.GetValue(item)?.ToString() : item.ToString()); } return string.Join(_separator, parts); } }这里的TypeDescriptor.GetProperties不是随口写的。绑定 DataTable 时,CheckedListBox 的每一项其实是DataRowView,它的列不是 CLR 属性,用item.GetType().GetProperty(ValueMember)会拿到 null;TypeDescriptor能走ICustomTypeDescriptor通道找到列值。如果这个细节不处理,CheckedValues返回的全是DataRowView对象,后续写库根本用不了。CheckedItemsText给界面显示用,默认用顿号分隔,拼接出来的字符串可以直接塞进 ComboBox 的 Text。
3.4 勾选回调与文本刷新
勾选动作发生在 CheckedListBox 里,文本刷新要立即反馈。需要注意ItemCheck事件的触发时机:事件触发时e.NewValue已经是目标状态,但CheckedItems集合还没更新。如果直接读CheckedItems刷新文本,会看到「勾了第一项没显示、勾第二项才显示第一项」的慢一拍现象。解决办法是用BeginInvoke把刷新动作排到 UI 线程消息队列尾部:
private void OnItemCheck(object sender, ItemCheckEventArgs e) { BeginInvoke(new Action(() => { Text = CheckedItemsText; })); } private void SyncText() { Text = CheckedItemsText; Invalidate(); }BeginInvoke的延迟只有几毫秒,肉眼无感,但能保证读到的是包含本次勾选结果的最新集合。Invalidate是为了让控件立刻重绘,避免文本改了但画面还停留在旧内容上。这套写法同样避免在ItemCheck事件里给 CheckedListBox 重新赋值 DataSource,一旦重绑,整个下拉面板的宿主都会被重建,下拉框会当场关闭,这是后面避坑部分要重点说的。
4. 避坑清单:下拉即收、焦点丢失与 0/1 显示成 checkbox 的五个坑
4.1 勾一下复选框,整个下拉面板立刻收起来
现象:下拉面板展开后,用户刚点一个 checkbox,面板闪一下就关闭了,没法连续勾选。原因一般有三个:一是用了自绘 DrawItem 方案,点击 checkbox 区域被 ComboBox 当成外部点击;二是在 ItemCheck 事件里重新设置了_checkedListBox.DataSource,导致宿主控件被重建;三是CheckOnClick = false时用户需要点两下完成勾选,第二下焦点已经落到面板外部,触发了ToolStripDropDown.AutoClose。解决:优先采用嵌套方案而不是自绘;ItemCheck 和文本刷新逻辑里绝不重绑 DataSource;把CheckOnClick = true,让勾选动作一次完成。AutoClose = true本身不用动,它只在点击外部区域时关闭面板。
4.2 勾了三项,控件文本只显示最后一项
现象:下拉关闭后 Text 显示成「C」,而不是「A、B、C」。原因是 ComboBox 在DropDownList模式下会把 SelectedItem 和 Text 联动,CheckedListBox 里只要有一项被选中,ComboBox.SelectedIndex 就会变化,Text 被单项覆盖。解决:在SyncText里强制把 Text 设成拼接串。这里会带来一个附带行为:拼接串在数据源里找不到匹配项,ComboBox 会把SelectedIndex置为 -1,这是正常的,不要再去把 SelectedIndex 找回来,否则拼接文本又会被冲掉。这也是这个控件看起来「属性没坏,但行为跟原生 ComboBox 不一样」的最大来源。
4.3 DataGridView 绑定 List ,0/1 列不显示成 checkbox
现象:dataGridView.DataSource = list之后,存 0/1 的列显示成数字,而不是复选框。原因:DataGridView 自动生成列时,int类型会生成DataGridViewTextBoxColumn,不会自己变成 CheckBoxColumn。解决:把这一列替换成DataGridViewCheckBoxColumn,设置TrueValue和FalseValue:
var checkCol = new DataGridViewCheckBoxColumn { DataPropertyName = "IsEnabled", HeaderText = "启用", TrueValue = 1, FalseValue = 0, ThreeState = false };这个就是搜索词「winform datagridview 将list 的一列0和1的值显示为checkbox」对应的核心处理,完整嵌入方式在 5.3 再展开。
4.4 数据源刷新后,之前勾的项全部丢失
现象:界面刷新时调用CheckedComboBox.DataSource = newList,结果之前的勾选全部清空。原因:CheckedListBox 的 DataSource 变更会重建内部 Items,CheckedItems 随之清空,这和 ComboBox 的 SelectedIndex 丢失是同一个底层行为。解决:刷新前用CheckedValues把勾选值存到临时列表,刷新完成后遍历新数据源,按ValueMember的值匹配,再调用SetItemChecked(index, true)。注意匹配不能用对象引用,刷新后每一项都是新对象。具体的ReloadDataSource方法在第六部分给出。
4.5 绑定 DataTable 后,CheckedValues 变成一堆 DataRowView
现象:处理勾选结果时,发现CheckedValues里不是主键值,而是DataRowView对象。原因:DataTable 作为 DataSource 时,CheckedListBox 的每一项就是DataRowView,用常规反射拿不到列值。解决:用TypeDescriptor.GetProperties(item).Find(ValueMember, true)解析,这在 3.3 的代码里已经处理。这条如果不处理,控件绑定 DataTable 场景基本等于废了:业务层拿到的集合既不能排序也不能写库,全部要返工。建议控件类里统一用TypeDescriptor解析属性,而不是反射。
5. 参数详解:从 DropDownHeight 到 CheckOnClick,每个必调参数的边界
5.1 CheckOnClick、ThreeState、IntegralHeight 三个最影响体验的参数
这些参数分散在 ComboBox 和 CheckedListBox 两侧,但共同决定这个控件的交互手感。下面按我实际调参的经验列一张表:
| 参数 | 建议值 | 影响 | 边界提醒 |
|---|---|---|---|
| CheckOnClick | true | 单击文本即勾选 | false 时要双击,用户会以为控件坏了 |
| ThreeState | false | 是否允许半选状态 | true 后可能出现灰色勾,业务层不好判断 |
| IntegralHeight | false | 面板高度是否按整项计算 | true 时高度会被项高强制补齐,底部留白 |
| AutoClose | true | 点击外部区域是否自动收起 | false 后面板可能残留桌面 |
| _dropDown.AutoSize | false | 面板是否跟随内容自动调整 | true 时手动设置的 Size 会被忽略 |
CheckOnClick = true和ThreeState = false是最重要的两个。前者解决连续勾选体验,后者避免三态复选框带来的业务歧义——半选状态在 CheckedValues 里没有对应表达,你很难告诉调用方「这个值到底算选中还是没选中」。IntegralHeight = false解决的是 CheckedListBox 的一项老毛病:默认情况下它会把高度凑成整项高度的整数倍,导致下拉面板底部多出一块空白;关掉之后面板高度完全跟随内容。这些参数写在构造函数里固定,业务方不需要知道它们存在,这才是控件类的封装意义。
5.2 分隔符、空值占位与超长截断
CheckedItemsText默认用中文顿号分隔,但不同业务偏好不同:筛选标签时用顿号自然,导出报表时可能希望逗号。我一般把分隔符做成公开属性DisplaySeparator,默认「、」,调用方可以改成任意字符串。另一个参数是空值占位:当用户一个都不勾时,拼接串为空,DropDownList 模式下的 ComboBox 会显示成一片空白,看起来像没绑定数据。常见做法是加一个EmptyText属性,默认「请选择」,在 SyncText 里判断:
public string EmptyText { get; set; } = "请选择"; private void SyncText() { Text = string.IsNullOrEmpty(CheckedItemsText) ? EmptyText : CheckedItemsText; Invalidate(); }注意EmptyText只是显示层兜底,不会混进CheckedValues,业务方取值时依然拿到空集合。超长截断也是必须处理的边界:勾选十来个选项后,拼接文本早就超出控件宽度,WinForms 不会自动省略。我习惯提供一个MaxDisplayChars,默认 60 个字符,超过就截断并追加省略号。这类参数不起眼,但控件交付给别的项目用的时候,往往就是这些细节决定对方要不要自己再改一版。
5.3 List 的 0/1 列绑定到 DataGridViewCheckBoxColumn:完整落地方案
回到搜索频率最高的场景:List<T>里的 0/1 列要在 DataGridView 里显示成 checkbox。先看完整代码:
var tags = new List<TagDto> { new TagDto { Id = 1, Name = "前端", IsEnabled = 1 }, new TagDto { Id = 2, Name = "后端", IsEnabled = 0 } }; var grid = new DataGridView { DataSource = tags }; int colIndex = grid.Columns["IsEnabled"].Index; var checkCol = new DataGridViewCheckBoxColumn { DataPropertyName = "IsEnabled", HeaderText = "启用", TrueValue = 1, FalseValue = 0, ThreeState = false }; grid.Columns.RemoveAt(colIndex); grid.Columns.Insert(colIndex, checkCol);这里最隐蔽的坑是类型匹配:TrueValue和FalseValue必须与IsEnabled属性的 CLR 类型一致。属性是int,这两个值就写1和0,不能写字符串"1";属性是string,再写字符串。类型不一致时,单元格会显示成空白或直接不响应点击。ThreeState = false是必须的,否则用户可能点出灰色半选状态,数据却只有 0/1 两种取值,反向映射时没法处理。如果不希望手工替换列,也可以在 DTO 里把属性改成bool IsEnabled,DataGridView 自动生成列时就会是 checkbox,但数据库读写时还要再转一次,我一般倾向于在 DataGridView 层解决,避免污染实体类。
6. 最后的打磨:全选/清空、刷新恢复勾选与事件约定
6.1 两个高频方法:CheckAll 与 ClearAll
多选下拉最常用的两个操作是「全选」和「清空」。全选在权限配置界面几乎必用,比如勾选所有角色再逐项取消。实现直接用SetItemChecked,它在代码里触发 ItemCheck 事件,但不会像用户点击那样把下拉面板收起来:
public void CheckAll() { for (int i = 0; i < _checkedListBox.Items.Count; i++) _checkedListBox.SetItemChecked(i, true); SyncText(); } public void ClearAll() { for (int i = 0; i < _checkedListBox.Items.Count; i++) _checkedListBox.SetItemChecked(i, false); SyncText(); }6.2 数据源刷新后恢复勾选
刷新数据源是配置界面最常见的操作之一。可靠做法是刷新前保存勾选值,刷新后按 ValueMember 值重新匹配:
public void ReloadDataSource(object dataSource) { var previous = CheckedValues; DataSource = dataSource; if (previous.Count == 0) return; for (int i = 0; i < _checkedListBox.Items.Count; i++) { var prop = TypeDescriptor.GetProperties(_checkedListBox.Items[i]).Find(ValueMember, true); if (prop == null) continue; var value = prop.GetValue(_checkedListBox.Items[i]); if (previous.Contains(value)) _checkedListBox.SetItemChecked(i, true); } SyncText(); }previous必须提前复制,因为DataSource赋值后 CheckedItems 已经被清空。Contains对值类型和字符串够用;如果 ValueMember 指向引用类型,需要重写比较逻辑,但常见场景里主键都是 int 或 string,可以直接用。
6.3 事件约定:对外只暴露 CheckedChanged
内部 CheckedListBox 的 ItemCheck 事件参数是ItemCheckEventArgs,带着Index、NewValue、OldValue,这些内部细节不适合让调用方直接依赖。我一般会在控件类上重新声明一个简化事件:
public event EventHandler CheckedChanged; private void OnItemCheck(object sender, ItemCheckEventArgs e) { BeginInvoke(new Action(() => { Text = CheckedItemsText; CheckedChanged?.Invoke(this, EventArgs.Empty); })); }这样调用方只关心「勾选集合变了」,不需要知道是哪一个内部控件触发的,也不需要处理事件重复触发。早期我也用过自绘方案,每次交付都有人反馈「勾一下就收起」,后来整体切到 ToolStripDropDown 嵌套,把参数和事件约定固定下来,新项目基本直接复制这个控件类就能用。这些边界和参数都是在一轮轮改配置面板时磨出来的,希望这篇文章能让你拿到手就续写自己的版本,少走一段弯路。
本文还有配套的精品资源,点击获取