
简介面向 Windows 桌面开发者的复合控件源码把复选框与组合框合二为一在下拉列表的每一项前嵌入 Checkbox让用户在一个紧凑控件内完成多选不必反复展开列表或堆叠一排复选框。适合需要制作偏好设置、条件过滤、多选筛选等界面的中级开发者参考。压缩包共 2 个文件、约 5KB含一个 cpp 实现文件与一个 h 头文件前者承载成员函数、事件响应与勾选状态维护的代码后者定义类结构与公开接口配合阅读即可看清控件轮廓。目前已有 262 人学习。读者可从中了解列表项复选框的绘制、选择变化与勾选变化的处理、单项状态的增删改以及最终选中项的读取方式描述中附带的数据库读写部分可先行剥离专注控件本身的行为逻辑再按项目需要接回数据绑定。1. 一个下拉框装下多个 checkbox这类 combox 类为什么容易翻车做后台管理系统筛选条件里迟早会出现这么个需求订单状态、标签、权限项下拉里每个选项前带个 checkbox选完收起来框上回显「已选 3 项」。原生下拉框只认单选于是很多人去找一个「含有 checkbox 的 combox 类」拿来直接用结果一编译才发现问题一堆面板弹出来闪一下就消失勾完文本不回填DataSource 一绑直接抛异常高 DPI 屏上勾选框和文字还错位。这类控件代码量通常不到一千行翻车点却高度集中都在焦点、绘制、事件时序这三件事上。适合谁正在做 WinForms 或 WPF 后台系统、不想为几个多选筛选就引入一整包 UI 框架的开发者。下面按「能跑、能看懂、能改」的顺序把它拆开。2. 控件选型与技术路线继承 ComboBox 自绘还是组合一个下拉宿主动手写之前先想清楚一件事这个控件的本质不是「一个能勾选的下拉框」而是「一个回显区 一个多选列表 一套弹出收起逻辑」。想明白这点后面所有设计取舍都顺了。2.1 原生 ComboBox 的三个硬伤第一个硬伤是绘制。DropDownStyle 设成 DropDownList 之后下拉部分是系统 ListBox只能显示文本没有 ItemCheck 这种机制。想加勾选框就得开 DrawMode.OwnerDrawFixed 自己画画本身不难难的是画完之后点击要自己算命中。第二个硬伤是回显。SelectedItem 天生只有一项多选状态下它没有「部分选中」这个概念你得自己维护一份勾选集合再拼成一段文本塞回去。第三个硬伤是消息。原生下拉是一个独立的顶层窗口展开时焦点会转移你重写 OnMouseDown 拿到的坐标还得用 IndexFromPoint 换算成行号滚动条一动这个换算就容易算到 -1。// 大多数人第一次尝试的写法继承 ComboBox 开自绘 public class NaiveCheckedCombo : ComboBox { public NaiveCheckedCombo() { DrawMode DrawMode.OwnerDrawFixed; // 打开自绘 DropDownStyle ComboBoxStyle.DropDownList; ItemHeight 20; // 行高后面算命中区域要用 } protected override void OnDrawItem(DrawItemEventArgs e) { if (e.Index 0) return; // 滚动时 e.Index 会是 -1不判会崩 e.DrawBackground(); var item Items[e.Index] as CheckedItem; var box new Rectangle(e.Bounds.Left 2, e.Bounds.Top 2, 14, 14); ControlPaint.DrawCheckBox(e.Graphics, box, item.Checked ? ButtonState.Checked : ButtonState.Normal); TextRenderer.DrawText(e.Graphics, item.Text, e.Font, new Point(e.Bounds.Left 20, e.Bounds.Top 2), e.ForeColor); } }这段代码画是画出来了但它只解决了「看起来有勾选框」。真正点上去没反应因为点击事件落在下拉列表窗口上得再拦 WndProc 的 WM_LBUTTONDOWN、用 IndexFromPoint 反推行号然后手动切换状态并 Invalidate。空格键、上下键、Home/End 全部不生效还得自己补一套键盘逻辑。写到这里基本就会明白继承 ComboBox 这条路省下的只是外观剩下的全是债。2.2 三条实现路线的取舍路线做法优点代价适合继承 ComboBox 自绘OwnerDrawFixed WndProc 拦消息外观最像原生命中判断、键盘、滚动全自己写容易出玄学只读展示、交互极少组合式宿主回显框 ToolStripDropDown 承载 CheckedListBoxCheckedListBox 自带勾选和键盘焦点和自动收起要处理后台系统筛选推荐伪装下拉CheckedListBox 放 Panel 里显隐最简单几百行搞定不悬浮会被父容器裁剪表单内嵌、不遮挡布局我一般选第二条。理由是 CheckedListBox 已经把「点击切换勾选、键盘空格勾选、CtrlA、滚动」这些交互做完了而且是控件库自带、经过大量项目验证的没必要重写。组合式唯一要额外处理的就是两件事下拉面板不能一弹就自动关以及弹出位置要跟着屏幕边界翻。注意ToolStripDropDown 默认 AutoClose 为 true鼠标在下拉里点一下就收这是新手最先撞上的墙。设成 false 之后收起的时机要自己判断。2.3 类骨架与职责划分整套代码我习惯拆成四个文件职责很清楚后面改的时候不至于全搅在一起。文件职责CheckedComboBox.cs主控件回显区 箭头 对外 APICheckedListHost.cs下拉宿主继承 ToolStripDropDown包住 CheckedListBoxCheckedItem.cs数据模型Value / Text / CheckedCheckedComboBoxDesigner.cs设计器支持让属性面板能改 Separator 之类主控件的骨架大概长这样public class CheckedComboBox : UserControl { private readonly TextBox _display; // 只读回显框模拟 ComboBox 的文本区 private readonly Button _arrow; // 右侧箭头点它展开 private readonly CheckedListHost _host; // 下拉宿主 private readonly ListCheckedItem _items new ListCheckedItem(); private bool _dropped; // 当前是否展开 public event EventHandler CheckedItemsChanged; public CheckedComboBox() { SetStyle(ControlStyles.Selectable, true); _display new TextBox { ReadOnly true, BorderStyle BorderStyle.None, Dock DockStyle.Fill }; _arrow new Button { Width 20, Dock DockStyle.Right, FlatStyle FlatStyle.Flat }; _host new CheckedListHost(); _host.List.ItemCheck OnItemCheck; Controls.Add(_display); Controls.Add(_arrow); } }选择 UserControl 而不是 Control 直接自绘是因为回显区要支持文本选中复制用户经常要把「已选的那几个状态」复制出去核对。自绘的话这部分得自己实现不值当。宿主那边这样包internal sealed class CheckedListHost : ToolStripDropDown { public CheckedListBox List { get; } public CheckedListHost() { List new CheckedListBox { CheckOnClick true, // 单击整行就切换勾选不用点小方框 BorderStyle BorderStyle.None, IntegralHeight false // 关掉整行吸附高度才能精确控制 }; var host new ToolStripControlHost(List) { Margin Padding.Empty, Padding Padding.Empty }; Items.Add(host); AutoSize false; AutoClose false; // 关键别点一下就自己关 } }CheckOnClick 是这个控件体验的分水岭开着的话用户点整行就能勾关掉的话必须精确点中那个十四像素的小方框在触控屏和远程桌面上基本没法用。IntegralHeight 关掉是为了让下拉高度严格等于「行数 × 行高」不然最后一行会被裁成半截看着别扭。3. 核心链路拆解从点开箭头到回填「已选 3 项」的完整过程主链路只有四步展开、勾选、回填、收起。每一步都有个容易忽略的细节写的时候不注意后面排查会很难受。3.1 状态字段与文本回显规则回显文本不能简单地把勾中的项 ToString 拼起来因为业务上通常有三种展示诉求全部列出「已通过, 已发货」、只显示数量「已选 3 项」、超过 N 项就截断「已通过 等 3 项」。所以我留了两个属性控制格式。public string Separator { get; set; } , ; // 多值分隔符 public int MaxDisplayItems { get; set; } 2; // 超过这个数量就只显示条数 public string CountFormat { get; set; } 已选 {0} 项; private void RefreshDisplayText() { var checkedTexts _items.Where(i i.Checked).Select(i i.Text).ToList(); if (checkedTexts.Count 0) _display.Text string.Empty; else if (checkedTexts.Count MaxDisplayItems) _display.Text string.Join(Separator, checkedTexts); else _display.Text string.Format(CountFormat, checkedTexts.Count); }MaxDisplayItems 这个参数看着小实际很影响体验。筛选区宽度通常只有两百来像素把六个状态名全拼上去会被截断成「已通过, 已发…」用户反而不知道选了几个。限制成两项加数量提示读起来清爽得多。3.2 弹出位置与屏幕边界处理下拉最常见的 bug 是「往下弹的时候被任务栏盖住」或者「在屏幕最下面那行弹出去看不见」。位置必须按当前屏幕的工作区算而且下方不够要往上翻。private const int MaxDropHeight 240; private void ShowDropDown() { if (_items.Count 0) return; var wa Screen.FromControl(this).WorkingArea; // 当前屏幕可用区域已排除任务栏 int itemH _host.List.ItemHeight; int h Math.Min(MaxDropHeight, Math.Max(itemH, _items.Count * itemH) 2); var p PointToScreen(new Point(0, Height)); // 控件左下角转屏幕坐标 if (p.Y h wa.Bottom) // 下方放不下往上翻 p.Y - Height h; _host.Size new Size(Width, h); _host.Show(p); // Show 重载收屏幕坐标 _dropped true; }PointToScreen 这一步不能省。很多人直接写_host.Show(this, new Point(0, Height))那个重载的坐标是相对父容器的一旦控件放在滚动面板或者 Tab 页里弹出来的位置就飘了。用屏幕坐标这个重载最稳。3.3 勾选事件与文本回填时序是重点这段是整个控件最容易翻车的地方说它是血泪经验一点不夸张。private void OnItemCheck(object sender, ItemCheckEventArgs e) { // 此刻 CheckedItems 里还是旧状态新状态在 e.NewValue 上 BeginInvoke(new Action(() { var item _items[e.Index]; item.Checked e.NewValue CheckState.Checked; // 消息循环结束后再同步模型 RefreshDisplayText(); CheckedItemsChanged?.Invoke(this, EventArgs.Empty); })); }ItemCheck 是在状态真正写进 CheckedListBox 之前触发的。在这时候读List.CheckedItems或者GetItemChecked(i)拿到的统统是旧值所以你会看到「勾上第二个回显里却是第一个」这种诡异现象。解决办法就是 BeginInvoke 把逻辑推到消息循环之后那时候状态已经落地了。如果不在 UI 线程或者不想异步也可以直接手动维护一份_items的勾选状态完全等价。另外事件里同步了模型之后还要考虑「程序化设置勾选」的场景。批量 SetCheckedValues 的时候会连触发几十次 ItemCheck每次都刷新一遍文本和抛事件界面会明显卡顿。批量操作期间挂个_suppressEvents标志位挡掉最后统一抛一次。3.4 键盘与焦点的收尾鼠标能用只是及格键盘才是后台系统的效率来源。至少要支持Alt↓ 展开、Esc 收起、空格切换勾选、回车确认收起。protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (keyData (Keys.Alt | Keys.Down)) { if (!_dropped) ShowDropDown(); _host.List.Focus(); return true; } if (keyData Keys.Escape _dropped) { HideDropDown(); Focus(); return true; } return base.ProcessCmdKey(ref msg, keyData); }重写 ProcessCmdKey 而不是 KeyDown是因为 Alt 组合键会先被窗体的菜单系统截走KeyDown 根本收不到。这个方法返回 true 表示消息已处理不再往下传返回值忘了写就会同时触发两遍逻辑。4. 数据接口与绑定把勾选结果变成业务层能直接用的值控件写完能点只是做了一半。另一半是接口设计——业务层怎么塞数据进去、怎么把结果取出来、刷新之后怎么回显。这部分设计不好控件在项目里活不过两个迭代。4.1 数据模型与 DisplayMemberpublic class CheckedItem { public object Value { get; set; } // 业务主键通常存 int 或 Guid public string Text { get; set; } // 显示文本 public bool Checked { get; set; } // 勾选状态 public object Tag { get; set; } // 备用放整行业务对象 public override string ToString() Text; }Value 和 Text 一定分开。见过不少项目图省事直接往 CheckedListBox 里塞字符串结果业务改了显示文案「已通过」改成「审核通过」数据库里存的筛选条件全对不上只能写脚本迁移。重写 ToString 是为了 CheckedListBox 在没设 DisplayMember 的时候也能正常显示这是个兜底。绑定的时候有个细节CheckedListBox 的 DisplayMember / ValueMember 只管文本和值勾选状态它不管也不会帮你回填。所以数据源绑定和勾选回填是两条独立的路别指望绑完就自动勾上。public void SetDataSource(IEnumerableCheckedItem source) { _items.Clear(); _items.AddRange(source); _host.List.Items.Clear(); foreach (var it in _items) _host.List.Items.Add(it, it.Checked); // 第二个参数直接指定初始勾选状态 RefreshDisplayText(); }Items.Add(item, checked)这个重载是回显的关键。如果用 AddRange 批量加所有项都是未勾选刷新完还得遍历一遍 SetItemChecked白跑一趟。4.2 对外 API 清单成员类型说明CheckedValues只读属性当前勾选的 Value 列表CheckedTexts只读属性当前勾选的显示文本列表SetCheckedValues(IEnumerable)方法按 Value 集合回填勾选常用于表单编辑CheckAll(bool)方法全选 / 全不选一次事件Clear()方法清空勾选保留数据项Separator属性多值分隔符默认逗号空格MaxDisplayItems属性超过几项改为显示数量CheckedItemsChanged事件勾选变化批量操作时只抛一次这套接口刻意没暴露「SelectedIndex」「SelectedItem」因为这个控件语义上是多选的暴露单值属性只会让调用方写出想当然的错代码。4.3 回显与业务对接业务里存筛选条件常见做法是存一段逗号分隔的值字符串比如1,3,5。编辑页回显时拆开匹配就行。// 保存把勾选取出来拼字符串 string saved string.Join(,, combo.CheckedValues.Select(v v.ToString())); // 回显拆字符串转成强类型再匹配避免 int 和 string 比较永远不相等 var ids saved.Split(new[] { , }, StringSplitOptions.RemoveEmptyEntries) .Select(int.Parse) .Castobject() .ToList(); combo.SetCheckedValues(ids); // 查询条件拼接注意空集合要短路不然 IN () 是语法错误 var where ids.Count 0 ? string.Empty : $ AND StatusId IN ({string.Join(,, ids)});最后那个空集合判断是踩过坑才写的。控件不选任何项时ids.Count是 0直接拼IN ()在某些数据库上会直接报语法错误导致用户「不筛选」反而查不出数据。要么短路要么走全选语义这个得跟产品对齐。5. 避坑记录多选下拉框上线后最常翻车的五个地方5.1 五条现场记录每条都按现象查原因第一条点开下拉鼠标一进去面板就消失了。现象是箭头点开能看到列表鼠标移过去列表立刻收起控件文本区还保持着原样。原因在 ToolStripDropDown 的 AutoClose它默认 true任何一次点击都会触发关闭而 CheckedListBox 的点击还没被处理。改法是把 AutoClose 设成 false然后在宿主里监听 List 的 MouseUp用Bounds.Contains(Cursor.Position)判断是否点在面板外是才收起。顺手还要处理父窗体移动和失焦加个 Deactivate 事件同步收起不然弹着下拉切换窗口它会孤零零悬在桌面上。第二条勾了第二项回显里变的是第一项。现象很迷惑用户勾第二个复选框文本区显示的是第一个的名字。原因就是前面说的 ItemCheck 时序事件触发时列表内部状态还没更新读出来的 CheckedItems 是上一次的。改法是用 BeginInvoke 推到消息循环之后或者以 e.NewValue 为准手动写模型。这个坑的迷惑性在于它偶尔又是对的——如果你点得慢、中间有别的消息插进来看起来就正常排查时会怀疑人生。第三条高 DPI 屏上勾选框和文字错位字被切掉一半。现象是在 125% 或 150% 缩放的笔记本上勾选框压在文字上行高明显不够。原因是 ItemHeight 用了固定像素值而字体随 DPI 放大了。改法是不写死行高用TextRenderer.MeasureText(测, Font).Height 6动态算控件构造和字体变更事件里都重算一次。窗体本身设 AutoScaleMode.Dpi别用 Font。另外如果自己重写了 DrawItem绘制文字一定要用 TextRenderer 而不是 Graphics.DrawString后者的度量和控件实际布局对不上在高 DPI 下偏移肉眼可见。第四条数据源刷新后勾选全没了。现象是点了「刷新」重新拉一次字典数据用户之前勾的三个状态没了。原因是 SetDataSource 里Items.Clear()把状态一起清了新数据全是未勾选。改法是在清空之前先把当前勾选的 Value 存到临时集合重建完成后再按 Value 匹配回填匹配不到的项丢弃即可。如果字典里某一项被删了回显里那个值也要同步从保存的字符串里剔掉不然下次打开编辑页会一直有个匹配不上的脏值。第五条放在 Tab 页或滚动面板里下拉位置飘了。现象是控件在第一个 Tab 上正常切到第二个 Tab 或者页面滚动之后下拉弹到屏幕左上角或者控件上方几十像素处。原因是 Show 的时候传了相对坐标父容器一偏移就全错。改法统一用 PointToScreen 转屏幕坐标再 Show别用相对父容器的那个重载。这个坑最隐蔽的地方在于它不是每次都错只在特定布局下错测试不覆盖就带病上线。5.2 排查时该先看哪里这类控件出问题九成集中在三个位置事件的触发时序、坐标系的转换、以及控件树里的焦点归属。我习惯先在两处下断点ItemCheck 里打印e.Index / e.NewValue / List.CheckedItems.Count三个值能立刻分辨是不是时序问题ShowDropDown 里打印 PointToScreen 前后的坐标和 WorkingArea能分辨是不是坐标系问题。焦点问题不好断点通常直接加个GotFocus/LostFocus日志看事件顺序就知道焦点被谁抢走了。提示调试下拉类控件时把「保留焦点在控件内」的检查先关掉让下拉能一直挂着否则一断点就收起什么也看不到。6. 二次开发给它补上搜索过滤和键盘操作再谈上生产原版控件能用了但只要选项超过三十个用户就得在列表里滚半天。生产环境基本都得补一个搜索框。6.1 在下拉宿主里加过滤框思路是在宿主顶部塞一个 TextBox输入时按关键字过滤。这里有个关键决定过滤时隐藏未匹配项而不是从 Items 里移除。移除会让勾选状态跟着丢用户搜「已」勾一个清空搜索再搜「待」勾一个回头发现第一个没了。private void ApplyFilter(string keyword) { // 先挂起事件避免逐项设置时反复触发 ItemCheck _suppressEvents true; try { for (int i 0; i _items.Count; i) { bool match string.IsNullOrEmpty(keyword) || _items[i].Text.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) 0; // 不匹配的项保留在集合里只是视觉上隐藏勾选状态不受影响 _host.List.SetItemChecked(i, _items[i].Checked); _host.List.SetItemCheckState(i, match ? (_items[i].Checked ? CheckState.Checked : CheckState.Unchecked) : CheckState.Indeterminate); } } finally { _suppressEvents false; } }更干净的做法是重写 CheckedListBox 的 DrawItem把不匹配的行绘制成极矮的高度相当于隐藏这样键盘导航也不会停在上面。如果不想动绘制用 Indeterminate 状态临时标记也能凑合但用户会看到一堆灰色方框体验一般。6.2 上生产前的验证清单检查项验证方式不通过的典型表现高 DPI系统缩放切 150% 重启勾选框与文字错位屏幕边界控件拖到屏幕最底部展开下拉被任务栏遮挡键盘操作全程不碰鼠标走一遍Alt↓ 无反应、Esc 关不掉数据回显编辑页保存再打开勾选丢失或错位空值查询一项不选直接查询SQL 语法错误或查不出数据批量操作全选 200 项界面卡顿、事件触发 200 次容器嵌套放进 Tab 页、滚动面板下拉位置偏移这套检查我现在是当固定动作执行的。早年有次改版控件在开发机上一切正常上线后客服反馈「筛选点了没反应」查了半天才发现是某个容器把下拉面板裁掉了开发机用的显示器分辨率高容器够大没暴露。从那以后我每次把这类悬浮控件往新页面里放都强制走一遍「缩到 1024×768 再点一次」的流程宁可多花五分钟也不想半夜被叫起来看黑匣子日志。希望帮到你。本文还有配套的精品资源点击获取