WinForms控件自绘实战:ComboBox多列下拉、自动完成与DataGridView自定义列 📅 发布时间:2026/9/2 20:39:27 👁 浏览次数: 简介一个面向C# WinForms开发者的实用控件通过继承ComboBox实现多列下拉框同时支持自动完成、模糊过滤和键盘操作并可作为DataGridView自定义列嵌入使用。针对表格录入时信息显示不完整、检索不便等痛点提供了完整源码与可运行示例。压缩包共25个文件以10个cs源码文件为主体内含DataGridView列、单元格及编辑控件等关键类附带3个exe示例程序、项目配置与resx/resources资源文件便于直接编译与二次修改整体仅82KB轻量易用。已有5301人学习下载。数据源、下拉显示列、列名及可搜索关键字均可灵活配置既可单独调用也能集成到DataGridView单元格中适合中高级桌面开发者快速扩展WinForms原生控件能力。 做上位机开发的朋友应该都遇到过这种需求界面上要挑一台设备原生ComboBox只能显示一行字符串编号、名称、状态这些关键信息全挤在一起根本没法看好不容易做成多列下拉框了又要支持键盘直接输入自动匹配最后还要把这一套塞进DataGridView的单元格里让表格里的下拉列也能多列展示、自动完成。这篇文章就把这三件事串在一起讲清楚——C# WinForms环境下ComboBox多列下拉框、自动完成、DataGridView自定义列从原理到代码一次性落地。内容主要面向WinForms开发者、上位机开发者和刚入门C#但想系统搞定控件定制的朋友。文中方案不依赖任何第三方控件库纯.NET原生实现拿到就能改、改完就能用。1. 需求分析与方案选型1.1 需求拆解先说清楚这三件事到底在解什么题。第一件事ComboBox多列下拉框。原生ComboBox的数据模板是固定的一个Item通常就是一个字符串ToString()返回什么就显示什么。你把一个设备对象丢进去默认显示的就是类名还得重写ToString()才能显示一行文字。但在实际项目里用户更希望看到的是“编号、名称、状态”这样的多列信息甚至希望下拉列表里每列宽度不一样列之间还有分割线。这就是要用到自绘OwnerDraw的核心场景。第二件事自动完成。也就是用户在ComboBox里敲几个字下拉项自动过滤出匹配结果。WinForms内置了AutoComplete机制但它有几个硬伤匹配规则是前缀匹配而不是包含匹配只匹配单个字段不能跨列搜索下拉展示样式也很难自定义。这就导致了很多时候内置方案不能满足需求得自己实现过滤逻辑。第三件事DataGridView自定义列。表格里要嵌入下拉框微软提供了DataGridViewComboBoxColumn但它的缺点是展示区域只能显示一列文本下拉列表也没有多列自绘能力。如果想让表格里的“设备选择”列同时显示编号和名称或者要在单元格里画个按钮、状态标签就得自己处理列的自绘逻辑。这三个需求虽然各自独立但组合起来就是一个完整的上位机参数配置界面左侧是设备列表下拉框中间是表格每行要选择一个工位或产品右侧还要显示联动信息。我把三套方案放在一起讲就是因为它们在代码层面可以共用一套绘制函数和过滤逻辑一次写好三处通用。1.2 方案对比在开始写代码之前我把常见的技术路线梳理了一下这里直接给结论。方案优点缺点适用场景原生ComboBox DrawItem自绘无依赖、轻量、可控性高需要自己处理绘制细节大部分中小型项目第三方控件库DevExpress等开箱即用、效果好看商业授权贵、体积大、学习成本高预算充足的商业项目重写一个UserControl组合控件灵活度最高开发量大、需要处理焦点和事件转发复杂交互、跨项目复用TextBox ListBox组合模拟下拉自动完成逻辑完全可控需要自己控制弹出和隐藏顺序对自动完成有特殊要求的场景我这篇文章主推方案1和方案3的混合做法多列展示用原生DrawItem自绘自动完成用TextChanged事件加自定义过滤最后DataGridView列复用同一套绘制逻辑。不需要引入额外控件代码量控制在两百行左右性价比最高。1.3 核心原理OwnerDraw自绘机制在做ComboBox多列之前必须先理解一个关键概念——OwnerDraw所有者绘制。默认情况下ComboBox的绘制是系统完成的Item里面存什么就画什么你没法干预。但当你把DropDownStyle设置为DropDownList、DrawMode设置为OwnerDrawFixed或OwnerDrawVariable之后绘制权就交给了我们自己。系统在需要绘制每一项时会触发DrawItem事件事件参数里带了一个DrawItemEventArgs对象里面包含了该项的索引、绘制的矩形区域、Graphics对象、以及当前项是否处于选中状态。说白了DrawMode就是告诉系统“别画了我来画”。DrawItem就是你拿到画布之后的自由发挥时间。多列下拉框的实现本质就是拿到那个矩形区域把一行文本按列宽切成几段分段画出来中间再画一条竖线作为分割线。这里有一个经常被忽略的细节ComboBox的ItemHeight和下拉框的整体高度。OwnerDrawFixed模式下ItemHeight是固定的默认是13像素左右这个高度画文字还行但如果要在项里放第二行小字或者加图标就太矮了。需要手动把ItemHeight加大比如设到22~26像素。而DropDownHeight控制的是整个下拉列表展开后的总高度不是每一项的高度别搞混。2. ComboBox多列下拉框的实现2.1 基础配置先看一段最基础的配置代码。假设我们要做一个设备选择下拉框每个设备有编号Code、名称Name、状态Status三个属性。// 设置ComboBox进入自绘模式 cmbDevice.DropDownStyle ComboBoxStyle.DropDownList; cmbDevice.DrawMode DrawMode.OwnerDrawFixed; cmbDevice.ItemHeight 24; // 每一项的高度比默认值大一些 cmbDevice.DropDownHeight 180; // 下拉列表展开后的总高度 cmbDevice.DropDownWidth 450; // 关键一定要比ComboBox本身的宽度宽 // 列宽定义单位是像素 private int[] columnWidths { 80, 240, 90 }; private string[] columnNames { 编号, 名称, 状态 };配置里有两个坑必须提前说。第一DropDownWidth。ComboBox自身的宽度通常比较小比如只有200像素如果不下拉你可能看不见完整数据。DropDownWidth如果不设置下拉列表展开后的宽度就等于ComboBox本身的宽度多列根本放不下。所以一定要单独设置一个比控件宽度大得多的值这就是为什么上面我设成了450。第二ItemHeight和DrawMode必须配套。如果你设置了OwnerDrawVariable可变高度模式那就不能只依赖ItemHeight还需要处理MeasureItem事件去告诉系统每一项的实际高度而OwnerDrawFixed只需要设一个固定值即可简单很多。大多数场景用OwnerDrawFixed就够了除非你要做每项高度不同的效果。2.2 DrawItem事件实现多列绘制核心代码在DrawItem事件里。这一段代码是整套方案的地基后面DataGridView自绘列还会复用同样的逻辑。private void cmbDevice_DrawItem(object sender, DrawItemEventArgs e) { if (e.Index 0) return; // 先画背景selected状态用系统高亮色 e.DrawBackground(); // 从Items里取出数据对象 DeviceInfo device cmbDevice.Items[e.Index] as DeviceInfo; if (device null) return; string[] cellTexts { device.Code, device.Name, device.Status }; using (SolidBrush textBrush new SolidBrush(e.ForeColor)) { int x e.Bounds.Left; for (int i 0; i columnWidths.Length; i) { Rectangle cellRect new Rectangle(x, e.Bounds.Top, columnWidths[i], e.Bounds.Height); // 画分割线除了第一列前面 if (i 0) { using (Pen linePen new Pen(Color.FromArgb(200, 200, 200))) { e.Graphics.DrawLine(linePen, cellRect.Left, cellRect.Top 2, cellRect.Left, cellRect.Bottom - 2); } } // 文字垂直居中排列 TextRenderer.DrawText(e.Graphics, cellTexts[i], e.Font, new Rectangle(cellRect.Left 5, cellRect.Top, cellRect.Width - 10, cellRect.Height), textBrush.Color, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); x columnWidths[i]; } } // 画焦点框 e.DrawFocusRectangle(); }这段代码看起来简单但里面有几个细节值得展开讲讲。TextRenderer.DrawText和e.Graphics.DrawString的选择。DrawText是TextRenderer提供的方法内部走的是GDI的TextRenderer渲染出来的文字更清晰锐利尤其是中文字体在WinForms里差别很明显。DrawString走的是GDI在高DPI屏幕上容易发虚。做这种控件自绘我建议统一使用TextRenderer。分割线的画法决定了视觉效果。列之间的分割线不要画在矩形最左边缘上而是稍微向内缩几个像素否则文字贴线太近会显得拥挤。我在上面的代码里画线时left偏移了0但要给文字留5像素的padding实际体验才正常。关键判断e.Index 0。这个判断必须有。ComboBox初始化时可能会触发一次Index为-1的DrawItem事件这时候Items[e.Index]会直接抛异常。还有一点选中项的高亮背景。e.DrawBackground()这个方法会根据当前项是否处于选中状态自动画蓝底或系统主题色文字颜色用e.ForeColor也能自动适配。这一步偷懒不得否则选中状态下的白字白底会很奇怪。2.3 数据绑定与实体设计多列下拉框要正常显示Items里就不能放字符串要放数据对象。public class DeviceInfo { public string Code { get; set; } public string Name { get; set; } public string Status { get; set; } public override string ToString() { return string.Format({0} {1}, Code, Name); } } // 填充数据 ListDeviceInfo devices GetDeviceList(); cmbDevice.Items.Clear(); foreach (var device in devices) cmbDevice.Items.Add(device);这里有一个很多人会踩的坑ComboBox的SelectedItem和SelectedValue。如果你只用最简单的Items.Add方式填充数据那么SelectedItem返回的就是DeviceInfo对象本身拿取值很直接.SelectedItem as DeviceInfo。但如果你用DataSource绑定又设置了DisplayMember和ValueMember那SelectedItem返回的还是整个对象吗答案是分情况——用DataTable绑定的时候SelectedItem返回的是DataRowView还需要再转一层。所以我的建议是在自绘模式下坚持用Items.Add填充实体对象不要走DataSource那套省掉很多弯弯绕绕。3. 自动完成功能的实现3.1 内置AutoComplete机制的局限性WinForms的ComboBox其实自带自动完成——把AutoCompleteMode设为SuggestAppendAutoCompleteSource设为CustomSource再给AutoCompleteCustomSource塞进一堆字符串就有效果了。但这个方案有三个明显的坑。坑一匹配规则不可控。内置的CustomSource自动完成匹配的是前缀也就是说你输入“P”才能匹配到“PL2000”输入“2000”是匹配不到的。但在设备查询场景里用户往往只记得设备名称中间的几个字前缀匹配形同虚设。坑二数据源是字符串集合。AutoCompleteCustomSource的类型是AutoCompleteStringCollection只能放字符串。一旦用了这个机制下拉列表里就只能显示这些字符串跟DrawItem自绘的多列数据完全脱节。也就是说自动完成和自绘多列这两个需求在原生机制下是矛盾关系用了一个就没法用另一个。坑三赋值顺序有讲究。设置AutoCompleteCustomSource必须在AutoCompleteSource设为CustomSource之前完成否则会抛异常。这就是很多新手报错“AutoCompleteCustomSource cannot be initialized without setting AutoCompleteSource to CustomSource”的直接原因。3.2 TextChanged事件实现自定义过滤既然内置方案满足不了需求那就自己接管过滤逻辑。思路很简单TextChanged事件里把当前输入的文本作为过滤条件重新生成下拉Items。但这里有一个细节必须处理当DropdownStyle是DropDownList时用户不能输入文本TextChanged也不会触发。所以自动完成的方案前提是ComboBox的DropDownStyle必须改成DropDown允许用户自由输入。核心代码如下。private string _currentFilter string.Empty; private bool _isProgrammaticChange false; private void cmbDevice_TextChanged(object sender, EventArgs e) { if (_isProgrammaticChange) return; string keyword cmbDevice.Text.Trim(); if (string.IsNullOrEmpty(keyword)) { // 关键字为空恢复全部数据 RefreshDropdownList(_allDevices); return; } // 过滤条件编号包含、名称包含、状态包含任意命中即可 var filtered _allDevices.Where(d d.Code.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) 0 || d.Name.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) 0 || d.Status.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) 0).ToList(); RefreshDropdownList(filtered); // 过滤后保持下拉框为打开状态用户才感觉是“边输入边筛选” cmbDevice.DroppedDown filtered.Count 0; } private void RefreshDropdownList(ListDeviceInfo data) { string currentText cmbDevice.Text; // 用标志位防止程序修改Text时再次触发TextChanged _isProgrammaticChange true; cmbDevice.BeginUpdate(); cmbDevice.Items.Clear(); foreach (var device in data) cmbDevice.Items.Add(device); cmbDevice.EndUpdate(); _isProgrammaticChange false; cmbDevice.Text currentText; cmbDevice.SelectionStart cmbDevice.Text.Length; cmbDevice.SelectionLength 0; }这段代码的核心逻辑是TextChanged触发时用当前文本过滤数据源然后重建Items。这里有几个细节要解释清楚。_isProgrammaticChange标志位非常关键。RefreshDropdownList里我们会设置cmbDevice.Text这个赋值会再次触发TextChanged事件如果不加标志位就会形成无限递归直接栈溢出。设置Text前把标志位置为trueTextChanged里检测到标志位就直接return一次过滤循环就安全结束了。Buffer里塞数据时用BeginUpdate/EndUpdate包起来可以避免每次Items.Add都触发一次界面重绘。数据量大了以后这个优化很重要——几百条数据不包的话界面会有明显的闪烁和卡顿。过滤完之后要重新把用户输入的文字塞回去。因为Items.Clear()会连带把Text清空不恢复的话用户敲一个字下来文本框反而变空白了体验非常差。恢复Text后再把光标移到末尾不然继续输入时文字会从开头插入。3.3 过滤性能与防抖优化上面的代码在数据量几百条的时候完全够用。但如果你的设备列表有上万条每次TextChanged都触发一次Where过滤加Items.Clear/Add就会开始卡顿。这时候有两个优化方向。第一个是防抖。用户连续输入时每一次按键都会触发过滤中间很多次过滤的结果马上就被下一次覆盖了纯属浪费。用一个System.Windows.Forms.Timer设200毫秒间隔TextChanged里只记录文本并重启计时器Timer的Tick事件里才真正执行过滤。private Timer _filterTimer new Timer { Interval 200 }; private void cmbDevice_TextChanged(object sender, EventArgs e) { // 重置计时器只有停止输入200ms后才触发过滤 _filterTimer.Stop(); _filterTimer.Start(); } private void _filterTimer_Tick(object sender, EventArgs e) { _filterTimer.Stop(); DoFilter(cmbDevice.Text.Trim()); }第二个是预排序加二分查找。如果数据源是稳定的可以在加载时按编号排好序过滤时用List .BinarySearch代替Where遍历。不过这个优化对多数场景来说收益不大除非你的设备列表真的到了上万级别否则我建议先把精力放在防抖上性价比更高。4. DataGridView自定义列的实现4.1 列方案对比DataGridView里的自定义列其实就是围绕两个核心机制展开一是DataGridViewComboBoxColumn这个现成列类型二是CellPainting事件驱动的自绘。DataGridViewComboBoxColumn的优势是省事默认就具备下拉功能DisplayMember和ValueMember一站配齐。缺点是它在单元格里显示的内容只有一列文本下拉列表也不支持多列自绘。DataGridViewTextBoxColumn加CellPainting自绘则正好反过来展示区域可以画任意内容通过EditingControlShowing事件注入一个自定义的ComboBox编辑控件后下拉交互也能做得非常丰富。一句话总结如果只是做个简单的值映射下拉用DataGridViewComboBoxColumn就够了如果需要在表格里展示多列信息或者要有自动完成就必须走自绘路线。下表把两种方案的适用场景摆一起对比。需求DataGridViewComboBoxColumnDataGridViewTextBoxColumn 自绘简单下拉选择推荐复杂度高多列展示不支持推荐自动完成需要额外代码推荐展示自定义状态标签不支持推荐开发量小中4.2 使用DataGridViewComboBoxColumn先看最简单的情况。要给表格加一个“设备”下拉列数据源是固定的。DataGridViewComboBoxColumn colDevice new DataGridViewComboBoxColumn(); colDevice.HeaderText 设备; colDevice.DataSource _allDevices; colDevice.DisplayMember Name; // 下拉和单元格显示的都是Name colDevice.ValueMember Code; // 实际存储的值是Code colDevice.DataPropertyName DeviceCode; // 绑定到数据行里的这个字段 dataGridView1.Columns.Insert(2, colDevice);这样配置完后表格每一行的DeviceCode字段会显示成对应的设备名称点开下拉可以切换。这套机制在数据行数不多的时候跑得很顺畅但在两个细节上会翻车。第一个细节是DataSource类型。上面直接绑了ListDeviceInfo如果后续这个列表有增删表格里的下拉选项不会自动刷新。解决方案是把DataSource绑定到BindingSource手动调用ResetBindings。BindingSource bsDevice new BindingSource { DataSource _allDevices }; colDevice.DataSource bsDevice; // 数据发生变化后调用 bsDevice.ResetBindings(false);第二个细节是EditingControlShowing事件。DataGridViewComboBoxColumn虽然在显示层面完成了一半工作但它内嵌的ComboBox编辑控件默认不带自动完成用户在下拉列表里选了角色后第二次再想输入关键字搜索就没有任何反应。要启用自动完成必须拿到这个编辑控件。private void dataGridView1_EditingControlShowing(object sender, DataGridViewEditingControlShowingEventArgs e) { if (dataGridView1.CurrentCell.ColumnIndex colDevice.Index e.Control is ComboBox combo) { // 先移除旧事件防止重复挂载 combo.SelectedIndexChanged - Combo_SelectedIndexChanged; combo.SelectedIndexChanged Combo_SelectedIndexChanged; // 启用自动完成这里用的是内置的字符串前缀匹配够用即可 combo.DropDownStyle ComboBoxStyle.DropDown; combo.AutoCompleteMode AutoCompleteMode.SuggestAppend; combo.AutoCompleteSource AutoCompleteSource.ListItems; } } private void Combo_SelectedIndexChanged(object sender, EventArgs e) { // 联动其他列 if (dataGridView1.CurrentCell ! null sender is ComboBox combo combo.SelectedItem is DeviceInfo device) { dataGridView1.CurrentRow.Cells[colModel].Value device.Model; } }这里有个小坑EditingControlShowing事件会在每次进入编辑状态时触发而DataSource绑定方式下ComboBox的SelectedIndexChanged里面拿到的SelectedItem类型取决于DataSource的结构。如果你绑定的是BindingSource包了一层List对象那SelectedItem通常是DeviceInfo对象但如果你绑定的是DataTable那SelectedItem就是DataRowView。做联动逻辑之前一定要先确认数据源类型再写类型转换否则必踩空。4.3 单元格自绘多列展示再来处理更进阶的需求表格里的设备列单元格显示的不是单一文本而是“编号 名称 状态”三列信息。这里我们要用DataGridViewTextBoxColumn配合CellPainting事件。private void dataGridView1_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { // 只处理我们关心的列并且要过滤列头行 if (e.RowIndex 0 || e.ColumnIndex ! colDeviceInfo.Index) return; DeviceInfo device e.Value as DeviceInfo; if (device null) return; // 先绘制默认背景和边框 e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border | DataGridViewPaintParts.Focus | DataGridViewPaintParts.SelectionBackground); // 手工绘制三分栏文字 int[] widths { 70, 130, 60 }; int x e.CellBounds.Left; string[] texts { device.Code, device.Name, device.Status }; using (Brush brush new SolidBrush(e.CellStyle.ForeColor)) { for (int i 0; i widths.Length; i) { Rectangle textRect new Rectangle(x 5, e.CellBounds.Top, widths[i] - 5, e.CellBounds.Height); TextRenderer.DrawText(e.Graphics, texts[i], e.CellStyle.Font, textRect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); // 画列分隔线 x widths[i]; using (Pen pen new Pen(Color.FromArgb(220, 220, 220))) { e.Graphics.DrawLine(pen, x, e.CellBounds.Top 3, x, e.CellBounds.Bottom - 3); } } } e.Handled true; // 告诉DataGridView我们已画完不需要默认绘制 }这一段的关联线程是e.Paint负责画背景和焦点框TextRenderer.DrawText负责画文字内容最后e.Handled true告诉DataGridView不用再执行默认绘制。三个环节缺一不可少了e.Handled你会看到文字重叠少了e.Paint单元格没有背景色选中状态会崩掉。有一个细节需要注意的是自绘列的Value属性存的是什么。上面我是在CellPainting里直接e.Value as DeviceInfo那在给单元格赋值时就要赋整个对象而不是只赋字符串。比如dataGridView1.Rows.Add(device)或者row.Cells[colDeviceInfo].Value device。这样你的单元格对象信息才完整绘制时才能拿出三列数据画出来。5. 常见问题与避坑实录写这套方案的代码时我踩过的坑基本都能列成一个清单。下面把高频问题都整理出来遇到的问题可以直接照着排查。问题根本原因解决方案DrawItem不触发DrawMode没有设置为OwnerDrawFixed或OwnerDrawVariable检查DrawMode同时确认没有用DataSource绑定下拉框展开后宽度太窄没有设置DropDownWidth或设置值小于ComboBox本身宽度显式设置DropDownWidth为能容纳所有列宽之和下拉列表出现纵向滚动条把最后一项盖住DropDownHeight设置过小适当调大DropDownHeight或者把IntegralHeight设为false自绘文字出现重叠e.Handled未设置为true系统默认绘制又执行了一遍绘制完成后设置e.Handled true自动完成过滤时光标总是跳到行首清空Items后没有恢复Text和光标位置清空重建后恢复Text再设置SelectionStart和SelectionLength英文输入法下过滤正常中文输入法出现候选框冲突IME模式与TextChanged事件冲突在TextChanged里插入防抖计时器并检查InputMethod.Current.ImeConversionModeDataGridViewComboBoxColumn数据更新后选项没变化数据源没有刷新通知改用BindingSource调用ResetBindings(false)DataGridView中自绘单元格选中时文字看不清只画了文字没有画选中背景在最开始调用e.Paint并包含SelectionBackground部分自动完成过滤报IndexOutOfRangeException过滤过程中Items清空但SelectedIndex还残留清空Items前先将SelectedIndex设为-1除了这张表还有两个细节值得单独提一下。第一个是ComboBox自绘时TextRenderer画出来的文字在Windows经典主题和高DPI屏幕上可能和预期不一致。建议给主程序加上App.config里的DPI感知配置并统一使用TextRenderer.DrawText在实测中它的高DPI表现比Graphics.DrawString稳定得多。第二个是数据量大的场景过滤逻辑里尽量不要在每次TextChanged都全量遍历否则界面会卡到没法输入。我自己的做法是加载数据时按设备编号做一次排序过滤时只用Where做一次线性扫描数据量到五千条以上再引入字典索引或者List.FindAll。多数设备系统能到几百台已经很多了这个优化更多是给列表型任务用的。最后分享一个我在实际使用中发现的小技巧自绘多列下拉框和DataGridView自绘列共用一套列宽数组和分割线绘制函数把这段逻辑抽成一个公共静态方法比如PaintMultiColumnCell(Graphics g, Rectangle bounds, string[] texts, int[] widths, Font font, Color foreColor)。这样ComboBox的DrawItem和DataGridView的CellPainting各调各的视觉风格完全统一后续调整列宽、颜色也只需要改一处。一开始我就是两边各写了一遍结果同一套视觉效果在表格和下拉框里颜色差了一点找了好久才发现是两个地方用了不同深浅的分割线。抽成公共方法之后这类问题再也没出现过。本文还有配套的精品资源点击获取