简介:本资源是一个面向WPF开发者的技术实践项目,聚焦于使用Telerik UI for WPF控件库实现DataGrid多行表头这一典型界面需求,适用于中初级WPF开发人员学习高级表格布局与数据绑定技巧。资源包共53个文件,包含13个C#逻辑文件、2个XAML界面定义、8个DLL依赖库、8个XML配置与资源文件,以及sln/csproj工程文件等,完整呈现一个可直接运行的TelerikWpfApp1示例工程,压缩包大小为3.34MB。目前已有292人学习下载,反映出该场景在实际报表类应用开发中的高频需求。读者可直接导入VS运行调试,深入理解RadGridView中HeaderRowTemplate配置、嵌套列组定义、HierarchicalCollectionView数据分组绑定等核心实现,并通过源码结构(如Vehicle.cs数据模型、MainWindow.xaml视图、App.xaml资源字典)掌握Telerik DataGrid多级表头的工程化组织方式。
1. WPF 多行表头不是“加个换行符”就能搞定:Telerik RadGridView 实现复杂表头合并的真实路径
你试过在 WPF DataGrid 里做「销售部:2024 Q1」这种两行嵌套表头吗?别急着改HeaderTemplate——原生 DataGrid 的列头渲染是单层扁平结构,强行塞<LineBreak>只会让文字挤成一团、对齐错乱、导出 Excel 时彻底崩坏。这个 Telerik RadGridView 多行表头 Demo 就是专治这类“伪多行”玄学的实战样本:它用GridViewColumnGroup+ColumnGroups机制,在不碰任何自定义模板的前提下,把物理列按逻辑组折叠,让「区域」「城市」「销售额(万元)」「同比(%)」四列自动归入「华东区:2024 Q1」和「华北区:2024 Q1」两个顶层分组,且支持分组内列宽独立拖拽、分组头冻结、导出时保留层级结构。适合正在用 Telerik 做企业级报表系统、又卡在表头语义化表达上的 WPF 开发者——不是教你怎么写 XAML,而是告诉你为什么必须用ColumnGroups而不是HeaderTemplate,以及 GroupHeader 模板里那行VerticalContentAlignment="Center"不加就会导致文字悬浮半空的血泪经验。
2. 为什么非得用 ColumnGroups:RadGridView 表头分层的底层逻辑与选型依据
2.1 原生 DataGrid 的表头模型缺陷:单层 Header 属性无法承载层级语义
WPF 原生DataGrid的每一列只暴露一个Header属性,类型为object。常见做法是传入TextBlock并设置TextWrapping="Wrap",看似能换行,但本质仍是单个 UIElement 渲染。问题在于:
- 布局失控:
DataGrid内部用Grid布局表头行,所有列头共享同一行高,无法为不同分组设置独立高度; - 交互断裂:点击分组头无法触发分组折叠/展开(原生无此概念),也无法响应分组级右键菜单;
- 导出失效:
DataGrid导出到 Excel 时仅读取Header.ToString(),换行符\n被转义为普通字符,Excel 单元格内不换行。
提示:有人尝试用
HeaderTemplate套StackPanel堆叠两个TextBlock,这会导致列头宽度计算异常——StackPanel不参与 Measure 过程,实际宽度由最宽子元素决定,拖拽列宽时会突然跳变。
2.2 Telerik RadGridView 的 ColumnGroups 架构:物理列与逻辑组的解耦设计
RadGridView 的解决方案是引入ColumnGroups集合,将表头划分为三层结构:
- 顶层 GroupHeader:显示「华东区:2024 Q1」这类跨列标题,绑定
GridViewColumnGroup.Header; - 中层 ColumnGroup:容器对象,管理所属列及分组样式,通过
GridViewColumnGroup.Columns关联物理列; - 底层 GridViewDataColumn:真实数据列,
Header属性仅显示末级字段名(如「销售额(万元)」)。
这种设计使分组头成为独立的 UI 元素,拥有自己的Width、IsFrozen、Visibility属性,且导出时GridViewExportOptions.ExportColumnGroups = true可自动将 GroupHeader 渲染为合并单元格。
2.3 本 Demo 的核心结构:4 列 × 2 分组 × 1 总标题的完整映射
Demo 中数据源为ObservableCollection<SalesRecord>,含Region、City、SalesAmount、YoYChange四字段。XAML 中的分组声明如下:
<telerik:RadGridView x:Name="radGridView" AutoGenerateColumns="False"> <telerik:RadGridView.ColumnGroups> <!-- 总标题组:覆盖全部4列 --> <telerik:GridViewColumnGroup Header="2024年第一季度销售数据" /> <!-- 华东区分组:包含前2列 --> <telerik:GridViewColumnGroup Header="华东区:2024 Q1" Name="EastGroup"> <telerik:GridViewColumnGroup.Columns> <telerik:GridViewDataColumn DataMemberBinding="{Binding Region}" Header="区域" /> <telerik:GridViewDataColumn DataMemberBinding="{Binding City}" Header="城市" /> </telerik:GridViewColumnGroup.Columns> </telerik:GridViewColumnGroup> <!-- 华北区分组:包含后2列 --> <telerik:GridViewColumnGroup Header="华北区:2024 Q1" Name="NorthGroup"> <telerik:GridViewColumnGroup.Columns> <telerik:GridViewDataColumn DataMemberBinding="{Binding SalesAmount}" Header="销售额(万元)" /> <telerik:GridViewDataColumn DataMemberBinding="{Binding YoYChange}" Header="同比(%)" /> </telerik:GridViewColumnGroup.Columns> </telerik:GridViewColumnGroup> </telerik:RadGridView.ColumnGroups> </telerik:RadGridView>关键点说明:
ColumnGroups集合中GridViewColumnGroup的声明顺序即为表头从左到右的排列顺序;- 每个
GridViewColumnGroup的Columns子集合必须显式指定所含物理列,不可遗漏或重复; Name属性用于代码后台引用(如动态隐藏分组),非必需但强烈建议添加。
3. 从零搭建可运行 Demo:XAML 声明、数据绑定与分组控制三步落地
3.1 创建基础项目并引用 Telerik 控件库
确保项目已安装Telerik.UI.for.WPFNuGet 包(版本 ≥ 2022.1.119)。在App.xaml中添加程序集资源:
<Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="/Telerik.Windows.Themes.Fluent;component/Themes/System.Windows.xaml"/> <ResourceDictionary Source="/Telerik.Windows.Themes.Fluent;component/Themes/Telerik.Windows.Controls.xaml"/> <ResourceDictionary Source="/Telerik.Windows.Themes.Fluent;component/Themes/Telerik.Windows.Controls.GridView.xaml"/> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources>注意:主题资源路径必须与安装的 Telerik 版本严格匹配。若使用
VisualStudio2019主题,需替换为对应路径,否则分组头背景色显示为灰色块。
3.2 定义数据模型与模拟数据源
创建SalesRecord.cs:
public class SalesRecord : INotifyPropertyChanged { private string _region; private string _city; private double _salesAmount; private double _yoYChange; public string Region { get => _region; set { _region = value; OnPropertyChanged(); } } public string City { get => _city; set { _city = value; OnPropertyChanged(); } } public double SalesAmount { get => _salesAmount; set { _salesAmount = value; OnPropertyChanged(); } } public double YoYChange { get => _yoYChange; set { _yoYChange = value; OnPropertyChanged(); } } public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }在MainWindow.xaml.cs中初始化数据:
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); var data = new ObservableCollection<SalesRecord> { new SalesRecord { Region = "华东", City = "上海", SalesAmount = 1250.5, YoYChange = 8.2 }, new SalesRecord { Region = "华东", City = "杭州", SalesAmount = 980.3, YoYChange = -1.5 }, new SalesRecord { Region = "华北", City = "北京", SalesAmount = 1620.7, YoYChange = 12.4 }, new SalesRecord { Region = "华北", City = "天津", SalesAmount = 750.0, YoYChange = 3.8 } }; radGridView.ItemsSource = data; } }3.3 启用分组头交互:折叠/展开与动态显示控制
RadGridView 默认不启用分组头折叠功能,需手动绑定IsExpanded属性。在 XAML 中为每个GridViewColumnGroup添加:
<telerik:GridViewColumnGroup Header="华东区:2024 Q1" Name="EastGroup" IsExpanded="True"> <!-- Columns --> </telerik:GridViewColumnGroup>若需代码后台动态控制,例如点击按钮隐藏华东区:
private void HideEastGroup_Click(object sender, RoutedEventArgs e) { var eastGroup = radGridView.ColumnGroups.FirstOrDefault(g => g.Name == "EastGroup"); if (eastGroup != null) { eastGroup.IsExpanded = false; // 触发重绘 radGridView.Rebind(); } }逻辑说明:
Rebind()强制刷新整个 Grid,避免仅调用InvalidateVisual()导致分组头状态未同步。这是 Telerik 2022+ 版本的已知行为,旧版本可用Refresh()替代。
4. 避坑指南:多行表头开发中 4 个高频翻车点与硬核解法
4.1 现象:分组头文字垂直居中失效,始终贴顶显示
原因:GridViewColumnGroup的默认HeaderTemplate中TextBlock未设置VerticalAlignment,且ColumnGroup自身无VerticalContentAlignment属性。
解决:重写HeaderTemplate,显式设置VerticalAlignment="Center":
<telerik:GridViewColumnGroup Header="华东区:2024 Q1"> <telerik:GridViewColumnGroup.HeaderTemplate> <DataTemplate> <TextBlock Text="{Binding}" VerticalAlignment="Center" HorizontalAlignment="Center" TextWrapping="Wrap" Margin="0,2,0,2"/> </DataTemplate> </telerik:GridViewColumnGroup.HeaderTemplate> <!-- Columns --> </telerik:GridViewColumnGroup>注意:
Margin="0,2,0,2"是为避免文字紧贴分组头上下边界,2px 上下边距是经验值,适配 Fluent 主题字体行高。
4.2 现象:拖拽列宽时,分组头宽度不随子列同步变化,出现空白裂隙
原因:GridViewColumnGroup的Width属性默认为Auto,不响应子列宽度变更;且ColumnGroups集合不监听子列Width属性变化。
解决:禁用分组头手动拖拽,强制其宽度由子列总和驱动。在 XAML 中设置:
<telerik:GridViewColumnGroup Header="华东区:2024 Q1" CanUserResize="False"> <!-- Columns --> </telerik:GridViewColumnGroup>并在代码中监听子列宽度变化,动态更新分组头宽度(需引用Telerik.Windows.Controls.GridView命名空间):
private void SubscribeToColumnWidthChanges() { foreach (var column in radGridView.Columns) { column.WidthChanged += (s, e) => { // 重新计算所属分组的总宽度 var group = radGridView.ColumnGroups.FirstOrDefault(g => g.Columns.Contains(column)); if (group != null) { group.Width = new GridViewLength(group.Columns.Sum(c => c.ActualWidth)); } }; } }4.3 现象:导出 Excel 后,分组头变成普通文本,未合并单元格
原因:RadGridView导出功能默认关闭ColumnGroups支持,且GridViewExportOptions需显式配置。
解决:导出前设置ExportColumnGroups = true,并确保ExportFormat为ExcelML或Xlsx:
private void ExportToExcel_Click(object sender, RoutedEventArgs e) { var exportOptions = new GridViewExportOptions { ExportColumnGroups = true, Format = ExportFormat.Xlsx, ShowColumnHeaders = true }; radGridView.Export("SalesReport.xlsx", exportOptions); }提示:
ExcelML格式兼容性更好,但文件体积大;Xlsx需引用Telerik.Windows.Documents.Spreadsheet程序集。
4.4 现象:启用FrozenColumnCount后,分组头冻结错位,部分分组头被截断
原因:FrozenColumnCount仅冻结物理列,而ColumnGroups是逻辑层,其冻结行为需单独配置IsFrozen属性。
解决:为需要冻结的分组头显式设置IsFrozen="True":
<telerik:GridViewColumnGroup Header="2024年第一季度销售数据" IsFrozen="True" /> <telerik:GridViewColumnGroup Header="华东区:2024 Q1" IsFrozen="True" Name="EastGroup"> <!-- Columns --> </telerik:GridViewColumnGroup>此时冻结效果为:总标题组 + 华东区分组头固定在左侧,右侧滚动时保持可见。
5. 进阶技巧:动态生成 ColumnGroups 与跨分组排序的底层控制
5.1 根据运行时数据结构自动构建 ColumnGroups:反射 + LINQ 实战
当业务要求「按用户选择的维度动态生成表头分组」(如切换「按区域」、「按产品线」、「按渠道」),硬编码 XAML 显然不可行。此时需在代码中动态构建ColumnGroups:
private void BuildColumnGroupsByDimension(string dimension) { // 清空现有分组 radGridView.ColumnGroups.Clear(); // 模拟维度配置:key=分组名,value=字段列表 var dimensionConfig = new Dictionary<string, List<string>> { ["Region"] = new List<string> { "Region", "City" }, ["Product"] = new List<string> { "ProductName", "Category" }, ["Channel"] = new List<string> { "ChannelName", "SubChannel" } }; if (!dimensionConfig.TryGetValue(dimension, out var fields)) return; // 创建总标题组 var totalGroup = new GridViewColumnGroup { Header = $"按{dimension}维度汇总" }; radGridView.ColumnGroups.Add(totalGroup); // 为每个字段创建分组(此处简化为每字段一个分组,实际可按业务规则聚合) for (int i = 0; i < fields.Count; i++) { var field = fields[i]; var columnGroup = new GridViewColumnGroup { Header = GetDisplayName(field), // 从属性特性获取中文名 Name = $"DynamicGroup_{i}" }; // 绑定对应字段的列 var gridColumn = new GridViewDataColumn { DataMemberBinding = new Binding(field), Header = GetDisplayName(field) }; columnGroup.Columns.Add(gridColumn); radGridView.ColumnGroups.Add(columnGroup); } } private string GetDisplayName(string propertyName) { var prop = typeof(SalesRecord).GetProperty(propertyName); var attr = prop?.GetCustomAttribute<DisplayAttribute>(); return attr?.GetName() ?? propertyName; }该方法将分组逻辑从 XAML 移至 C#,便于与 ViewModel 解耦,也方便单元测试验证分组结构。
5.2 跨分组排序:当点击「销售额(万元)」列头时,如何让「华东区」和「华北区」数据分别排序?
RadGridView 默认排序作用于整个ItemsSource,无法感知分组边界。要实现「分组内独立排序」,需拦截Sorting事件并重写排序逻辑:
private void radGridView_Sorting(object sender, GridViewSortingEventArgs e) { // 取消默认排序 e.Cancel = true; // 获取被点击列所属的分组 var clickedColumn = e.Column as GridViewDataColumn; var parentGroup = radGridView.ColumnGroups.FirstOrDefault(g => g.Columns.Contains(clickedColumn)); if (parentGroup == null) return; // 按分组提取数据子集(此处假设数据源为 ObservableCollection<SalesRecord>) var source = radGridView.ItemsSource as IEnumerable<SalesRecord>; if (source == null) return; // 按分组字段(如 Region)分组,并对每组内数据按点击列排序 var groupedData = source.GroupBy(x => x.Region); // 此处 Region 即分组依据字段 var sortedList = new List<SalesRecord>(); foreach (var group in groupedData) { var sortedGroup = group.OrderBy(x => { // 根据点击列动态取值 var prop = typeof(SalesRecord).GetProperty(clickedColumn.DataMemberBinding.Path.Path); return prop?.GetValue(x) ?? null; }).ToList(); sortedList.AddRange(sortedGroup); } // 更新 ItemsSource(注意:需保证 ObservableCollection 通知机制正常) var observableSource = radGridView.ItemsSource as ObservableCollection<SalesRecord>; if (observableSource != null) { observableSource.Clear(); foreach (var item in sortedList) observableSource.Add(item); } }注意:此方案要求数据源中存在明确的分组依据字段(如
Region),且该字段值能唯一标识分组。若分组依据为复合条件(如Region + Year),需调整GroupBy表达式。
5.3 分组头样式深度定制:用 ControlTemplate 替换默认渲染器
当HeaderTemplate无法满足设计需求(如添加图标、状态徽标、下拉箭头),需重写GridViewColumnGroup的ControlTemplate。以下为带「展开/折叠」图标的模板:
<Style TargetType="telerik:GridViewColumnGroup"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="telerik:GridViewColumnGroup"> <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <!-- 折叠图标 --> <ToggleButton Grid.Column="0" IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" Width="16" Height="16" Margin="4,0,4,0"> <ToggleButton.Template> <ControlTemplate> <Path Data="M0,0 L4,4 L0,8 Z" Fill="Gray" RenderTransformOrigin="0.5,0.5"> <Path.RenderTransform> <RotateTransform Angle="{Binding IsChecked, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource BoolToAngleConverter}}"/> </Path.RenderTransform> </Path> </ControlTemplate> </ToggleButton.Template> </ToggleButton> <!-- 分组头文字 --> <ContentPresenter Grid.Column="1" Content="{TemplateBinding Header}" VerticalAlignment="Center" HorizontalAlignment="Center" TextBlock.TextWrapping="Wrap"/> <!-- 右侧占位 --> <Rectangle Grid.Column="2" Width="16"/> </Grid> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>配套的BoolToAngleConverter:
public class BoolToAngleConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { return (bool)value ? 90.0 : 0.0; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } }从那以后我每次做 Telerik 表头分组,都会先检查三点:ColumnGroups是否按物理列顺序声明、IsFrozen是否为需要固定的分组显式开启、导出前是否设置了ExportColumnGroups = true。这三个检查项覆盖了 90% 的交付现场翻车场景——尤其是导出环节,客户永远在验收当天才第一次打开 Excel 文件。希望帮到你。
本文还有配套的精品资源,点击获取