简介:面向 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 承载 CheckedListBox | CheckedListBox 自带勾选和键盘 | 焦点和自动收起要处理 | 后台系统筛选,推荐 |
| 伪装下拉 | CheckedListBox 放 Panel 里显隐 | 最简单,几百行搞定 | 不悬浮,会被父容器裁剪 | 表单内嵌、不遮挡布局 |
我一般选第二条。理由是 CheckedListBox 已经把「点击切换勾选、键盘空格勾选、Ctrl+A、滚动」这些交互做完了,而且是控件库自带、经过大量项目验证的,没必要重写。组合式唯一要额外处理的就是两件事:下拉面板不能一弹就自动关,以及弹出位置要跟着屏幕边界翻。
注意:ToolStripDropDown 默认 AutoClose 为 true,鼠标在下拉里点一下就收,这是新手最先撞上的墙。设成 false 之后,收起的时机要自己判断。
2.3 类骨架与职责划分
整套代码我习惯拆成四个文件,职责很清楚,后面改的时候不至于全搅在一起。
| 文件 | 职责 |
|---|---|
| CheckedComboBox.cs | 主控件,回显区 + 箭头 + 对外 API |
| CheckedListHost.cs | 下拉宿主,继承 ToolStripDropDown,包住 CheckedListBox |
| CheckedItem.cs | 数据模型,Value / Text / Checked |
| CheckedComboBoxDesigner.cs | 设计器支持,让属性面板能改 Separator 之类 |
主控件的骨架大概长这样:
public class CheckedComboBox : UserControl { private readonly TextBox _display; // 只读回显框,模拟 ComboBox 的文本区 private readonly Button _arrow; // 右侧箭头,点它展开 private readonly CheckedListHost _host; // 下拉宿主 private readonly List<CheckedItem> _items = new List<CheckedItem>(); 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 数据模型与 DisplayMember
public 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(IEnumerable<CheckedItem> 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) .Cast<object>() .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 再点一次」的流程,宁可多花五分钟,也不想半夜被叫起来看黑匣子日志。希望帮到你。
本文还有配套的精品资源,点击获取