拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

WinForms实现带CheckBox的多选下拉框:两种方案详解

WinForms实现带CheckBox的多选下拉框:两种方案详解 简介面向Windows桌面应用开发者的自定义控件示例主要演示如何在ComboBox中集成CheckBox控件实现单项下拉框向多选列表的扩展解决标准组合框仅能单选的问题。资源为MultiSelectComboBox Demo(Codebehind)的完整工程共55个文件、压缩包约125KB包含C#源码、XAML界面、资源文件以及可执行程序适合WinForms或WPF开发人员学习自定义控件渲染、数据绑定和CheckBox事件处理。已有495人学习下载。示例运行后可直接观察下拉列表内复选框与数据项的联动效果结合后台代码可清晰看到初始化数据源、重写下拉列表样式、同步选中状态等关键环节既可用作教学参考也能直接移植到权限选择、配置设置等实际场景中。对于想扩展多选交互、提升控件复用能力的开发者这份小巧的Demo提供了清晰的实现范式。1. 为什么原生ComboBox装不下CheckBox两条实现路线的取舍做权限分配、报表字段筛选、标签勾选这类WinForms界面时需求几乎都是同一句话下拉框里每一项带一个CheckBox勾几个就带几个值走。原生ComboBox只认“选中一行”CheckedListBox倒是带勾选框但它平铺在窗体上占地方放进下拉更不现实。这个“带CheckBox的ComboBox”本质是改造ComboBox的勾选语义而不是在ComboBox里塞一堆控件。实现上有两条路自绘OwnerDraw改ComboBox的画法和交互代码量小适合选项少、界面边缘的场合组合控件ToolStripDropDown承载CheckedListBox交互更接近商业控件适合正式业务页面。这篇把两条路的完整代码、关键参数和踩坑都展开C#桌面开发直接抄。2. 自绘方案打底用OwnerDraw接管CheckBox和文本绘制2.1 从DrawMode到DrawItem自绘的开关和回调链WinForms里让ComboBox变成“带CheckBox的ComboBox”第一步不是加控件而是告诉ComboBox“你别自己画了我来画”。DrawMode就是总开关Normal系统画没得商量OwnerDrawFixed系统调用你的DrawItem但所有项等高OwnerDrawVariable系统调用你的DrawItem和MeasureItem每项高度可以自己定。我一般用OwnerDrawVariable因为下拉项里加CheckBox后垂直居中的计算需要知道绘制区域高度固定高度在DPI缩放下容易顶头或者压底Variable模式至少留了测量的口子。同时要把DropDownStyle固定成DropDownList否则用户能在编辑框里敲字后面文本回显会被搅乱这个坑在第5章单独讲。自绘的回调链很简单下拉弹出时系统对每个可见项先调OnMeasureItem问高度再调OnDrawItem让你画背景、画CheckBox、画文本。编辑框区域非下拉状态也会触发OnDrawItem但这时e.Index可能小于0或者状态里有ComboBoxEdit标志必须单独处理不能把CheckBox画进编辑框里。2.2 一个能跑的CheckBoxComboBox类完整代码与关键参数说明下面这段是自绘方案的最小可运行版本先跑通“下拉项里出现CheckBox”再说交互。要用到的using缺一不可特别是System.Windows.Forms.VisualStylesCheckBoxRenderer画出来的才是系统原生勾选样式自己画矩形框容易被系统主题区别对待。using System; using System.Collections.Generic; using System.Drawing; using System.Linq; using System.Windows.Forms; using System.Windows.Forms.VisualStyles; public class CheckBoxComboBox : ComboBox { private readonly HashSetint checkedSet new HashSetint(); private bool isDroppingDown; public CheckBoxComboBox() { DrawMode DrawMode.OwnerDrawVariable; DropDownStyle ComboBoxStyle.DropDownList; } // 当前勾选的索引集合按索引升序返回 public IEnumerableint CheckedIndices checkedSet.OrderBy(i i); // 代码层面设置某一项的勾选状态刷新文本和界面 public void SetItemChecked(int index, bool isChecked) { if (index 0 || index Items.Count) return; if (isChecked) checkedSet.Add(index); else checkedSet.Remove(index); RefreshText(); Invalidate(); } private void ToggleCheck(int index) { if (checkedSet.Contains(index)) checkedSet.Remove(index); else checkedSet.Add(index); } protected override void OnDrawItem(DrawItemEventArgs e) { base.OnDrawItem(e); // 编辑框区域Index 0 时显示的是 Text不画 CheckBox if (e.Index 0) { TextRenderer.DrawText(e.Graphics, Text, e.Font, e.Bounds, ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); return; } if (e.Index Items.Count) return; // 编辑框还有一种状态Index 有效但带 ComboBoxEdit 标志同样只画文本 bool isEditArea (e.State DrawItemState.ComboBoxEdit) DrawItemState.ComboBoxEdit; if (isEditArea) { TextRenderer.DrawText(e.Graphics, GetItemText(Items[e.Index]), e.Font, e.Bounds, ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); return; } e.DrawBackground(); // 勾选框尺寸按 DPI 换算15px 是 96dpi 下的经典大小 int checkSize (int)Math.Round(15 * e.Graphics.DpiX / 96f); Rectangle checkRect new Rectangle( e.Bounds.X 2, e.Bounds.Y (e.Bounds.Height - checkSize) / 2, checkSize, checkSize); CheckBoxState state checkedSet.Contains(e.Index) ? CheckBoxState.CheckedNormal : CheckBoxState.UncheckedNormal; CheckBoxRenderer.RenderGlyph(e.Graphics, checkRect, state); // 文本区域从 CheckBox 右侧开始留出 2 checkSize 6 的缩进 Rectangle textRect new Rectangle( e.Bounds.X 2 checkSize 6, e.Bounds.Y, e.Bounds.Width - (2 checkSize 6 2), e.Bounds.Height); TextRenderer.DrawText(e.Graphics, GetItemText(Items[e.Index]), e.Font, textRect, e.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); if ((e.State DrawItemState.Focus) DrawItemState.Focus) e.DrawFocusRectangle(); } protected override void OnMeasureItem(MeasureItemEventArgs e) { base.OnMeasureItem(e); // Variable 模式下必须给高度太小会把整个控件压扁 e.ItemHeight Math.Max(e.ItemHeight, 20); } protected override void OnDropDown(EventArgs e) { isDroppingDown true; base.OnDropDown(e); } protected override void OnDropDownClosed(EventArgs e) { isDroppingDown false; base.OnDropDownClosed(e); RefreshText(); } private void RefreshText() { if (isDroppingDown) return; Liststring parts new Liststring(); foreach (int idx in CheckedIndices) parts.Add(GetItemText(Items[idx])); Text parts.Count 0 ? string.Join(, , parts) : ; } }几个参数说明checkedSet用HashSetint存索引重复Toggle不会出问题比Listint干净。但它只适合静态Items数据源频繁重建的情况看第5.2条。OnDrawItem里两个“只画文本”的分支容易被漏掉。如果漏了你会看到编辑框里出现一个勾选框图形这是自绘新手最容易碰到的怪异画面。GetItemText(Items[e.Index])而不是Items[e.Index].ToString()是为了兼容DisplayMember绑定这个方法会自动解析数据行的显示列。RefreshText用string.Join(, , parts)把勾选项拼到Text里。isDroppingDown标志是防止下拉展开过程中反复刷新Text导致重绘闪烁。到这一步代码能编译下拉里能看到CheckBox但点击“只是选中行、不会改变勾选状态”。下一章把交互补上这个控件才真正能干活。3. 勾选交互与文本回显Click切换、RefreshText和指定项默认选中3.1 点击下拉项就切换勾选用SelectionChangeCommitted接管原生ComboBox的点击语义是“选定一行然后关闭下拉”我们需要的语义是“点一行切换勾选然后继续留在下拉里”。这个矛盾用SelectedIndexChanged解决不了它在下拉关闭前就触发时机和状态都不好控制。我习惯用SelectionChangeCommitted它只在用户交互导致选中项变化之后触发代码赋值SelectedIndex不会走这里。在2.2的类里追加两个覆写方法protected override void OnSelectionChangeCommitted(EventArgs e) { base.OnSelectionChangeCommitted(e); int idx SelectedIndex; if (idx 0) return; ToggleCheck(idx); SelectedIndex -1; // 复位选中保证下次点击同一行还能触发 RefreshText(); // 立刻把勾选结果写回编辑框 // 重新展开下拉支持连续勾选多行 BeginInvoke(new Action(() { try { DroppedDown true; } catch { /* 焦点已经丢失或控件被释放时忽略 */ } })); } protected override bool ProcessDialogKey(Keys keyData) { // 自绘方案对键盘方向键/回车不友好先吞掉避免键盘把勾选状态搅乱 if (keyData Keys.Up || keyData Keys.Down || keyData Keys.Enter) return true; return base.ProcessDialogKey(keyData); }这段代码的逻辑顺序值得细看。用户点击下拉项后下拉面板先关闭然后SelectionChangeCommitted触发。我们读到点击行的索引切换勾选把SelectedIndex复位为-1。复位是必须的如果不复位下次点击同一行时系统认为选中项没变事件不触发勾选状态就卡死。复位后立即RefreshText把新的勾选结果拼进编辑框。最后用BeginInvoke重新打开下拉为什么不能直接DroppedDown true因为当前还在事件回调链里直接改下拉状态在某些Windows版本上会抛异常或者显示一个半透明的残影用BeginInvoke把操作推迟到消息循环空闲时再执行稳定得多。注意我这里吞掉了方向键和回车。这是自绘方案的真实边界键盘改变选中时也会触发SelectionChangeCommitted同样会翻转勾选并重开下拉体验很诡异。让用户用鼠标勾选键盘操作留给后面第6章的组合控件方案去解决。3.2 编辑框回显勾选结果写进Text超长截断RefreshText在第2章已经给出基础版但它有个实际痛点勾了十来个选项时Text被撑得很长编辑框根本显示不下用户看到的是一串截断的乱字。我一般会加一个显示上限只显示前几项后面用“等N项”概括。private void RefreshText() { if (isDroppingDown) return; Liststring parts CheckedIndices .Select(idx GetItemText(Items[idx])) .ToList(); if (parts.Count 3) Text string.Join(, , parts.Take(3)) 等 parts.Count 项; else Text string.Join(, , parts); }这个“前3项总数”的写法是很多商用系统的标配因为业务方要的是“一眼看出选了哪些选太多了知道个数就行”。如果你希望完全展开去掉上限分支即可。要注意Take(3)之前的Select不能把idx换成Items[idx]后直接ToString还是那句老话走GetItemText才能兼容数据绑定。还有一个细节RefreshText在OnDropDownClosed里也会调用一次。这是因为下拉展开时isDroppingDown为true勾选过程中的Text不会实时刷新关闭后再统一刷新一次避免编辑框里的内容在下拉展开期间闪动。3.3 C#中把ComboBox指定项设为默认勾选两个设置方法“c# combobox指定项”这个搜索词见的太多对应的需求就是初始化时把某几项默认勾上比如权限界面里回显用户已有的角色。第2章代码里已经有SetItemChecked(int index, bool isChecked)但业务代码里往往拿的是文本而不是索引。我习惯再加一个按文本定位的方法public void SetItemCheckedByText(string text, bool isChecked) { for (int i 0; i Items.Count; i) { if (GetItemText(Items[i]) text) { SetItemChecked(i, isChecked); return; } } } public void SetCheckedByValue(object value) { int idx Items.IndexOf(value); if (idx 0) SetItemChecked(idx, true); }用法很直观比如从配置表读回一组已选项循环调用即可// 持久化恢复按文本回显勾选状态 foreach (string saved in savedCheckedItems) combo.SetItemCheckedByText(saved, true); // 或者拿数据对象直接定位 combo.SetCheckedByValue(selectedRole);SetItemChecked内部已经调用了RefreshText和Invalidate所以批量设置后界面会同步更新不用额外再刷新。按文本定位有一个边界列表里有重复文本时只会命中第一个遇到这种情况建议改用ValueMember对应的数据对象定位也就是SetCheckedByValue那条路。4. 自绘必调参数CheckBox尺寸、文本缩进与下拉宽度4.1 CheckBox矩形与高DPI缩放15px只是一个起点2.2的代码里checkSize (int)Math.Round(15 * e.Graphics.DpiX / 96f)这行不是炫技。WinForms在96dpi下运行15px刚好是原生CheckBox的经典尺寸一旦用户把系统缩放调到125%或150%e.Graphics.DpiX变成120或144如果还用15px勾选框会明显比系统其他CheckBox小一圈而且在高分屏上发虚。我见过不止一次这样的翻车现场自绘的CheckBox在100%缩放下正常换到一台150%缩放的笔记本上字体清晰、勾选框模糊。原因就是绘制尺寸没有跟DPI走位图被系统强行拉伸。正确做法是每个绘制分支都从e.Graphics.DpiX换算一次不要用类字段缓存因为控件可能被拖到不同DPI的显示器上。CheckBoxRenderer.RenderGlyph这个API内部会读取系统主题画出来的是当前Windows版本的勾选样式。别用DrawRectangle手画边框再画个勾主题一换就穿帮。4.2 文本缩进与MeasureItem画不下和压扁都可能文本起始位置是e.Bounds.X 2 checkSize 6这个公式里三个数都有意义2是CheckBox左侧的呼吸间距checkSize是勾选框本身宽度6是勾选框和文字之间的间距。总缩进在96dpi下是23px比很多教程里写的“固定24px”稍微精确一点因为你在DPI缩放后不能继续用24这个常数。OnMeasureItem里e.ItemHeight Math.Max(e.ItemHeight, 20)这条是保命用的。OwnerDrawVariable模式下如果MeasureItem返回的高度小于字体高度ComboBox的编辑框区域会被压得很扁整个控件看起来像一条线怎么调Height都没用。20px是经验值配合默认字体正好。如果你用的字体偏大就改成Math.Max(e.ItemHeight, (int)e.Graphics.MeasureString(A, Font).Height)直接跟字体走。还有一个容易忽略的点DrawMode.OwnerDrawFixed虽然代码更短但ItemHeight由控件属性统一控制在DPI变化时不会自动跟着缩放。Variable模式多写一个方法换来的是每项高度都能自适应这笔账划算。4.3 DropDownWidth按最长项自动撑宽还是固定下拉面板的默认宽度继承自控件的Width自绘加了CheckBox缩进后原本能完整显示的文本会被截断右侧出现滚动条。这个现象很闹心控件宽度明明够长文本却能显示却不显示。原因是下拉面板宽度等于控件宽度没算CheckBox占用的23px。我的做法是在Items变化后主动计算一次public void RecalcDropDownWidth() { int maxWidth 0; foreach (object item in Items) { string text GetItemText(item); int w TextRenderer.MeasureText(text, Font).Width; maxWidth Math.Max(maxWidth, w); } DropDownWidth Math.Max(Width, maxWidth 2 15 6 2); }加号后面的2 15 6 2基本对应left margin checkSize text间隔 right margin。这里按96dpi算就行DropDownWidth是控件级属性系统会按比例缩放。调用时机放在Items.AddRange之后或者在SetItemChecked里同步触发。如果数据量几百条MeasureText循环一次也就几毫秒不用担心性能。真正该注意的是不要在下拉展开期间调用这个方法DropDownWidth在DroppedDown true时改动会导致面板闪烁。5. 带CheckBox的ComboBox高频坑现象、原因、解决5.1 点击勾选框没反应SelectedIndexChanged也没触发现象自绘的CheckBox画出来了但鼠标点上去毫无反应旁边的文本区域点了反而导致下拉关闭。原因CheckBox是画出来的图形不是真实控件。ComboBox把整行都当成一个可点击区域走了自己的“选中并关闭”逻辑根本没把点击坐标交给我们。解决用SelectionChangeCommitted接管交互见3.1节。在事件里翻转勾选状态、复位SelectedIndex、再通过BeginInvoke重开下拉。注意这里不能用SelectedIndexChanged它在下拉面板还在的时候就会触发状态复位和重开的时序全乱。5.2 勾选状态存在Index里Items一重建就全乱现象第一次AddRange后勾了两项调用Items.Clear()再重新AddRange新列表里完全不同的行被自动勾上。原因checkedSet存的是索引。索引只对同一份Items有效Items重建后索引指向的是新列表里的其他位置。解决静态列表无所谓动态列表要么在重建前清空勾选要么换存储载体。清空最简单public void ClearChecks() { checkedSet.Clear(); RefreshText(); }如果业务上要求在数据源刷新后按业务键保留勾选就把HashSetint换成HashSetobject存数据对象引用Toggle时用Items.IndexOf(item)反向查索引。数据对象要做合理的Equals实现否则引用比较会失灵。5.3 DataSource DisplayMember绑定后画出来的是类型名现象下拉项里文本显示成“System.Data.DataRowView”或者自定义类的命名空间全名完全不是DisplayMember指定的列。原因代码里写了Items[e.Index].ToString()。绑定数据源后Items里的元素是数据行对象ToString返回的是类型信息不是显示文本。解决所有取显示文本的地方统一走GetItemText(Items[e.Index])。这个方法会看DisplayMember有没有设置设置了就取对应列的值没设置才退回到ToString。排查时可以全局搜一下代码里有没有裸的.ToString()有就换掉。5.4 DropDownStyle设成DropDown用户敲字把回显搅乱现象控件初始是DropDown样式用户点击编辑框输入文字后Text字段被用户输入占据RefreshText又把勾选项拼回来两边互相覆盖最后显示内容既不是用户输入也不是勾选结果。原因DropDown样式允许编辑Text而自绘方案依赖Text显示勾选结果两个写入方冲突。解决不要在带CheckBox的ComboBox上用DropDown样式固定DropDownList。如果确实需要过滤输入不要走这条自绘路线第6章的CheckedListBox组合方案可以单加一个FilterTextBox职责分离不会互相踩。这个坑的教训是自绘组件能做的功能有限“能编辑输入”和“能勾选多选”是两套交互硬拼在一起只会两头都坏。5.5 高分屏下CheckBox渲染模糊文本被截断现象系统缩放125%的机器上勾选框边缘发虚文本右侧被切掉一截看起来像样式没做对。原因两件事叠加。一是绘制尺寸没按DPI换算CheckBox被系统拉伸二是文本矩形宽度按固定数字算缩放后实际可用宽度变小。解决勾选框尺寸用e.Graphics.DpiX / 96f换算文本矩形宽度也由换算后的checkSize推算不要写死24。另外确认app.manifest里声明了DPI感知WinForms项目默认可能是PerMonitorV2老项目可能被系统做位图拉伸声明后重新生成再试。这个现象在“看着正常”和“一换显示器就翻车”之间反复出现属于高分屏开发的玄学干脆一开始就统一按DPI写。6. 量产版做法ToolStripDropDown承载CheckedListBox的完整交互6.1 组合控件骨架Trigger ToolStripDropDown CheckedListBox自绘方案胜在轻量但交互上限摆在那里键盘支持差、连续勾选要重开下拉、文本回显有闪烁。要做成正式功能上业务线我推荐组合控件方案。思路是用一个只读ComboBox当触发器真正承载勾选的是CheckedListBox把它塞进ToolStripDropDown里当作下拉面板。public class PopupCheckedComboBox : UserControl { private readonly ComboBox trigger; private readonly CheckedListBox list; private readonly ToolStripDropDown popup; public PopupCheckedComboBox() { trigger new ComboBox { DropDownStyle ComboBoxStyle.DropDownList, Width 180 }; list new CheckedListBox { CheckOnClick true, BorderStyle BorderStyle.None, Height 160 }; popup new ToolStripDropDown { Padding Padding.Empty }; popup.Items.Add(new ToolStripControlHost(list) { AutoSize false, Width trigger.Width }); // 用 popup 替代原生下拉面板 trigger.DropDown (s, e) { trigger.DroppedDown false; popup.Show(trigger, new Point(0, trigger.Height)); }; Controls.Add(trigger); } }ToolStripDropDown的优势是系统级的弹出窗口点击控件外部自动关闭内部控件点击不会把它关掉。CheckedListBox的CheckOnClick设为true后点一下选项就翻转勾选滚动条、键盘上下选择全是原生行为自绘方案里那些补齐交互的代码统统不需要了。6.2 ItemCheck事件里的时间差BeginInvoke读取最新勾选组合方案也有自己要踩的坑。CheckedListBox.ItemCheck触发时CheckedItems集合还没包含当前项直接在事件里拼trigger.Text会慢一拍回显结果总少一个。解决是用BeginInvoke把读取延迟到事件处理完成之后list.ItemCheck (s, e) BeginInvoke(new Action(() { string[] texts list.CheckedItems .Castobject() .Select(list.GetItemText) .ToArray(); trigger.Text string.Join(, , texts); }));这个时间差问题我用BeginInvoke兜住比e.NewValue手动判断当前项再塞进集合要省心。整个组合方案做下来自绘里那些DPI、缩进、MeasureItem的参数全都不需要了控件把尺寸和滚动全部内部消化。如果团队里有新手要维护多选下拉我会直接让他们用这个版本自绘方案留作轻量场景的备选。希望帮到你。本文还有配套的精品资源点击获取
返回列表