ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

WinForms多选下拉控件:继承ComboBox并自绘复选框

WinForms多选下拉控件:继承ComboBox并自绘复选框 简介一套面向 Windows 桌面开发者的自定义控件源码实现将复选框Checkbox整合进组合框Combobox的“CheckComboBox”类它解决了普通下拉列表只能单选、无法直观显示多选状态的问题适用于设置偏好、筛选条件等需要多项选择的界面场景。资源压缩包仅 5KB包含 2 个文件CheckComboBox.cpp 提供类实现细节包括成员函数、事件处理与复选状态维护CheckComboBox.h 声明类结构、公共接口与私有成员便于直接理解控件设计描述中提到原有连接数据库的代码可删除因此学习者既能参考数据绑定思路也可剥离数据库部分专注控件本身行为。通过阅读这两个文件可以掌握自定义控件的构造方法、初始化流程及 OnCheckChange 等事件处理技巧并学会如何在实际项目中实例化与获取用户多选结果目前已有 261 人学习下载适合初级至中级 Windows 界面开发者用作控件封装与交互逻辑的参考。1. 带复选框的下拉控件从“只能单选”到“批量勾选”做 WinForms 桌面端的人基本都遇到过这种需求界面上要给用户一个多选下拉比如筛选部门、勾选角色、批量标记状态。原生 ComboBox 只能单选DropDownList 模式下连输入都省了用户点一下列表就收回去了选完想在界面上同时看到“选了哪几项”得再额外堆 TextBox 或标签。这个“含有 checkbox 的 combox 控件类”解决的就是这类问题——它不替换你熟悉的 ComboBox 用法只是在它的基础上增加一个下拉复选框层选中态保持可见、数据可回读。适合做权限分配、筛选条件、报表指标选择这类中等量级数据几十到几百项的界面场景也适合移植到现有的老项目里不用大规模改结构。下文我把实现拆开讲给出可直接抄的控件类代码和边界参数再列几条真实踩坑记录。2. 核心机制拆解原生 ComboBox 拉不住复选框关键在绘制与鼠标命中2.1 继承 ComboBox 而不是从零造控件很多人的第一反应是自己做一个 UserControl把一个 TextBox 和 ListBox 拼起来。我不建议这样做你需要重复处理键盘事件、焦点边框、下拉动画、滚动条外观这些原生 ComboBox 早已实现。正确做法是继承ComboBox把DrawMode切成 OwnerDraw让每一项自己画出一个复选框和一行文本同时拦下鼠标点击转换成勾选状态。类的基本形态如下public class CheckComboBox : ComboBox { private HashSetobject _checkedSet new HashSetobject(); public CheckComboBox() { DropDownStyle ComboBoxStyle.DropDownList; DrawMode DrawMode.OwnerDrawVariable; ItemHeight 22; } public bool IsChecked(int index) { if (index 0 || index Items.Count) return false; return _checkedSet.Contains(Items[index]); } public IEnumerableobject CheckedItems _checkedSet.ToList(); }这里把DropDownStyle设为DropDownList是为了避免用户编辑文本破坏内部状态OwnerDrawVariable允许每项独立高度而不是像OwnerDrawFixed那样所有项共用固定行高。_checkedSet是整个控件的状态核心以数据项本身为 key这样切换 DataSource 时无需额外维护一套 int 索引。注意CheckedItems返回的是快照副本调用方拿到的集合在遍历时不会因为控件内部变化而抛异常。2.2 DrawItem 重绘复选框和文字各占一块区域绘制逻辑是整个控件最容易被改崩的部分。OnDrawItem里需要做四件事画背景、画复选框、画文字、画焦点框。复选框的大小我按SystemInformation.SmallIconSize取跟随系统 DPI 变化不要写死 16 或 20。protected override void OnDrawItem(DrawItemEventArgs e) { if (e.Index 0) return; e.DrawBackground(); e.DrawFocusRectangle(); Rectangle bounds e.Bounds; Size checkSize SystemInformation.SmallIconSize; Point checkPos new Point(bounds.X 4, bounds.Y (bounds.Height - checkSize.Height) / 2); Rectangle textRect new Rectangle(bounds.X checkSize.Width 8, bounds.Y, bounds.Width - checkSize.Width - 12, bounds.Height); CheckBoxState state IsChecked(e.Index) ? CheckBoxState.CheckedNormal : CheckBoxState.UncheckedNormal; CheckBoxRenderer.DrawCheckBox(e.Graphics, checkPos, checkSize, state); TextRenderer.DrawText(e.Graphics, GetDisplayText(e.Index), e.Font, textRect, e.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); base.OnDrawItem(e); }CheckBoxRenderer.DrawCheckBox的第三个参数是Size传checkPos和矩形都可以但建议用 PointSize 的形式方便后续鼠标命中时做区域比对。文字区域从checkSize.Width 8开始留白这个 8 是复选框和文字之间的固定间距视觉上不会贴在一起。GetDisplayText负责返回实际要显示的内容后面第 3 章会讲它和本地化的关系。这里有个容易踩的小坑base.OnDrawItem(e)要放在最后不要前置。如果你先调了基类实现再画复选框基类会用系统默认背景刷把整行覆盖掉你画的框就全被抹掉了。2.3 鼠标命中处理三种做法和各自边界这是整个控件类真正的分水岭。原生 ComboBox 在下拉列表里点击任意项默认行为是“选中这一项并关闭下拉”。带复选框的版本期望的是“点击文字或复选框区域时只切换勾选状态不关闭下拉”。要做到这点常见有三条路。第一种在OnMouseClick里判断点击位置是否落在复选框区域内命中才切换状态。实现简单但用户点文字时不会触发勾选体验断裂只有点那个小框才有反应被吐槽过很多次。第二种重写WndProc在下拉列表是展开状态时拦截WM_LBUTTONUP每次点击都切换状态并且在下拉即将关闭时立刻重新打开。体验接近用户预期但需要在消息循环里判断DroppedDown状态处理不好会出现下拉框闪烁。private const int WM_LBUTTONUP 0x0202; private const int WM_COMMAND 0x0111; private const int CBN_CLOSEUP 8; protected override void WndProc(ref Message m) { base.WndProc(ref m); if (m.Msg WM_LBUTTONUP DroppedDown) { int index IndexFromPoint(MousePosition); if (index 0) { ToggleChecked(index); // 下拉即将关闭重新弹出保持列表可见 if (!DroppedDown) { DroppedDown true; } } } }第三种干脆不用原生下拉列表自己写一个UserControl模拟下拉面板里面放一个 CheckedListBox点击行为完全由自己控制。灵活度最高下拉宽度、多列布局都能改代价是你要自己处理失焦关闭、滚动穿透、屏幕边缘翻转这些边界问题。如果你的控件类只服务一个固定业务界面选第三种最省心如果是通用控件建议用第二种。我通常的做法是通用场景用第二种内部用IndexFromPoint配合MousePosition转成窗体坐标来定位项索引业务场景固定、且每一项还有额外的图标或行数据时直接换 UserControl 方案。2.4 暴露状态集合勾选项的读法与回调控件类不能只负责“画得好看”调用方总要拿结果去保存、去过滤。至少要暴露三样东西CheckedItems数据源、IsChecked(int index)判断方法、SelectedIndexChanged之外的回调事件。不能直接修改Text表示多选结果因为DropDownList模式下Text是只读的写不进去。public event EventHandler CheckedItemsChanged; public void SetItemChecked(int index, bool isChecked) { if (index 0 || index Items.Count) return; object item Items[index]; if (isChecked) _checkedSet.Add(item); else _checkedSet.Remove(item); InvalidateItem(index); CheckedItemsChanged?.Invoke(this, EventArgs.Empty); } private void ToggleChecked(int index) { if (IsChecked(index)) SetItemChecked(index, false); else SetItemChecked(index, true); }SetItemChecked是给外部调用的ToggleChecked专门给鼠标逻辑用。注意每次状态变更后调用InvalidateItem而不是Invalidate()——前者只重绘被点击的那一行数据量上百时性能差距明显。CheckedItemsChanged事件放在状态真正的变更点触发不要放在绘制函数里否则一遍重绘会连续触发多次。3. 可抄作业的控件封装尺寸、文本与数据绑定3.1 完整控件类骨架与关键参数把前面的绘制和点击逻辑串起来就是一个可以直接放进项目里的控件类。下面这个版本去掉了业务特殊性保留通用行为单击整行切换勾选、下拉保持展开、支持 DataSource 绑定。public class CheckComboBox : ComboBox { private readonly HashSetobject _checkedSet new(); private bool _isDroppedDown false; public CheckComboBox() { DropDownStyle ComboBoxStyle.DropDownList; DrawMode DrawMode.OwnerDrawVariable; ItemHeight 22; IntegralHeight false; } public event EventHandler CheckedItemsChanged; public IEnumerableobject CheckedItems _checkedSet.ToList(); public int CheckedCount _checkedSet.Count; public bool IsChecked(int index) { if (index 0 || index Items.Count) return false; return _checkedSet.Contains(Items[index]); } public void SetItemChecked(int index, bool isChecked) { if (index 0 || index Items.Count) return; object item Items[index]; if (isChecked) { _checkedSet.Add(item); } else { _checkedSet.Remove(item); } InvalidateItem(index); CheckedItemsChanged?.Invoke(this, EventArgs.Empty); } protected override void OnDrawItem(DrawItemEventArgs e) { if (e.Index 0) return; e.DrawBackground(); e.DrawFocusRectangle(); Size checkSize SystemInformation.SmallIconSize; Point checkPos new Point(e.Bounds.X 4, e.Bounds.Y (e.Bounds.Height - checkSize.Height) / 2); Rectangle textRect new Rectangle( e.Bounds.X checkSize.Width 8, e.Bounds.Y, e.Bounds.Width - checkSize.Width - 12, e.Bounds.Height); CheckBoxState state IsChecked(e.Index) ? CheckBoxState.CheckedNormal : CheckBoxState.UncheckedNormal; CheckBoxRenderer.DrawCheckBox(e.Graphics, checkPos, checkSize, state); TextRenderer.DrawText(e.Graphics, GetDisplayText(e.Index), e.Font, textRect, e.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); base.OnDrawItem(e); } protected override void OnMeasureItem(MeasureItemEventArgs e) { e.ItemHeight _itemHeight; } private int _itemHeight 22; public int ItemRowHeight { get _itemHeight; set _itemHeight value 18 ? 18 : value; } private string GetDisplayText(int index) { if (index 0 || index Items.Count) return string.Empty; return Convert.ToString(Items[index]); } protected override void WndProc(ref Message m) { base.WndProc(ref m); if (m.Msg WM_LBUTTONUP DroppedDown) { int index IndexFromPoint(MousePosition); if (index 0) { bool hadChecked IsChecked(index); ToggleChecked(index); if (!DroppedDown) { DroppedDown true; } } } } private const int WM_LBUTTONUP 0x0202; private void ToggleChecked(int index) { if (IsChecked(index)) SetItemChecked(index, false); else SetItemChecked(index, true); } }逻辑上的关键点是ItemRowHeight的边界限制小于 18 会直接强制拉回 18防止你把行高原调到连复选框都画不下。IntegralHeight false是为了提前规避一条老坑默认值 true 会在项数不满一屏时强行改变下拉框高度出现“列表被截断”的假象。3.2 下拉宽度自适应与最大行高规划原生 ComboBox 的DropDownWidth默认等于控件宽度项文本超过宽度会出现水平滚动条复选框区域被推出可视范围。惯用做法是在控件加载完成后主动算一次最大宽度private void AdjustDropDownWidth() { int maxWidth Width; using (Graphics g CreateGraphics()) { foreach (object item in Items) { int w (int)g.MeasureString(Convert.ToString(item), Font).Width; if (w maxWidth) maxWidth w; } DropDownWidth maxWidth 40; } }多出来的 40 是给复选框、文字留白和滚动条预留的余量。如果你项文本里带了比较宽的字符或者本地化后文本长度变化明显这个值要提高到 48。方法调用的时机建议放在DropDown事件里而不是HandleCreated——因为此时字体已经应用完成MeasureString的结果才可信。如果有几行文本特别长导致列表宽度异常膨胀可以考虑在OnMeasureItem里同时校一遍最小宽度或者直接调用这个调整方法只增不减当前DropDownWidth大于新算出的值时不缩小。3.3 string 项与本地化场景的取舍这套控件类最常见的绑定写法是Items.Add(张三)或DataSource DataTable。这两种都可以但要注意如果直接把中文文本写死在 Items 里后续做本地化比如中英文切换就要重建整个 Items 集合勾选状态全靠_checkedSet里的对象引用维持集合重建后引用全失效勾选自然被清空。更稳的做法是让 Items 存一个业务对象显示文本通过DisplayMember控制。拿不到业务对象时最低成本方案是存一个 Key-Value 结构public sealed class SelectableItem { public string Key { get; set; } public string DisplayText { get; set; } public override string ToString() DisplayText; }ToString返回值会被GetDisplayText转成显示文本同时_checkedSet里存的是整个SelectableItem对象本地化时只需要替换DisplayText属性并重绘控件不需要清空重绑。勾选集合保存的是 Key 列表存数据库、做默认回显都很干净。这是多个项目里试下来最兼顾“文本展示”和“值传递”的做法。4. 实战接入放置、数据绑定与结果回读4.1 在窗体里注册控件并初始化如果项目里没有 UserControl 库可以直接把控件类文件放进项目然后手动添加到窗体设计器。设计器里操作方式是在窗体类里声明字段并实例化private CheckComboBox ccbDepartments; private void InitializeCheckCombo() { ccbDepartments new CheckComboBox { Location new Point(30, 30), Size new Size(220, 30), DropDownHeight 260, Font new Font(Microsoft YaHei, 9F) }; Controls.Add(ccbDepartments); }DropDownHeight 260控制的是展开列表的最大可视高度项多时内部滚动。注意Size的 Height 部分会被 ComboBox 强制覆盖为实际控件高度写多少都行真正决定行高的是ItemRowHeight。初始化时建议把字体统一改成微软雅黑否则默认的宋体在新系统上渲染出来checkState会偏窄文字和复选框的间距观感上变得不平衡。4.2 数据源绑定Items 与 DataSource 两种方式各自边界如果只需要简单字符串列表直接循环添加ccbDepartments.Items.Clear(); ccbDepartments.Items.Add(生产部); ccbDepartments.Items.Add(质检部); ccbDepartments.Items.Add(物流部);需要带上业务 ID 的时候用SelectableItem列表ListSelectableItem depts new ListSelectableItem { new SelectableItem { Key D01, DisplayText 生产部 }, new SelectableItem { Key D02, DisplayText 质检部 } }; ccbDepartments.DataSource depts; ccbDepartments.DisplayMember DisplayText;DataSource模式下_checkedSet存的是SelectableItem对象引用只要不重建 DataSource勾选状态就稳定。反过来如果反复调用DataSource null; DataSource depts2;老的 checkedSet 和新的数据源对象完全对不上唯一规避方案是把 Key 落到持久化字段里下次打开界面时根据 Key 数组回设勾选。4.3 回读勾选结果并格式化输出用户勾选完成后界面要能立即反馈“选了哪几项”保存时还要能转成数据库可用的字符串。常见做法是订阅CheckedItemsChanged同时把结果格式化成逗号拼接private void OnDeptCheckedChanged(object sender, EventArgs e) { Liststring keys ccbDepartments.CheckedItems .CastSelectableItem() .Select(x x.Key) .ToList(); txtDeptResult.Text string.Join(,, keys); lblDeptCount.Text $已选 {keys.Count} 项; }这段代码的好处是把控件和业务解耦CheckedItems只负责给出对象集合格式化、入库、回显都是调用方的事。如果是小项目、不想引入额外类型也可以把 Key 直接塞在字符串里如“D01:生产部”但那样Convert.ToString(item)出来的显示文本会很难看不推荐。另外CheckedItemsChanged事件在下拉状态下每点一次就触发一次。如果你的保存逻辑比较重建议做一层延迟只在DropDownClosed事件里读取最终结果避免用户连续勾选时频繁写库。5. 避坑与排查四条高频问题的现象、原因、解决5.1 复选框不显示列表只显示纯文本现象下载后把代码拖进项目编译运行下拉列表和普通 ComboBox 一模一样看不到任何复选框。原因DrawMode没有被设置为OwnerDrawVariable。如果设计器里直接把控件拖到窗体设计器生成代码时可能不序列化这个属性导致运行时走的是系统默认绘制路径。解决在构造函数里强制赋值一次并加上Invalidate()。public CheckComboBox() { DrawMode DrawMode.OwnerDrawVariable; ItemHeight 22; DropDownStyle ComboBoxStyle.DropDownList; Invalidate(); }如果还不行检查是否有其他地方把DrawMode重置回了Normal比如属性面板里被人手动改过。5.2 点一下就选中关闭勾选状态不保持现象点击下拉项后复选框确实变了但下拉立刻收起再次展开时部分勾选状态已经丢失。原因WndProc里的重新弹出逻辑没有命中根本原因是IndexFromPoint在WM_LBUTTONUP时定位失败——坐标没转对。MousePosition是屏幕坐标IndexFromPoint需要的是客户区坐标二者混用会得到错误的索引或 -1。解决先用PointToClient转换Point pt PointToClient(MousePosition); int index IndexFromPoint(pt); if (index 0) { ToggleChecked(index); if (!DroppedDown) { DroppedDown true; } }5.3 下拉项行高参差不齐复选框上下偏移现象有的行文字被截断复选框和文字不在一条垂直线上第一行正常、第五行歪掉。原因在OwnerDrawVariable模式下你重写了OnDrawItem却没有重写OnMeasureItem。系统默认的MeasureItem会用控件原始ItemHeight计算每行但字体变化后实际需高会变动绘制时以Bounds为基准二者取值不一致就会出现偏移。解决重写OnMeasureItem并显式赋值protected override void OnMeasureItem(MeasureItemEventArgs e) { e.ItemHeight ItemRowHeight; e.ItemWidth Width; }ItemRowHeight建议由Font.Height 8推导不要写死。高 DPI 环境下写死的 22 在 150% 缩放下看起来明显偏矮。5.4 回显时 SetItemChecked 无效现象窗体加载时从数据库读出了已选 Key 数组循环调用SetItemChecked把复选框打上勾界面无反应或者只在滚动时闪现。原因InvalidateItem内部对某个具体索引的重绘在OwnerDrawVariable模式下偶尔不生效特别是项的Bounds尚未被完整计算时控件刚创建就立刻回显。绘制缓存还保留着旧状态。解决批量回显结束后强制调用一次完整Invalidatepublic void SetCheckedKeys(Liststring keys, FuncSelectableItem, string keySelector) { _checkedSet.Clear(); foreach (SelectableItem item in Items) { if (keys.Contains(keySelector(item))) _checkedSet.Add(item); } Invalidate(); CheckedItemsChanged?.Invoke(this, EventArgs.Empty); }6. 进阶应用多选结果回写、本地化动态切换与验证清单6.1 回写到文本框并同时保持下拉展开时的实时反馈如果你的界面需求是“用户在下拉中勾选同时上方文本框实时显示已选名称”不要直接在CheckedItemsChanged里更新文本框——下拉弹出状态下文本框位置会被下拉列表遮挡用户根本看不到。我一般把反馈拆成两层下拉展开时信息展示在下拉列表顶部自定义绘制一块提示区域下拉关闭后才把结果写进文本框或标签。这样既保留实时性又不会出现“看着下拉列表却不知道选了什么”的体验断裂。6.2 本地化切换的完整做法之前提到过SelectableItem的显示文本可换。完整的本地化流程是在切换语言时遍历所有SelectableItem把DisplayText换成资源文件里的对应值然后调用Invalidate()重绘。_checkedSet存的是对象引用所以不会丢勾选状态。对于下拉宽度也要在语言切换后重新调用AdjustDropDownWidth因为德文、法文普遍比中文文本长 20% 左右不重算就会出现横向滚动。一个实际项目里的写法private void SwitchLanguage(string lang) { var list ccbDepartments.Items.CastSelectableItem().ToList(); foreach (var item in list) { item.DisplayText Resources.ResourceManager.GetString(item.Key, new CultureInfo(lang)); } ccbDepartments.Invalidate(); AdjustDropDownWidth(); }这里Key同时充当资源键名要求 Key 必须是合法的资源标识符纯数字开头的 Key 会加载失败。这也是我在项目里把 Key 统一改成“Dept_01”这种格式的原因纯字符串项目从Items.Add(生产部)迁移过来时这一步躲不过。6.3 一组可复现的验证用例控件交付前我习惯按下面清单走一遍能筛掉绝大多数翻车情况全选后关闭下拉再展开勾选状态保持且复选框位置不偏移。在 150% DPI 下重复上述操作。设计器修改控件尺寸后下拉宽度不少于所有项文本最大宽度加 40。快速连续点击多项时CheckedItemsChanged触发次数和点击次数一致无重复触发、无丢事件。DataSource重新赋值后用回显方法恢复勾选确认 Key 匹配无误。实际项目里我遇到最典型的坑是第一条和第二条一起做普通 DPI 下一切正常但用户换了台高分屏复选框和文字就错位。后来强制ItemRowHeight Font.Height 8并允许最小值 18错位问题才真正根治。从那以后我每次做这类自定义绘制控件都会把“DPI 变化后骨架是否仍成立”当成第一测试项不再依赖本机环境验证。希望这份控件拆解笔记能帮你省下排查这类问题的时间。本文还有配套的精品资源点击获取
返回列表