简介:面向WPF初、中级开发者的表格控件列样式定制示例工程,解决默认列无法在一个单元格内展示多字段或做特殊排版的问题。项目借助WPF模板列与数据模板机制,分别设置非编辑状态和编辑状态下的单元格内容模板;在模板中既可以用多个文本块并列显示不同属性,也可以混入复选框、按钮等交互元素,再配合横向排列、网格定位等布局方式,让单列内呈现出丰富而规整的内容。压缩包共15个文件,大小仅13KB,主体为C#源码与XAML界面文件,另有解决方案与工程文件、配置文件及资源文件,结构完整,便于直接打开调试和比对学习。已有800人学习。内含可运行的示例工程,覆盖数据模型、视图模型与自定义列头模型,并按MVVM模式组织代码,读者可对照界面标记和后端绑定逻辑,理解从数据源到单元格呈现的完整链路,快速迁移到自己的业务表格中。
1. WPF项目里的DataGrid,为什么默认列样式总是不够用
做过一个进销存系统的桌面端,订单列表里要求一列同时展示状态圆点、金额格式、操作按钮和审核进度。默认的DataGridTextColumn只能绑定字符串,状态显示成文字,按钮根本没地方放,金额也不支持右对齐带货币符号。折腾了几天,最后完全基于WPF原生控件,用DataGridTemplateColumn、DataTemplateSelector和样式触发器搭出了一套自定义复杂列方案。这套方法不依赖第三方表格控件,能处理动态列、同列多态、行内命令和滚动性能。这篇笔记就把WPF中自定义复杂DataGrid列样式的完整思路、源码结构和二次封装方式拆开讲,适合正在跟DataGrid较劲的WPF开发者,也适合想深入理解模板列绑定机制的人。
2. 架构设计:一张复杂表从数据模型到单元格的绑定链路
2.1 数据模型:INotifyPropertyChanged是双向绑定的地基
DataGrid单元格里的内容要跟着业务数据实时刷新,数据类必须先实现INotifyPropertyChanged。这不是可选项,模板里用{Binding OrderStatus},本质是让DataGrid订阅这个事件。如果数据类不通知,界面上改了状态,ViewModel里的值变了,但单元格显示纹丝不动。
我习惯先写一个基类,把SetProperty封装好,所有行实体继承它。这样每个属性都走统一的赋值入口,不怕忘记触发通知。
public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string name = null) { if (EqualityComparer<T>.Default.Equals(field, value)) { return false; } field = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); return true; } }逻辑说明:SetProperty先用EqualityComparer判断新旧值是否相同,相同就不触发事件,避免无意义的刷新。CallerMemberName会在调用处自动把属性名传进来,手写字符串容易写错,而且属性重命名时也不会漏。
public class OrderItem : ViewModelBase { private string _orderStatus; public string OrderStatus { get => _orderStatus; set => SetProperty(ref _orderStatus, value); } private decimal _amount; public decimal Amount { get => _amount; set => SetProperty(ref _amount, value); } }参数说明:OrderStatus和Amount就是列绑定要用的属性。这里用string表达状态,正式项目建议用枚举,但XAML里DataTrigger比较枚举值更麻烦,所以进销存这个项目里直接用了字符串,模板里Value="已支付"能精确匹配。
2.2 视图模型与列配置:把列定义变成可观测集合
自定义复杂列之后,列的增删不该写死在XAML里。我的做法是让ViewModel暴露一个ObservableCollection ,把表头、宽度、可见性、模板Key都作为描述数据。业务层可以动态调整列,视图层再把这个描述集合转换成真正的DataGridColumn加入DataGrid.Columns。
public class ColumnDescription { public string Header { get; set; } public string BindingPath { get; set; } public double Width { get; set; } = 200; public bool Visible { get; set; } = true; public string TemplateKey { get; set; } } public class MainViewModel : ViewModelBase { public ObservableCollection<OrderItem> Orders { get; set; } public ObservableCollection<ColumnDescription> ColumnDefs { get; set; } public MainViewModel() { Orders = new ObservableCollection<OrderItem>(); ColumnDefs = new ObservableCollection<ColumnDescription> { new ColumnDescription { Header = "订单号", BindingPath = "OrderNo", Width = 150, TemplateKey = "Text" }, new ColumnDescription { Header = "状态", BindingPath = "OrderStatus", Width = 180, TemplateKey = "Status" }, new ColumnDescription { Header = "操作", BindingPath = "", Width = 120, TemplateKey = "Action" } }; } }逻辑说明:BindingPath留给普通文本列用,模板Key决定使用哪个DataTemplate。状态列用Status模板,操作列用Action模板。转换成真正的列这一步,通常在窗口Loaded事件里做。
private void BuildColumnsFromDefs() { dataGrid.Columns.Clear(); foreach (var def in ViewModel.ColumnDefs) { if (!def.Visible) continue; var column = new DataGridTemplateColumn { Header = def.Header, Width = new DataGridLength(def.Width) }; if (string.IsNullOrEmpty(def.TemplateKey)) { var factory = new FrameworkElementFactory(typeof(TextBlock)); factory.SetBinding(TextBlock.TextProperty, new Binding(def.BindingPath)); column.CellTemplate = new DataTemplate { VisualTree = factory }; } else { column.CellTemplate = (DataTemplate)FindResource($"CellTemplate_{def.TemplateKey}"); column.CellEditingTemplate = (DataTemplate)FindResource($"EditTemplate_{def.TemplateKey}"); } dataGrid.Columns.Add(column); } }参数说明:FindResource里用的键名,必须和资源字典里声明的DataTemplate x:Key严格一致。这里约定CellTemplate_Status这种命名,没有找到模板时会在运行时抛异常,最好加一层TryFindResource判断。
2.3 模板选择器:同一列根据数据动态切换单元格视觉
有时候同一列在不同行要显示不同的东西,例如状态列,待支付行显示一个按钮,已支付行只显示文本。用DataGridTemplateColumn的固定CellTemplate做不到每行差异化,最直接的方案是DataTemplateSelector。
public class StatusTemplateSelector : DataTemplateSelector { public DataTemplate PendingTemplate { get; set; } public DataTemplate PaidTemplate { get; set; } public DataTemplate ShippedTemplate { get; set; } public override DataTemplate SelectTemplate(object item, DependencyObject container) { if (item is OrderItem order) { switch (order.OrderStatus) { case "待支付": return PendingTemplate; case "已支付": return PaidTemplate; case "已发货": return ShippedTemplate; } } return base.SelectTemplate(item, container); } }逻辑说明:DataTemplateSelector的SelectTemplate方法会根据item(也就是行数据)返回不同的DataTemplate。关键点在于返回的模板里的DataContext依然是整个OrderItem对象,所以模板里仍然用{Binding OrderStatus}取列值,而不是{Binding}把整行当字符串用。
在窗口资源里把这个选择器声明出来,再给DataGridTemplateColumn设置ItemTemplateSelector属性:
<Window.Resources> <DataTemplate x:Key="StatusPendingTemplate"> <Button Content="去支付" Command="{Binding DataContext.PayCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}" CommandParameter="{Binding}"/> </DataTemplate> <DataTemplate x:Key="StatusPaidTemplate"> <TextBlock Text="已支付" Foreground="Green"/> </DataTemplate> <local:StatusTemplateSelector x:Key="StatusSelector" PendingTemplate="{StaticResource StatusPendingTemplate}" PaidTemplate="{StaticResource StatusPaidTemplate}"/> </Window.Resources> <DataGridTemplateColumn Header="状态" CellTemplateSelector="{StaticResource StatusSelector}"/>提示:使用CellTemplateSelector时,CellTemplate属性就不要同时设置,否则选择器不生效。另外,如果这个列允许编辑,还要设置CellEditingTemplateSelector,否则编辑时候选模板会退回默认。
3. 核心实现:DataGridTemplateColumn的展示与编辑模板拆解
3.1 展示模板:用StackPanel和DataTrigger绘制状态列
复杂列样式的核心是CellTemplate,它决定了非编辑状态下单元格长什么样。以状态列为例,我希望前面是一个圆点图标,后面是状态文字,圆点颜色根据状态变化。
<DataGridTemplateColumn Header="订单状态" Width="180"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> <StackPanel Orientation="Horizontal" VerticalAlignment="Center"> <Ellipse Width="10" Height="10" Margin="4,0" Fill="Gray" VerticalAlignment="Center"> <Ellipse.Style> <Style TargetType="Ellipse"> <Setter Property="Fill" Value="Gray"/> <Style.Triggers> <DataTrigger Binding="{Binding OrderStatus}" Value="待支付"> <Setter Property="Fill" Value="Orange"/> </DataTrigger> <DataTrigger Binding="{Binding OrderStatus}" Value="已支付"> <Setter Property="Fill" Value="Green"/> </DataTrigger> <DataTrigger Binding="{Binding OrderStatus}" Value="已作废"> <Setter Property="Fill" Value="Red"/> </DataTrigger> </Style.Triggers> </Style> </Ellipse.Style> </Ellipse> <TextBlock Text="{Binding OrderStatus}" VerticalAlignment="Center"/> </StackPanel> </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn>逻辑说明:Ellipse本身设置了Fill=Gray作为默认色,背景上的Style里又设置了默认样式,最终显示时以Style里的Setter为准。DataTrigger的Binding会自动绑定到DataContext,也就是OrderItem,所以直接写OrderStatus属性名。Value等于对应字符串时,圆点变色。
参数说明:Width="10" Height="10"是圆点直径,Margin="4,0"控制圆点跟文字的距离。如果状态枚举含有已取消、退款中这类状态,继续加DataTrigger就行,不需要改动后台代码。这里有个细节:Ellipse.Fill属性被直接设置后,Style里的Setter会覆盖直接属性,但DataTrigger的Setter优先级更高,所以能正确变色。
3.2 编辑模板:ComboBox与单元格编辑状态
默认编辑是TextBox,对状态列不合适,我改成ComboBox下拉选择。编辑模板放在CellEditingTemplate里,DataGrid进入编辑模式时自动替换视觉树。
<DataGridTemplateColumn.CellEditingTemplate> <DataTemplate> <ComboBox ItemsSource="{Binding DataContext.StatusOptions, RelativeSource={RelativeSource AncestorType=DataGrid}}" SelectedItem="{Binding OrderStatus, UpdateSourceTrigger=PropertyChanged}" HorizontalAlignment="Stretch"/> </DataTemplate> </DataGridTemplateColumn.CellEditingTemplate>逻辑说明:ItemsSource绑定到DataGrid.DataContext.StatusOptions,这是ViewModel里的一个List ,下拉内容全局统一。SelectedItem双向绑定到OrderStatus,选择后立刻写回数据。加UpdateSourceTrigger=PropertyChanged是为了避免等焦点移走才提交,某些操作场景会立刻读取状态,这个属性很关键。
public List<string> StatusOptions { get; } = new List<string> { "待支付", "已支付", "已发货", "已作废" };踩过的坑是:ComboBox的ItemTemplate如果是CheckBox这类复杂控件,SelectedItem的匹配会变成按引用而不是按值,导致下拉选中了但状态没变。这里最简单就是让ComboBox直接显示字符串,SelectedItem是字符串,与OrderStatus字符串按值比较,不会出问题。
3.3 操作列:按钮命令如何绑定到ViewModel
操作列是另一个常见复杂列。按钮放在DataTemplate里,Command要定位到ViewModel,绝对不能直接写{Binding ReviewCommand},因为DataContext是行数据OrderItem,不是ViewModel。
<DataGridTemplateColumn Header="操作" Width="160"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> <StackPanel Orientation="Horizontal"> <Button Content="审核" Margin="4,0" Command="{Binding DataContext.ReviewCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}" CommandParameter="{Binding}"/> <Button Content="删除" Margin="4,0" Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}" CommandParameter="{Binding}"/> </StackPanel> </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn>逻辑说明:RelativeSource的AncestorType=DataGrid表示从当前元素沿视觉树向上找,找到DataGrid实例后,取它的DataContext作为命令源。CommandParameter={Binding}把当前行OrderItem传给命令,命令里就能拿到是哪一行。
ViewModel里对应命令用RelayCommand实现:
public ICommand ReviewCommand { get; } private void OnReview(OrderItem order) { order.OrderStatus = "已支付"; } public MainViewModel() { ReviewCommand = new RelayCommand<OrderItem>(OnReview); }参数说明:RelayCommand 的泛型参数与CommandParameter一致。如果不传参数,直接写CommandParameter={Binding}忽略类型,通常也能工作,但命令里拿到的可能是DataGrid对象,所以显式传OrderItem更安全。
3.4 表头模板:多行表头与带说明单位
复杂列不只是单元格,表头也需要定制。比如金额列表头要显示“金额”和“单位:元”,宽度有限时还得分两行展示。用HeaderTemplate可以完全控制表头内容。
<DataGridTemplateColumn Width="120" CanUserSort="True" SortMemberPath="Amount"> <DataGridTemplateColumn.HeaderTemplate> <DataTemplate> <StackPanel> <TextBlock Text="金额" FontWeight="Bold" HorizontalAlignment="Center"/> <TextBlock Text="单位:元" FontSize="10" Foreground="Gray" HorizontalAlignment="Center"/> </StackPanel> </DataTemplate> </DataGridTemplateColumn.HeaderTemplate> <DataGridTemplateColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding Amount, StringFormat={}{0:F2}}" HorizontalAlignment="Right" Margin="0,0,8,0"/> </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn>逻辑说明:DataGridTemplateColumn本身没有SortMemberPath属性,但可以在这里设置,这样点击自定表头也能排序。SortMemberPath告诉DataGrid按数据的哪个属性排序,和显示模板无关。注意StringFormat里的{}转义,因为直接在XAML里写{F2}会被当成标记扩展。
4. 性能与交互优化:模板复杂了,滚动怎么才不卡
4.1 开启行虚拟化与Recycling模式
自定义模板意味着每行会有更多控件,如果列表有几千行,默认虚拟化策略可能不足。DataGrid默认开启行虚拟化,但列虚拟化不一定。模板列里放了按钮、圆点这些交互元素后,滚动掉帧很常见。我会在DataGrid上显式设置:
<DataGrid VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" EnableRowVirtualization="True" EnableColumnVirtualization="True" ScrollViewer.IsDeferredScrollingEnabled="True">逻辑说明:VirtualizationMode=Recycling让DataGrid回收移出可视区域的行容器,而不是销毁重建。这样从底部滚回顶部时,控件实例被复用,DataContext被替换,UI重建成本大幅降低。ScrollViewer.IsDeferredScrollingEnabled=True让滚动优先响应,内容是延迟重画的,适合大表格。
注意,Recycling模式对模板里的x:Name有约束。如果一个模板里有两个用了x:Name的控件,复用后名称作用域可能冲突,所以在复杂列模板里不要使用x:Name,所有控件都走绑定。
4.2 冻结列:操作列固定在可视区域
当列数达到十几列,操作列往往被滚动到屏幕外。DataGrid的FrozenColumnCount属性可以把左侧N列固定住。我把操作列放在Columns集合的第一位,然后FrozenColumnCount="1"。
<DataGrid FrozenColumnCount="1"> <DataGrid.Columns> <DataGridTemplateColumn Header="操作" Width="160"> <!-- 操作按钮模板 --> </DataGridTemplateColumn> <DataGridTemplateColumn Header="订单号" Width="150"/> <!-- 其他列 --> </DataGrid.Columns> </DataGrid>逻辑说明:冻结列的本质是把列从水平滚动区域排除。操作列冻结后,水平滚动时保持不动。需要权衡的是冻结列宽度不能太宽,否则挤压滚动区域空间。如果操作列按钮很多,宽度建议控制在180以内,再多可以折叠成“更多”菜单。
4.3 行级样式触发器:按状态高亮整行
有时高亮的是整行而不是某一格。DataGrid.RowStyle里可以写DataTrigger,绑定行数据的某个属性,改变行的Background和Foreground。这样用户扫一眼就知道哪些行异常。
<DataGrid.RowStyle> <Style TargetType="DataGridRow"> <Setter Property="Background" Value="White"/> <Style.Triggers> <DataTrigger Binding="{Binding OrderStatus}" Value="已作废"> <Setter Property="Background" Value="#FFF4F4"/> <Setter Property="Foreground" Value="#999999"/> </DataTrigger> <DataTrigger Binding="{Binding OrderStatus}" Value="待支付"> <Setter Property="Background" Value="#FFFFF8"/> </DataTrigger> </Style.Triggers> </Style> </DataGrid.RowStyle>逻辑说明:行样式的DataTrigger也是对DataContext生效,也就是OrderItem。这里的Foreground会继承到单元格里的TextBlock,如果TextBlock在模板里显式设置了Foreground,那行级Foreground不会覆盖。因此行级高亮只适合模板控件没有自己颜色的场景。如果需要完全覆盖,模板里的TextBlock不要给Foreground,或者用TemplateBinding。
4.4 减少视觉树膨胀:隐式模板与共享样式
复杂列也可以把单元格内容拆成UserControl,但要注意UserControl会加重视觉树。轻量做法是继续使用DataTemplate,把重复出现的样式定义为公共资源。例如所有列共用的对齐样式、边距样式,定义为Style,然后通过Style引用,避免每个模板里重复写属性。
<Style x:Key="CellTextStyle" TargetType="TextBlock"> <Setter Property="VerticalAlignment" Value="Center"/> <Setter Property="Margin" Value="8,0"/> <Setter Property="TextTrimming" Value="CharacterEllipsis"/> </Style>然后在DataTemplate里用<TextBlock Text="{Binding Amount}" Style="{StaticResource CellTextStyle}"/>。这样同一套样式在多列复用时,只保留一份资源定义,修改时全局生效。
5. 避坑指南:自定义DataGrid列样式常见问题与排查
5.1 现象:ComboBox编辑模板选中后,单元格显示不更新
有次把ComboBox放在CellEditingTemplate里,选择新状态后按回车退出编辑,看单元格还是旧值。原因有两个:一是ComboBox的SelectedItem绑定没有设置UpdateSourceTrigger=PropertyChanged,默认是LostFocus才提交,但DataGrid的编辑提交和ComboBox失焦顺序有时候会错位;二是ComboBox的ItemsSource里放的是ComboBoxItem对象,SelectedItem类型和OrderStatus的string类型不匹配,导致绑定失败。
解决:ItemsSource直接绑ViewModel里的List ,SelectedItem加UpdateSourceTrigger=PropertyChanged。如果还要显示图标,要在ItemTemplate里做,不能把Item直接改成复杂对象。
5.2 现象:DataTemplateSelector不生效,所有行都显示同一个模板
典型原因是同时设置了CellTemplate和CellTemplateSelector,DataGrid的优先级是显式CellTemplate优先,选择器被忽略。还有种情况是,在Selector的SelectTemplate里返回了null,然后DataGrid退回默认的模板。我建议的排查顺序:先确认Selector被赋给了CellTemplateSelector;再在SelectTemplate方法内加断点,看item类型是否是预期的OrderItem;最后检查返回的DataTemplate是否已经在资源字典里加载。
如果item是ContentPresenter而不是OrderItem,说明模板选择器被用在了错误层级。DataGridTemplateColumn的CellTemplateSelector接收的是行数据,而不是单元格ContentPresenter,所以强转前先判断。
5.3 现象:虚拟化开启后,按钮点击没反应或者命令参数错乱
Recycling模式复用行容器,滚动后行数据变了,但按钮还是原来那个实例。如果按钮的CommandParameter用了比较复杂的绑定,比如多级路径,复用时可能取到新行的数据,这实际上是正常的,但如果你用了事件处理器,事件参数里的DataContext在回收时可能没更新,导致事件读到的还是旧行数据。
最省心的解决方式:一律使用Command绑定,不要用Click事件里通过Button.DataContext拿数据。Command的CanExecute会自动刷新,CommandParameter绑定到DataContext,回收后值自然更新。
5.4 现象:修改数据源后,单元格不刷新,关掉窗口再开才显示
这种情况九成是数据模型没有实现INotifyPropertyChanged,或者属性直接赋值没有走SetProperty。DataGrid绑定是单向还是双向都无法绕过硬编码通知。另一种隐蔽原因:绑定路径写成了{Binding OrderStatus},但OrderStatus是私有字段或无setter,不会触发。所以排查时先看输出窗口的绑定错误,WPF会打印BindingExpression路径错误日志。如果属性名拼写不对,界面是空的,日志里会有明确提示。
6. 二次开发:把列样式封装成可复用的自定义列与行为
做一次两次还能接受,项目里有七八个窗口都要用这套复杂列,重复XAML会把人逼疯。我的做法是二次封装成自定义列控件SmartColumn,把模板Key、命令、可见性都变成依赖属性。这样XAML里写一行就能得到完整列样式。
public class SmartColumn : DataGridTemplateColumn { public static readonly DependencyProperty TemplateKeyProperty = DependencyProperty.Register(nameof(TemplateKey), typeof(string), typeof(SmartColumn), new PropertyMetadata(null, OnTemplateKeyChanged)); public string TemplateKey { get => (string)GetValue(TemplateKeyProperty); set => SetValue(TemplateKeyProperty, value); } public string CommandPath { get; set; } private static void OnTemplateKeyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var column = (SmartColumn)d; var window = Window.GetWindow(column); if (window == null) return; if (window.TryFindResource($"CellTemplate_{e.NewValue}") is DataTemplate cellTeemplate) { column.CellTemplate = cellTeemplate; } if (window.TryFindResource($"EditTemplate_{e.NewValue}") is DataTemplate editTemplate) { column.CellEditingTemplate = editTemplate; } } }逻辑说明:依赖属性回调里通过Window.GetWindow找到所属窗口,再TryFindResource。这样模板Key变化后,列会自动切换到对应模板。窗口加载完成后,如果后续改变TemplateKey,也会触发重新查找。注意如果模板资源在App级,TryFindResource也能从Application级找到。
再加一个附加行为,统一设置DataGrid的虚拟化和冻结列:
public static class DataGridBehavior { public static readonly DependencyProperty AutoEnableRecyclingProperty = DependencyProperty.RegisterAttached("AutoEnableRecycling", typeof(bool), typeof(DataGridBehavior), new PropertyMetadata(false, OnAutoEnableRecyclingChanged)); public static void SetAutoEnableRecycling(DependencyObject obj, bool value) => obj.SetValue(AutoEnableRecyclingProperty, value); public static bool GetAutoEnableRecycling(DependencyObject obj) => (bool)obj.GetValue(AutoEnableRecyclingProperty); private static void OnAutoEnableRecyclingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is DataGrid grid && (bool)e.NewValue) { grid.EnableRowVirtualization = true; grid.EnableColumnVirtualization = true; VirtualizingPanel.SetIsVirtualizing(grid, true); VirtualizingPanel.SetVirtualizationMode(grid, VirtualizationMode.Recycling); } } }使用:
<DataGrid local:DataGridBehavior.AutoEnableRecycling="True"> <local:SmartColumn TemplateKey="Status" Header="状态"/> <local:SmartColumn TemplateKey="Action" Header="操作"/> </DataGrid>验证这套改造时,我会单独建一个测试页,塞入两千条模拟数据,测三件事:第一,滚动是否流畅,CPU占用是否稳定;第二,修改某一行状态后,单元格是否立刻变化;第三,冻结列和模板选择器是否互相冲突。从那以后,凡是新窗口里出现DataGrid复杂列,我都强制自己走一遍这个封装流程,把模板Key、命令上下文、虚拟化开关列成清单逐一确认,再也没有因为列样式翻车。希望帮到你。
本文还有配套的精品资源,点击获取