ARTICLE DETAIL

资讯详情

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

WinForms多列下拉框与DataGridView自动完成控件实现

WinForms多列下拉框与DataGridView自动完成控件实现 简介面向C# WinForms开发者的ComBox多列下拉框增强方案解决默认下拉框只能显示单一文本、无法多列展示与自动匹配的痛点。通过继承ComboBox实现数据源、显示列、列名及搜索关键字均可自由配置支持键盘操作、模糊过滤和自动完成既可作为独立控件使用也可嵌入DataGridView自定义列满足数据录入和选择场景的多样化需求。压缩包共25个文件含10个cs核心源码、3个exe演示程序以及resx、resources等资源文件并附带解决方案和项目文件整体仅82KB源码结构清晰方便直接对照学习和移植。源码中涉及DataGridViewDataWindowColumn、DataGridViewDataWindowCell及DataGridViewDataWindowEditingControl等关键类完整演示了网格内嵌下拉编辑的封装思路可帮助开发者理解自定义控件原理。已有5301人学习适合需要提升下拉交互体验的WinForms中高级开发者。 做WinForms上位机或者管理系统的朋友迟早会在一个需求前卡住界面上的下拉框光显示一列文本根本不够用。做上位机要选设备型号做进销存要选物料你希望下拉框里同时看到编码、名称、规格、库存甚至价格选完以后还要把选中的整条数据回填到旁边的文本框、Label或者DataGridView的某一格里。我第一次遇到这个需求是在一套仓储管理系统里物料清单几千条下拉框只显示物料名称重名的东西一多光靠肉眼根本分不清该选哪个选错了就是一次库存事故。后来我把多列下拉框、自动完成、DataGridView自定义列这套组合写成了一个通用控件前后迭代过三个版本今天把最终方案的完整思路和关键代码拆给你看。整个方案不依赖任何第三方UI库纯WinForms原生就能跑适合做C#上位机、桌面工具、管理类系统的朋友直接抄作业。1. 需求分析与方案选型1.1 原生ComboBox的三个硬伤先说清楚原生ComboBox到底哪里不行不然你不会理解后面为什么要绕这么大一圈。第一原生ComboBox只能显示单列文本。想让它同时展示物料编码、名称、规格最常见的做法是把几个字段拼成一个字符串中间用空格隔开。显示是显示出来了但列之间完全没有对齐边界而且更麻烦的是用户选中之后你要重新去拆字符串才能拿到各个字段。拆了你就知道字段里有备注、有空格的情况能把人活活气死。第二AutoComplete的匹配能力很弱。原生自带的自动完成建议模式只有Suggest、Append、SuggestAppend三种本质上都是前缀匹配。你输“001”能匹配到“001001”这种开头一致的但如果你想按物料名称搜、按规格搜、甚至按拼音首字母搜原生方案一个都做不了。而且它的建议框是系统自己绘制的走的是AutoCompleteStringCollection字符串列表路线列表里的每一项都是一段纯文本不可能呈现成多列结构。第三DataGridView单元格里要用多列下拉框就更麻烦了。DataGridView自带的DataGridViewComboBoxColumn只支持单列它内部弹出来的下拉列表样式你完全控制不了。很多人的做法是在单元格里放一个原生ComboBox控件去模拟但那样焦点管理、位置同步、滚动跟随全是坑做出来的东西像贴了一块膏药怎么看怎么别扭。1.2 三条技术路线与最终选型面对这个需求通常有三条路可以走我逐一踩过说说真实体会。路线A上第三方控件库。DevExpress、Telerik、ComponentOne这些商业控件都有现成的多列下拉和自动完成控件功能确实齐全颜值也高。但代价是引入重量级依赖安装包体积直接翻几倍还得学一套新API。更麻烦的是如果公司项目对授权有要求光License这一关就够你喝一壶。为了一个下拉框引入整个商业控件库总觉得有点用牛刀杀鸡。路线B继承原生ComboBox用OwnerDrawVariable自绘模式自己画多列同时配合TextChanged事件做个性化过滤再扩展成DataGridViewCell的编辑控件。这条路改造工作量适中代码量控制在三百行以内不引入任何额外依赖所有细节都在自己手里想怎么改都行。路线C用TextBox加ListBox或者DataGridView做浮层自己模拟一个下拉框。这种方案做出来的效果最自由多列、搜索框、分页都能塞进去但实现成本极高光焦点转移、失焦关闭、键盘导航这几件事就够折腾一星期放到DataGridView单元格编辑场景下更是容易出问题。我最终选了路线B。理由很简单WinForms原生的ComboBox已经把最难的那部分处理好了包括下拉展开收起、滚动、键盘导航、失焦自动关闭这些都是成熟稳定的行为我们只是在它基础上补一层绘制和过滤逻辑投入产出比最高。下面就把完整实现步骤拆开讲。2. 核心实现一个可复用的多列下拉框控件2.1 类设计与对外接口先定义控件类的骨架。我习惯把列信息封装成ColumnInfo类每一列包含绑定字段名、列标题、列宽三个核心属性方便在窗体设计器里用代码初始化。控件的基类直接继承ComboBox构造函数里把DrawMode设为OwnerDrawVariable同时把DropDownStyle设为DropDownList这样用户只能从列表中选择避免手动输入造成的脏数据。public class MultiColumnComboBox : ComboBox { public class ColumnInfo { public string DataField { get; set; } public string Header { get; set; } public int Width { get; set; } public TextFormatFlags Format { get; set; } TextFormatFlags.Left; } private ListColumnInfo _columns new ListColumnInfo(); private DataTable _dataTable; private bool _isFiltering; public MultiColumnComboBox() { DrawMode DrawMode.OwnerDrawVariable; DropDownStyle ComboBoxStyle.DropDownList; } public void AddColumn(string dataField, string header, int width) { _columns.Add(new ColumnInfo { DataField dataField, Header header, Width width }); } public void SetDataSource(DataTable table) { _dataTable table; DataSource table; SelectedIndex -1; } }这里有个细节值得说明DropDownStyle的选择会直接影响后面的自动完成方案。如果你坚持用DropDown模式允许用户输入那SelectedIndexChanged的触发时机变得非常敏感因为用户在编辑文本时也会触发索引变化控制不好就会导致显示值和选中值不一致。我建议先走DropDownList模式把多列绘制稳定下来自动完成部分用后面讲的过滤方案去补充。2.2 MeasureItem与DrawItem的绘制核心自绘是本方案最关键的部分没有之一。MeasureItem负责告诉系统每一个下拉项占据多高同时计算需要的总宽度DrawItem负责真正画出每一项的内容。先看MeasureItem。很多人写自绘ComboBox时不覆写这个方法结果列表项行高只有默认的十几像素文字全部挤在一起。正确的做法是显式设置ItemHeight同时在这里检查列总宽度如果超过当前的DropDownWidth就自动扩宽保证下拉框能完整展示多列内容。protected override void OnMeasureItem(MeasureItemEventArgs e) { base.OnMeasureItem(e); e.ItemHeight 26; int totalWidth _columns.Sum(c c.Width); if (DropDownWidth totalWidth) { DropDownWidth totalWidth; } }再看DrawItem。绘制时首先要处理e.Index为负数的情况这是空列表时的状态直接交回base处理。接着根据选中状态决定背景色和前景色这里建议用SystemColors.Highlight和SystemColors.HighlightText不要自己写死颜色否则系统换主题时你的控件看起来会很突兀。绘制文本用TextRenderer.DrawText这个API在高DPI环境下文字更清晰性能也比Graphics.DrawString好一个档次。protected override void OnDrawItem(DrawItemEventArgs e) { if (e.Index 0 || _dataTable null || e.Index _dataTable.Rows.Count) { base.OnDrawItem(e); return; } bool selected (e.State DrawItemState.Selected) DrawItemState.Selected; Color backColor selected ? SystemColors.Highlight : BackColor; Color foreColor selected ? SystemColors.HighlightText : ForeColor; using (SolidBrush brush new SolidBrush(backColor)) { e.Graphics.FillRectangle(brush, e.Bounds); } DataRow row _dataTable.Rows[e.Index]; int x e.Bounds.Left 4; foreach (ColumnInfo col in _columns) { string text row[col.DataField]?.ToString() ?? string.Empty; Rectangle rect new Rectangle(x, e.Bounds.Top, col.Width, e.Bounds.Height); TextRenderer.DrawText(e.Graphics, text, Font, rect, foreColor, col.Format); x col.Width; } // 绘制列分隔线 int lineX e.Bounds.Left 4; using (Pen pen new Pen(Color.Gainsboro)) { foreach (ColumnInfo col in _columns) { lineX col.Width; e.Graphics.DrawLine(pen, lineX, e.Bounds.Top 2, lineX, e.Bounds.Bottom - 2); } } }这段代码里有两点要特别注意。第一DataRow索引直接用e.Index是安全的因为后面做自动完成过滤时我们替换的是DataSource指向的DataTable而不是在原表上做筛选所以列表里的行号始终和当前数据源的行号一一对应。第二列分隔线的位置要略缩进一点第一条线不要画在边框上否则看起来非常挤。我在第一版时没注意这个缩进下拉列表整体视觉效果显得很乱加上4像素左边距之后才正常。2.3 数据取值与下拉宽度计算多列下拉框还有个容易忽略的细节列宽如果设置得太死板长内容会被截断成一串省略号。我建议在SetDataSource时遍历一遍数据按各列内容的实际最长宽度动态调整列宽取配置宽度和内容宽度的最大值。public void SetDataSource(DataTable table) { _dataTable table; DataSource table; SelectedIndex -1; if (_dataTable null) return; foreach (ColumnInfo col in _columns) { int maxLen col.Width; foreach (DataRow row in _dataTable.Rows) { string text row[col.DataField]?.ToString() ?? string.Empty; int textWidth TextRenderer.MeasureText(text, Font).Width; if (textWidth maxLen) maxLen textWidth; } col.Width maxLen 12; } DropDownWidth _columns.Sum(c c.Width); }这个遍历放在几千条数据的表上不会有性能问题毕竟只是算字符串像素宽度毫秒级就完成了。真正要注意的是列宽计算出来之后要给内容留一点呼吸空间所以我在最右侧加了12像素的余量否则文字会贴着下一条分隔线阅读起来很难受。3. 自动完成不要用AutoCompleteMode自己写3.1 原生AutoComplete的局限明确了多列绘制的方案之后自动完成这块也得走自己的路。原生ComboBox的AutoCompleteMode、AutoCompleteSource、AutoCompleteCustomSource这三个属性组合对付简单场景够用但放在我们的多列下拉框里完全不成立。核心原因有两个它是纯前缀匹配不支持在任意位置模糊搜索它绑定的是AutoCompleteStringCollection字符串集合而不是DataTable多列信息没法携带。我做过的项目里用户的操作习惯大多是这样他知道物料编码大概是“ABC”开头但他也可能只记得名称里的某个关键字甚至只记得规格里的一个数字。前缀匹配在这个场景下等于没有匹配。所以自动完成的真正需求是模糊搜索而且搜索范围要覆盖多列字段。3.2 基于TextChanged的过滤下拉实现我的做法是给控件外部提供一个搜索入口用防抖Timer避免用户每敲一个键就触发一次全量过滤然后在TextChanged或者搜索事件里按关键字过滤数据源重新赋值给DataSource最后重新展开下拉框。private System.Windows.Forms.Timer _searchTimer; private void InitSearch() { _searchTimer new System.Windows.Forms.Timer { Interval 200 }; _searchTimer.Tick (s, e) { _searchTimer.Stop(); ApplyFilter(Text.Trim()); }; TextChanged (s, e) { if (_isFiltering) return; _searchTimer.Stop(); _searchTimer.Start(); }; } private void ApplyFilter(string keyword) { _isFiltering true; try { if (string.IsNullOrEmpty(keyword)) { DataSource _dataTable; } else { DataTable temp _dataTable.Clone(); foreach (DataRow row in _dataTable.Rows) { bool hit _columns.Any(col { string value row[col.DataField]?.ToString() ?? string.Empty; return value.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) 0; }); if (hit) temp.ImportRow(row); } DataSource temp; } if (Items.Count 0) { BeginInvoke(new Action(() DroppedDown true)); } } finally { _isFiltering false; } }这段代码有四个关键细节。第一Clone加ImportRow的组合不会破坏原表筛选出来的临时表和原表共享DataRow引用效率很高而且不会影响其他绑定同一数据源的控件。第二TextChanged里用_isFiltering标志位拦截否则每次给DataSource重新赋值都会触发TextChanged造成递归调用。第三用Timer做200毫秒防抖用户连续输入时不会频繁重建临时表实测两万行数据也不会卡。第四重新展开下拉框必须用BeginInvoke延迟到消息队列尾部否则在输入法处理过程中直接设置DroppedDown下拉框可能刚打开就被后续消息关掉了。这里顺带说一个常见的困惑DataGridView单元格编辑模式下用户按回车想确认选择结果焦点直接跳到下一行或者弹出一个MessageBox回车事件被吃掉。这其实是KeyPress和MessageBox的交互问题后面专门讲。4. DataGridView自定义列整合4.1 自定义列与单元格类型设计多列下拉框控件做好之后要把它塞进DataGridView作为单元格的编辑控件。这一步需要自定义两个类DataGridViewColumn的子类和DataGridViewCell的子类。我选择继承DataGridViewTextBoxColumn而不是DataGridViewColumn原因是TextBox列已经有了一套成熟的编辑控件处理机制包括默认的Text值类型和格式化逻辑我们只需要把自己的控件类型替换进去。public class MultiColumnComboColumn : DataGridViewTextBoxColumn { public MultiColumnComboColumn() { CellTemplate new MultiColumnComboCell(); } public ListMultiColumnComboBox.ColumnInfo Columns { get; set; } } public class MultiColumnComboCell : DataGridViewTextBoxCell { public override Type EditType typeof(MultiColumnComboEditingControl); public override Type ValueType typeof(string); public override Type FormattedValueType typeof(string); }继承TextBoxCell有个好处单元格的默认显示逻辑不用自己实现直接把值当作文本显示出来。只有用户进入编辑状态时才会触发EditType指向的编辑控件。4.2 编辑控件实现IDataGridViewEditingControl接下来是关键中的关键让MultiColumnComboBox实现IDataGridViewEditingControl接口。这个接口是DataGridView和编辑控件之间的通信协议核心成员包括EditingControlFormattedValue、GetEditingControlFormattedValue、ApplyCellStyleToEditingControl、EditingControlWantsInputKey。public class MultiColumnComboEditingControl : MultiColumnComboBox, IDataGridViewEditingControl { private DataGridView _dataGridView; private int _rowIndex -1; private bool _valueChanged; public DataGridView EditingControlDataGridView { get _dataGridView; set _dataGridView value; } public int EditingControlRowIndex { get _rowIndex; set _rowIndex value; } public bool RepositionEditingControlOnValueChange false; public bool EditingControlWantsInputKey(Keys keyData, bool dataGridViewWantsInputKey) { switch (keyData Keys.KeyCode) { case Keys.Enter: case Keys.Tab: case Keys.Escape: return true; default: return !dataGridViewWantsInputKey; } } public object EditingControlFormattedValue { get GetEditingControlFormattedValue(DataGridViewDataErrorContexts.Formatting); set Text value?.ToString() ?? string.Empty; } public object GetEditingControlFormattedValue(DataGridViewDataErrorContexts context) { return SelectedItem?.ToString() ?? string.Empty; } public void ApplyCellStyleToEditingControl(DataGridViewCellStyle dataGridViewCellStyle) { Font dataGridViewCellStyle.Font; } protected override void OnSelectedIndexChanged(EventArgs e) { base.OnSelectedIndexChanged(e); if (SelectedIndex 0) { _valueChanged true; EditingControlDataGridView?.NotifyCurrentCellDirty(true); } } }IDataGridViewEditingControl这个接口的方法看着多其实大部分都是模板化的照抄就能用。真正要花心思的是EditingControlWantsInputKey它决定了用户按回车、Tab、方向键时事件到底给谁。我把Enter、Tab、Escape都拦截下来让DataGridView决定处理逻辑这样用户按回车就能确认编辑并跳到下一行符合桌面软件的通用操作习惯。4.3 单元格事件驱动与联动回填编辑控件接入之后还需要在DataGridView的EditingControlShowing事件里初始化列定义和数据源。因为多列下拉框的列配置不是全局统一的不同列可能有不同的展示字段。private void dgv_EditingControlShowing(object sender, DataGridViewEditingControlShowingEventArgs e) { if (e.Control is MultiColumnComboEditingControl combo dgv.CurrentCell is MultiColumnComboCell) { combo.Items.Clear(); foreach (var col in ((MultiColumnComboColumn)dgv.CurrentCell.OwningColumn).Columns) { combo.AddColumn(col.DataField, col.Header, col.Width); } DataTable source GetMaterialTable(); combo.SetDataSource(source); combo.Text dgv.CurrentCell.EditedFormattedValue?.ToString() ?? string.Empty; } }联动回填逻辑放在CellValueChanged事件里做。比如选择了物料编码之后自动把物料名称、规格、单价填到同一行的其他列。这里有个常见的坑CellValueChanged在DataSource绑定模式下触发时机很微妙有时单元格还没提交行里的其他单元格拿不到最新值。稳妥的做法是在CellEndEdit事件里统一处理或者先调用CommitEdit强制提交。我实际项目里用的代码是这样private void dgv_CellEndEdit(object sender, DataGridViewCellEventArgs e) { if (dgv.Columns[e.ColumnIndex] is MultiColumnComboColumn) { string code dgv.Rows[e.RowIndex].Cells[e.ColumnIndex].Value?.ToString(); if (string.IsNullOrEmpty(code)) return; DataRow row FindRowByCode(code); if (row null) return; dgv.Rows[e.RowIndex].Cells[colName].Value row[Name]; dgv.Rows[e.RowIndex].Cells[colSpec].Value row[Spec]; dgv.Rows[e.RowIndex].Cells[colPrice].Value row[Price]; } }这样选择即回填的效果就出来了用户在下拉框里选一个物料编码整行的名称规格价格自动带出来效率比手填高了一个数量级。5. 踩坑实录与排查技巧5.1 绘制闪烁与文字模糊问题自绘控件最常见的问题就是闪烁。ComboBox的DoubleBuffered属性是protected的在外部访问不到需要在子类构造函数里通过反射强制开启或者直接子类里设置。public MultiColumnComboBox() { DrawMode DrawMode.OwnerDrawVariable; DropDownStyle ComboBoxStyle.DropDownList; typeof(Control).GetProperty(DoubleBuffered, BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(this, true); }这段反射虽然看起来不太优雅但确实有效。开启DoubleBuffered之后下拉列表滚动时的撕裂感会明显减轻。文字模糊的问题重点确认你用的是TextRenderer.DrawText而不是Graphics.DrawString。DrawString走的是GDI在DPI缩放125%、150%的系统上容易出现字体边缘发虚DrawText走GDI接口文字边缘渲染更锐利特别适合这种需要一行一行对齐显示的场景。5.2 回车键、MessageBox与输入法冲突热搜词里有一条“c# keyup事件中messagebox的回车(enter)按键的回调问题”这个坑我在做自动完成时也踩过。现象是在KeyUp事件里弹MessageBox提示选择结果MessageBox关闭之后刚才的回车键好像又被触发了一次导致后续操作重复执行。原因很简单MessageBox弹出时回车键事件已经被系统送到消息队列里MessageBox关闭之后这个回车消息又继续传递。解决办法是彻底避开在KeyUp里直接弹窗用BeginInvoke把弹窗操作挪到消息队列尾部让回车事件先处理完或者用Application.DoEvents强制刷新队列。我建议用BeginInvoke代码是private void combo_KeyUp(object sender, KeyEventArgs e) { if (e.KeyCode Keys.Enter) { BeginInvoke(new Action(() { // 这里再弹MessageBox或者执行确认逻辑 })); } }输入法的问题在中文系统中特别明显。用户输入拼音时每个字母都会触发TextChanged导致过滤频繁执行界面一顿一顿的。用200毫秒防抖是基础手段更彻底的方案是把ComboBox的ImeMode设为Off让用户用拼音输入但过滤时不处理中间过程。实测这个组合方案在中文输入场景下体验最好。5.3 DataGridView列宽被挤压与焦点丢失热搜词里还有一条“datagridview列标题列宽没有超出却又一些会被拥挤”这实际上是DataGridView的AutoSizeColumnsMode和Fill权重搭配问题。当你给某列设置了Fill模式时DataGridView会按照列的FillWeight比例分配剩余宽度如果这个列的数据本身不需要那么宽就会造成其他列被“挤瘦”的视觉错觉。解决方法是给非填充列设置一个最小宽度并关闭AutoSizeColumnsMode的Fill模式。在我们这个场景里多列下拉框列的宽度已经由控件内部计算好了所以应该用AutoSizeColumnsMode.None由我们自己指定列的Width。焦点丢失的问题最隐蔽。在DataGridView编辑状态下如果Filter重建了DataSource当前编辑控件可能被重建焦点就丢了。解决思路是在单元格编辑过程中不要更换DataSource只在进入编辑状态时初始化一次。所以上面的代码里我是把SetDataSource放在EditingControlShowing事件里做的保证整个编辑生命周期内数据源稳定。6. 写在最后的个人经验这套方案我前后写了三版第一版是纯拼字符串加原生AutoComplete第二版才改成自绘第三版补上了DataGridView的编辑控件集成。过程中最深的体会是WinForms虽然老但它的扩展机制非常成熟关键是找对切入点。ComboBox的OwnerDrawVariable模式和IDataGridViewEditingControl接口就是这扇门一旦摸准了很多看起来复杂的需求都能用原生控件组合出来。在实际项目里这套方案已经完全替代了我之前对第三方控件的依赖。如果你后续要扩展可以考虑在过滤时加入拼音首字母索引或者用BindingSource的Filter属性替代Clone加ImportRow大数据量下性能会更好。别一上来就追求完美先让它在项目里跑起来再根据体验慢慢迭代。本文还有配套的精品资源点击获取
返回列表