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

资讯详情

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

C# WinForms 自制 DataGridView 表头下拉筛选与 RowFilter 实现

C# WinForms 自制 DataGridView 表头下拉筛选与 RowFilter 实现

简介:面向Windows Forms开发者的DataGridView表头下拉筛选实现示例,面向C#与VB开发者,针对数据表格大、需快速筛选的场景。资源共125个文件,压缩包约542KB,主要包含C#源码(15个cs)、VB源码(18个vb)、可执行程序(12个exe)与动态库(12个dll),以及resx/resources资源文件、settings设置文件、项目工程(sln/csproj/vbproj)等,类别清晰,可直接编译查看效果;另附类图与测试数据设计器,便于阅读工程结构。示例从自定义DataGridViewColumnHeaderCell着手,重写绘制逻辑实现表头下拉箭头,并通过ColumnHeaderMouseClick事件弹出筛选项;代码同时给出了CheckedListBox多选交互、DataView.RowFilter与LINQ两种过滤写法,还提供了“全部”选项与清除筛选恢复数据的方案,覆盖完整实现路径。已有985人学习,工程注释和目录结构便于学习,适合初初级开发者用做功能扩展参考,可快速集成到实际项目中提升数据操作效率。

1. 表头下拉筛选:原生 DataGridView 不做,那就自己动手画一个

用 C# 写 WinForms 后台界面的人,迟早会遇到 DataGridView 表头下拉筛选这个需求:列头右侧一个小三角,点开是这一列的唯一值,选中后表格马上过滤。原生 DataGridView 不但没有这个功能,默认还把表头单击当成排序入口,两件事天然打架。这套实现的核心是一个继承 DataGridViewColumnHeaderCell 的 FilterHeaderCell 类,外加一个按列类型拼接 RowFilter 的工具方法,不依赖第三方控件,全部代码可以直接粘进 WinForms 工程。适合正在做报表、数据维护界面,又不想为了一个筛选功能换掉原生控件的朋友。

2. 自定义表头单元格:继承 DataGridViewColumnHeaderCell 是性价比最高的路

先别急着写事件,选型这一步很重要。网上搜“DataGridView 表头下拉筛选”,能看到的做法其实就三类:一是用一个 DataGridViewComboBoxColumn 塞在表头位置伪装下拉框,列宽一变、滚动一下,位置立刻错位,基本只能做 Demo;二是在 CellPainting 事件里画箭头,再在 ColumnHeaderMouseClick 里弹菜单,逻辑能跑,但列一多,全局变量和 if 判断全堆在 Form 里,后续维护的人看到那一段代码会想骂人;三是继承 DataGridViewColumnHeaderCell,让每个列头自己持有按钮矩形、筛选状态和筛选表达式,最后一列 new 一个自定义 HeaderCell 就行,Form 里几乎不用加事件代码。我最后选的就是第三种。

2.1 类骨架:按钮区域计算与鼠标命中检测

先给出这个类的骨架,下面每一节都会往里面补方法,最后是一个完整可编译的文件。

public class FilterHeaderCell : DataGridViewColumnHeaderCell { private const int BUTTON_WIDTH = 18; // 表头右侧预留的下拉按钮宽度 private Rectangle _buttonRect; // 最近一次 Paint 时计算的按钮区域 private bool _pressed; // 鼠标是否正处于按下状态 public bool IsFilterActive { get; private set; } public string FilterExpression { get; private set; } public string FilterDescription { get; private set; } public FilterHeaderCell() { } public FilterHeaderCell(string headerText) { Value = headerText; ToolTipText = "点击右侧箭头筛选"; } protected override void OnMouseDown(DataGridViewCellMouseEventArgs e) { if (e.Button == MouseButtons.Left && IsInButton(e.Location)) { _pressed = true; DataGridView.InvalidateCell(this); return; // 命中按钮区域时不再往下走,避免触发列的默认行为 } base.OnMouseDown(e); } protected override void OnMouseUp(DataGridViewCellMouseEventArgs e) { if (_pressed && IsInButton(e.Location)) { DataGridView.EndEdit(); PopupFilterList(); // 第 3 章实现,弹出唯一值菜单 } _pressed = false; DataGridView.InvalidateCell(this); base.OnMouseUp(e); } private bool IsInButton(Point location) { Rectangle cellRect = DataGridView.GetCellDisplayRectangle(ColumnIndex, RowIndex, false); return new Rectangle(cellRect.Right - BUTTON_WIDTH, cellRect.Top, BUTTON_WIDTH, cellRect.Height).Contains(location); } }

这里最需要注意的是_buttonRect不能放在构造函数里初始化,因为列头单元格加入 DataGridView 之后坐标才会变。每次 Paint 时把按钮矩形算出来存到字段里,IsInButton则直接用GetCellDisplayRectangle实时计算,这样第一次点击时也不会拿到空矩形。BUTTON_WIDTH取 18,是给右侧按钮留出的水平空间,文字区域实际只占cellBounds.Width - BUTTON_WIDTH,表头文字长了会用省略号截断,不会把三角按钮顶出边界。

OnMouseDown里命中按钮后直接 return,不调用 base 方法,这一步很关键。如果你打算保留原生“点击表头排序”的功能,事件会在这个位置漏下去,菜单刚弹出来数据就重新排了一遍。所以我的做法是:所有启用表头筛选的列,SortMode 一律设成 NotSortable,排序入口统一挪到筛选菜单里,后面第 5 章会说怎么加。

2.2 重写 Paint:画出下拉箭头和筛选激活状态

按钮能不能被用户看见,全靠 Paint 方法。下面是完整实现:

protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { // 先画背景、边框,前景(文字)自己画,这样才能控制文字不盖住按钮区域 base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts & ~DataGridViewPaintParts.ContentForeground); // 表头文字区域,右侧留出 18 像素给按钮 Rectangle textRect = new Rectangle( cellBounds.Left + 4, cellBounds.Top, cellBounds.Width - BUTTON_WIDTH - 6, cellBounds.Height); TextRenderer.DrawText(graphics, formattedValue?.ToString() ?? string.Empty, cellStyle.Font, textRect, cellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.EndEllipsis | TextFormatFlags.SingleLine); // 下拉按钮区域 _buttonRect = new Rectangle( cellBounds.Right - BUTTON_WIDTH, cellBounds.Top + 1, BUTTON_WIDTH - 1, cellBounds.Height - 2); if (IsFilterActive) { // 筛选生效时,按钮区域用红色边框提示用户这里不是装饰 using (Pen pen = new Pen(Color.Firebrick)) graphics.DrawRectangle(pen, _buttonRect); } // 画一个倒三角,视觉上表示“可下拉” Point[] arrow = { new Point(_buttonRect.Left + 4, _buttonRect.Top + _buttonRect.Height / 2 - 2), new Point(_buttonRect.Right - 5, _buttonRect.Top + _buttonRect.Height / 2 - 2), new Point(_buttonRect.Left + _buttonRect.Width / 2 - 1, _buttonRect.Top + _buttonRect.Height / 2 + 2) }; using (SolidBrush brush = new SolidBrush(IsFilterActive ? Color.Firebrick : Color.DimGray)) graphics.FillPolygon(brush, arrow); }

几个细节说明一下。第一,base.Paint 里我把 ContentForeground 位从 paintParts 中去掉了,因为列头默认的文本不能让它画到整个 cell 宽度,否则长标题会一直延伸到按钮底下,最后被按钮背景盖住,视觉上是文字突然断了一截。自己用 TextRenderer 在缩小的 textRect 里重绘,配合TextFormatFlags.EndEllipsis,长表头会显示成省略号,不会和按钮重叠。第二,筛选激活时我把三角箭头和边框都改成 Firebrick 红色,和 Excel 筛选中橙色漏斗大致一个作用,用户扫一眼就知道当前哪一列被过滤过。第三,Graphics.FillPolygon的三角坐标是我调过的,高度 3 像素、左右各留 4 像素,在 18 像素宽的小区域里不会糊成一团。这个尺寸在字体大小为 9 到 12 像素之间都能看清,不需要额外适配。

Paint 里还有一个容易被忽略的点:这里没有调用InvalidateCell之外的额外刷新。用户点击按钮后要记得手动触发重绘,也就是 OnMouseDown 里那行DataGridView.InvalidateCell(this),否则按钮按下去没有视觉反馈,体验很生硬。

3. 弹出下拉菜单并执行筛选:唯一值怎么取、RowFilter 怎么拼

表头按钮画出来只是第一步,真正决定这个功能能不能落地的是两件事:下拉菜单里的唯一值从哪里来,以及 RowFilter 表达式怎么按列类型拼。这两件事做不好,就会出现菜单里显示 “123”,筛选结果却是空的这种玄学问题。这一章把完整方法拆开讲。

3.1 用 ContextMenuStrip 承载唯一值列表

我给 FilterHeaderCell 补一个 PopupFilterList 方法,用 ContextMenuStrip 做下拉容器。为什么不自己画一个列表面板?因为 ContextMenuStrip 是 WinForms 自带的弹出式控件,失焦自动关闭、支持键盘方向键,还能通过 ItemClicked 拿到点击项,省掉一大堆手工处理。

private void PopupFilterList() { DataGridView grid = DataGridView; if (grid == null) return; DataTable table = GetSourceTable(); if (table == null) return; DataColumn dataColumn = ResolveDataColumn(table); if (dataColumn == null) return; ContextMenuStrip menu = new ContextMenuStrip(); menu.RenderMode = ToolStripRenderMode.System; ToolStripMenuItem allItem = new ToolStripMenuItem("(全部)") { Tag = null }; menu.Items.Add(allItem); List<object> values = table.AsEnumerable() .Select(r => r[dataColumn]) .Distinct() .OrderBy(v => v?.ToString()) .ToList(); if (values.Count > 0) { menu.Items.Add(new ToolStripSeparator()); foreach (object value in values) { string display = value == DBNull.Value ? "(空白)" : value.ToString(); menu.Items.Add(new ToolStripMenuItem(display) { Tag = value }); } } else { menu.Items.Add("当前列没有数据"); } menu.ItemClicked += (s, e) => { menu.Close(); ApplyFilter(dataColumn, e.ClickedItem.Tag); }; Rectangle r = grid.GetCellDisplayRectangle(ColumnIndex, -1, false); Point showPoint = grid.PointToScreen(new Point(r.Left, r.Bottom)); menu.Show(showPoint); } private DataTable GetSourceTable() { DataGridView grid = DataGridView; if (grid.DataSource is DataView view) return view.Table; if (grid.DataSource is BindingSource bs && bs.DataSource is DataTable bdt) return bdt; if (grid.DataSource is DataTable dt) return dt; return null; } private DataColumn ResolveDataColumn(DataTable table) { DataGridViewColumn gridColumn = DataGridView.Columns[ColumnIndex]; string propertyName = gridColumn.DataPropertyName; if (!string.IsNullOrEmpty(propertyName) && table.Columns.Contains(propertyName)) return table.Columns[propertyName]; return table.Columns[ColumnIndex]; }

这段代码里有三个容易被带偏的地方。第一个是GetSourceTable:DataSource 必须是 DataTable 或 DataView,不能用 List 。BindingSource.Filter 和 DataView.RowFilter 都只认 DataTable 体系,如果项目现在绑的是List<Order>,需要先转成 DataTable,常见做法是写个泛型 ToDataTable 反射扩展,或者直接改成 DataTable 绑定。第二个是取唯一值用Select(r => r[dataColumn]),不要用r.Field<object>强转,因为 DataTable 里的 DBNull.Value 在强转时会直接抛异常。第三个是ResolveDataColumn一定要优先走 DataPropertyName,DataGridView 列的物理顺序和数据表列的顺序经常不一致,直接按 ColumnIndex 去取 DataColumn,数据错位只是迟早的事。

菜单项的 Tag 里存的是原始值,不是显示文本。这样做的原因在第 3.2 节会体现——RowFilter 拼接需要区分字符串、数字、日期和布尔值,如果 Tag 里存的是 “123” 这种 string,后面再做类型判断就会丢掉真实类型。

3.2 RowFilter 按类型拼接:字符串、数字、日期、布尔和空白

这节是这个资源里最容易翻车的部分。DataView.RowFilter 的语法和 SQL WHERE 有点像,但细节不一样:字符串必须用单引号包,日期必须用 # 包,列名要加方括号,字符串里的单引号要翻倍转义。我见过不少实现是把所有值都拼成字符串,数字列能跑,日期列和布尔列全废。这里直接给通用方法:

private string BuildFilterExpression(DataColumn column, object rawValue) { string colName = "[" + column.ColumnName + "]"; if (rawValue == null || rawValue == DBNull.Value) return colName + " IS NULL"; if (column.DataType == typeof(string)) { string s = rawValue.ToString().Replace("'", "''"); return colName + " = '" + s + "'"; } if (column.DataType == typeof(DateTime)) { DateTime d = Convert.ToDateTime(rawValue); return colName + " = #" + d.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + "#"; } if (column.DataType == typeof(bool)) { bool b = Convert.ToBoolean(rawValue); return colName + " = " + (b ? "true" : "false"); } // int、decimal、double 等数值类型统一走这里,不带引号 return colName + " = " + Convert.ToString(rawValue, CultureInfo.InvariantCulture); }

逻辑看着简单,但每条分支都有实际踩过的坑。字符串列转义在中文场景下容易被忽略:用户数据里出现 “O'Brien” 这种带单引号的英文名,不 Replace 的话 RowFilter 直接抛Syntax error,整列筛选就废了。所以Replace("'", "''")不是防御性代码,是必须项。日期列写成#yyyy-MM-dd HH:mm:ss#是 RowFilter 对 DateTime 列的标准写法,格式里不能省略时分秒,否则边界值会被当成 00:00:00 处理。

布尔列最容易踩坑:有人把 true 转成 "1" 拼进去,DataView 在某些区域设置或版本下会把 1 当成整数去和 bit 列比较,结果是空集合。写成true/false是最保守的。数值列用CultureInfo.InvariantCulture转字符串,是防止小数在德语等区域设置下变成逗号1,5,RowFilter 会把整个表达式解析成两个值,直接报语法错误。你这个程序可能只在中文 Windows 上运行,但只要有人把系统区域格式改成英文或者德语,这个坑立刻显现。

3.3 多列组合筛选:一个静态方法合并所有生效条件

单列筛选做完后,下一步自然就是多列同时筛选。FilterHeaderCell 必须能把自己这列的表达式交出去,由某个统一的入口合并成一条 RowFilter。我给这个类加一个公开的 ApplyFilter 方法,同时提供一个静态的 CombineFilters:

public void ApplyFilter(DataColumn column, object rawValue) { string newFilter = null; if (rawValue == null) { // 点“(全部)”,清除当前列筛选 newFilter = null; } else if (rawValue == DBNull.Value) { newFilter = "[" + column.ColumnName + "] IS NULL"; } else { newFilter = BuildFilterExpression(column, rawValue); } FilterExpression = newFilter; IsFilterActive = !string.IsNullOrEmpty(newFilter); FilterDescription = rawValue == DBNull.Value ? "(空白)" : rawValue?.ToString(); DataGridView grid = DataGridView; DataView view = GetSourceTable()?.DefaultView; if (view != null) view.RowFilter = CombineFilters(grid); grid.InvalidateColumn(ColumnIndex); } public static string CombineFilters(DataGridView grid) { List<string> parts = new List<string>(); foreach (DataGridViewColumn column in grid.Columns) { if (column.HeaderCell is FilterHeaderCell cell && !string.IsNullOrEmpty(cell.FilterExpression)) { parts.Add(cell.FilterExpression); } } return parts.Count == 0 ? string.Empty : string.Join(" AND ", parts); }

注意 ApplyFilter 里我区分了 null 和 DBNull.Value 两种情况:Tag 为 null 表示“(全部)”,这时清除当前列的 FilterExpression;Tag 为 DBNull.Value 表示“(空白)”,要生成IS NULL而不是清空筛选。这个细节不分开处理,用户选了“(空白)”后表格会什么都不筛,或者反过来把整列清空了,属于隐蔽 bug。

CombineFilters 把所有列的生效表达式用 AND 连接。这里有个约定:同一列筛选一定是单选,因为菜单项是唯一值枚举,不会出现同一列两个条件同时成立的情况。如果你以后要做成多选,这个合并逻辑就得改成按列分组后用 OR 连接,再在列之间用 AND,复杂度会上去,但当前方案已经覆盖 90% 的表头筛选场景。

第 3 章到这里,主链路已经走通了:点按钮 → 弹菜单 → 选值 → 拼表达式 → 写 RowFilter → 重绘表头。接下来是真正决定这个资源好不好用的部分——排错。

4. 避坑与排查:表头下拉筛选的五个真实翻车现场

再完善的代码,不注意环境细节也会莫名其妙失效。下面五条是从实际使用中攒下来的,每条都按现象、原因、解决三步写,遇到同类问题可以直接对号入座。

4.1 现象:菜单刚弹出来,整列数据就按表头排序了

第一次把 FilterHeaderCell 挂到列上时,点下拉按钮会出现一个很诡异的操作:菜单打开的同时,列头的排序箭头出现,数据顺序立刻变了,菜单里的值列表跟着乱跳。原因在于 DataGridView 对表头鼠标单击有一套默认处理,命中按钮后就算不调用 base 方法,内部消息循环也可能会触发排序逻辑。解决方法是彻底放弃这列的原生点击排序,把 SortMode 设为 NotSortable:

dataGridView1.Columns["OrderId"].HeaderCell = new FilterHeaderCell("订单号"); dataGridView1.Columns["OrderId"].SortMode = DataGridViewColumnSortMode.NotSortable;

如果列是动态生成的,就在 DataBindingComplete 里统一处理,把所有 FilterHeaderCell 对应的列 SortMode 都改掉。排序入口移到右键菜单或者筛选菜单底部,第 5 章会给具体写法。

4.2 现象:字符串筛选永远查不到数据

菜单里明明显示有“华东”,选完表格却空了。排查后发现 RowFilter 表达式拼成了[区域] = "华东",双引号。DataView.RowFilter 对字符串常量只认单引号,双引号会被解析成列名,于是报错,但由于异常被吞掉,界面看起来就是“结果为空”。解决方法是把列类型分支写对,字符串列统一走'值',并且内部单引号要翻倍。如果你在调试时看到 RowFilter 抛Syntax error,第一反应就去看表达式里的引号,这是我踩过最多次的坑。

4.3 现象:重新绑定数据源后,表头还亮着,但筛选已经失效

这个场景很常见:用户筛选完,程序后台刷新数据,DataGridView.DataSource 被重新赋值,此时 IsFilterActive 还是 true,表头红色高亮,RowFilter 却已经被新 DataView 清掉了。原因是重新绑定后列头单元格保留了旧状态,而新的 DataView 根本没拿到筛选条件。解决方法是建立一个统一的刷新入口:

public static void ResetAllFilters(DataGridView grid) { foreach (DataGridViewColumn column in grid.Columns) { if (column.HeaderCell is FilterHeaderCell cell) { cell.FilterExpression = null; cell.IsFilterActive = false; cell.FilterDescription = null; grid.InvalidateColumn(column.Index); } } if (grid.DataSource is BindingSource bs && bs.DataSource is DataTable dt) dt.DefaultView.RowFilter = string.Empty; else if (grid.DataSource is DataView view) view.RowFilter = string.Empty; }

在重新赋值 DataSource 之后、界面刷新之前调用一次,就不会出现“表头高亮但数据没过滤”的假象。

4.4 现象:列名带空格或关键字,RowFilter 直接抛异常

数据库里列名起得随意,比如 “Unit Price” 或者 “Order”,拼出来的表达式是Unit Price = '1',RowFilter 解析到空格就断了。原因很简单:DataRowFilter 的列名必须用方括号括起来,即使没有空格也应该括,这是从 OLE DB 语法继承下来的习惯。解决方法是所有列名统一走[列名]格式,我上面 BuildFilterExpression 里第一行string colName = "[" + column.ColumnName + "]";就是为了堵这个坑。别偷懒只在发现异常时加,否则换一张表就踩一次。

4.5 现象:布尔列选 True 后,明明有数据却筛不出来

菜单里显示 True/False,选了 True,表格空。这个在 DataTable 的 bool 列(数据库里通常是 bit)很常见。原因一是表达式写成了[IsEnabled] = 'True',带了引号,RowFilter 把它当字符串和 bool 比较,类型不匹配;原因二是有人把它转成1去和 bit 列比较,某些区域设置下会失败。解决方法是严格按 DataType 分支,bool 列填true或false不加引号。这行代码我写进去时还觉得多余,后来真派上了用场——用户从 SQL Server 导入的 bit 列,99% 都是用布尔菜单筛选。

5. 进阶玩法:把排序入口并进筛选菜单,并适配动态复杂表头

核心功能做完后,别忘了用户还有一个原始诉求:点击表头排序。既然第 4 章把 SortMode 设成了 NotSortable,排序能力不能丢,把它收进筛选菜单里反而更好用。在 PopupFilterList 的菜单构造函数里,往最前面插入两行:

ToolStripMenuItem ascItem = new ToolStripMenuItem("升序") { Tag = SortOrder.Ascending }; ToolStripMenuItem descItem = new ToolStripMenuItem("降序") { Tag = SortOrder.Descending }; menu.Items.Insert(0, ascItem); menu.Items.Insert(1, descItem); menu.Items.Insert(2, new ToolStripSeparator());

然后在 ItemClicked 事件里判断 Tag 类型:

menu.ItemClicked += (s, e) => { menu.Close(); if (e.ClickedItem.Tag is SortOrder) { DataGridView grid = DataGridView; ListSortDirection dir = (SortOrder)e.ClickedItem.Tag == SortOrder.Ascending ? ListSortDirection.Ascending : ListSortDirection.Descending; DataGridViewColumn column = grid.Columns[ColumnIndex]; grid.Sort(column, dir); grid.ClearSelection(); } else { ApplyFilter(dataColumn, e.ClickedItem.Tag); } };

这里用grid.Sort(column, dir)而不是DataView.Sort = "列名 ASC",主要原因是 DataGridView.Sort 走的是控件的排序逻辑,会同步更新列头的排序箭头,而且不会和当前 RowFilter 冲突。DataView.Sort 一旦设置,行顺序被 DataView 接管,下次 RowFilter 条件变更时排列顺序可能被重置,体验不稳定。

动态复杂表头是这个功能的终极形态。如果列不是设计期写死的,而是运行时根据配置生成,每个新列都得重新挂 HeaderCell。我一般会在 DataBindingComplete 里统一处理,顺便加一个 ToolTip 提示:

private void grid_DataBindingComplete(object sender, DataGridViewBindingCompleteEventArgs e) { foreach (DataGridViewColumn column in dataGridView1.Columns) { if (column.HeaderCell is FilterHeaderCell == false) column.HeaderCell = new FilterHeaderCell(column.HeaderText); column.SortMode = DataGridViewColumnSortMode.NotSortable; column.ToolTipText = "点击右侧箭头筛选,点击标题排序在菜单里"; } }

这个入口有个天然的坑:每次 DataSource 重新赋值都会触发一次 DataBindingComplete,旧的 FilterHeaderCell 里可能还残留上一次的 FilterExpression。所以在这个方法里要顺带调用第 4.3 节的 ResetAllFilters,保证重建列头时筛选状态一起清空。从那以后我每次做 WinForms 表格界面,只要是 DataGridView 挂 DataTable,表头筛选和排序入口都强制走同一套流程:所有列 SortMode 先设 NotSortable,统一用 FilterHeaderCell 接管绘制和事件,最后在 DataBindingComplete 里做一次复位。这套代码我在三个项目里复用过了,第一次调试花了两个晚上,后面每次接入新表只要半小时。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表