资讯动态

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

发布时间:2026/10/8 9:43:51 来源:尧图企业网站定制
简介面向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); Listobject 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]; }这段代码里有三个容易被带偏的地方。第一个是GetSourceTableDataSource 必须是 DataTable 或 DataView不能用 List 。BindingSource.Filter 和 DataView.RowFilter 都只认 DataTable 体系如果项目现在绑的是ListOrder需要先转成 DataTable常见做法是写个泛型 ToDataTable 反射扩展或者直接改成 DataTable 绑定。第二个是取唯一值用Select(r r[dataColumn])不要用r.Fieldobject强转因为 DataTable 里的 DBNull.Value 在强转时会直接抛异常。第三个是ResolveDataColumn一定要优先走 DataPropertyNameDataGridView 列的物理顺序和数据表列的顺序经常不一致直接按 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); }逻辑看着简单但每条分支都有实际踩过的坑。字符串列转义在中文场景下容易被忽略用户数据里出现 “OBrien” 这种带单引号的英文名不 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,5RowFilter 会把整个表达式解析成两个值直接报语法错误。你这个程序可能只在中文 Windows 上运行但只要有人把系统区域格式改成英文或者德语这个坑立刻显现。3.3 多列组合筛选一个静态方法合并所有生效条件单列筛选做完后下一步自然就是多列同时筛选。FilterHeaderCell 必须能把自己这列的表达式交出去由某个统一的入口合并成一条 RowFilter。我给这个类加一个公开的 ApplyFilter 方法同时提供一个静态的 CombineFilterspublic 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) { Liststring parts new Liststring(); 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 表示“全部”这时清除当前列的 FilterExpressionTag 为 DBNull.Value 表示“空白”要生成IS NULL而不是清空筛选。这个细节不分开处理用户选了“空白”后表格会什么都不筛或者反过来把整列清空了属于隐蔽 bug。CombineFilters 把所有列的生效表达式用 AND 连接。这里有个约定同一列筛选一定是单选因为菜单项是唯一值枚举不会出现同一列两个条件同时成立的情况。如果你以后要做成多选这个合并逻辑就得改成按列分组后用 OR 连接再在列之间用 AND复杂度会上去但当前方案已经覆盖 90% 的表头筛选场景。第 3 章到这里主链路已经走通了点按钮 → 弹菜单 → 选值 → 拼表达式 → 写 RowFilter → 重绘表头。接下来是真正决定这个资源好不好用的部分——排错。4. 避坑与排查表头下拉筛选的五个真实翻车现场再完善的代码不注意环境细节也会莫名其妙失效。下面五条是从实际使用中攒下来的每条都按现象、原因、解决三步写遇到同类问题可以直接对号入座。4.1 现象菜单刚弹出来整列数据就按表头排序了第一次把 FilterHeaderCell 挂到列上时点下拉按钮会出现一个很诡异的操作菜单打开的同时列头的排序箭头出现数据顺序立刻变了菜单里的值列表跟着乱跳。原因在于 DataGridView 对表头鼠标单击有一套默认处理命中按钮后就算不调用 base 方法内部消息循环也可能会触发排序逻辑。解决方法是彻底放弃这列的原生点击排序把 SortMode 设为 NotSortabledataGridView1.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 1RowFilter 解析到空格就断了。原因很简单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 里做一次复位。这套代码我在三个项目里复用过了第一次调试花了两个晚上后面每次接入新表只要半小时。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑