资讯详情

资讯详情

WPF DataGrid复杂列样式自定义实战:模板选择器与性能优化技巧

简介面向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 SetPropertyT(ref T field, T value, [CallerMemberName] string name null) { if (EqualityComparerT.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 ObservableCollectionOrderItem Orders { get; set; } public ObservableCollectionColumnDescription ColumnDefs { get; set; } public MainViewModel() { Orders new ObservableCollectionOrderItem(); ColumnDefs new ObservableCollectionColumnDescription { 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:KeyStatusPendingTemplate Button Content去支付 Command{Binding DataContext.PayCommand, RelativeSource{RelativeSource AncestorTypeDataGrid}} CommandParameter{Binding}/ /DataTemplate DataTemplate x:KeyStatusPaidTemplate TextBlock Text已支付 ForegroundGreen/ /DataTemplate local:StatusTemplateSelector x:KeyStatusSelector 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订单状态 Width180 DataGridTemplateColumn.CellTemplate DataTemplate StackPanel OrientationHorizontal VerticalAlignmentCenter Ellipse Width10 Height10 Margin4,0 FillGray VerticalAlignmentCenter Ellipse.Style Style TargetTypeEllipse Setter PropertyFill ValueGray/ Style.Triggers DataTrigger Binding{Binding OrderStatus} Value待支付 Setter PropertyFill ValueOrange/ /DataTrigger DataTrigger Binding{Binding OrderStatus} Value已支付 Setter PropertyFill ValueGreen/ /DataTrigger DataTrigger Binding{Binding OrderStatus} Value已作废 Setter PropertyFill ValueRed/ /DataTrigger /Style.Triggers /Style /Ellipse.Style /Ellipse TextBlock Text{Binding OrderStatus} VerticalAlignmentCenter/ /StackPanel /DataTemplate /DataGridTemplateColumn.CellTemplate /DataGridTemplateColumn逻辑说明Ellipse本身设置了FillGray作为默认色背景上的Style里又设置了默认样式最终显示时以Style里的Setter为准。DataTrigger的Binding会自动绑定到DataContext也就是OrderItem所以直接写OrderStatus属性名。Value等于对应字符串时圆点变色。参数说明Width10 Height10是圆点直径Margin4,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 AncestorTypeDataGrid}} SelectedItem{Binding OrderStatus, UpdateSourceTriggerPropertyChanged} HorizontalAlignmentStretch/ /DataTemplate /DataGridTemplateColumn.CellEditingTemplate逻辑说明ItemsSource绑定到DataGrid.DataContext.StatusOptions这是ViewModel里的一个List 下拉内容全局统一。SelectedItem双向绑定到OrderStatus选择后立刻写回数据。加UpdateSourceTriggerPropertyChanged是为了避免等焦点移走才提交某些操作场景会立刻读取状态这个属性很关键。public Liststring StatusOptions { get; } new Liststring { 待支付, 已支付, 已发货, 已作废 };踩过的坑是ComboBox的ItemTemplate如果是CheckBox这类复杂控件SelectedItem的匹配会变成按引用而不是按值导致下拉选中了但状态没变。这里最简单就是让ComboBox直接显示字符串SelectedItem是字符串与OrderStatus字符串按值比较不会出问题。3.3 操作列按钮命令如何绑定到ViewModel操作列是另一个常见复杂列。按钮放在DataTemplate里Command要定位到ViewModel绝对不能直接写{Binding ReviewCommand}因为DataContext是行数据OrderItem不是ViewModel。DataGridTemplateColumn Header操作 Width160 DataGridTemplateColumn.CellTemplate DataTemplate StackPanel OrientationHorizontal Button Content审核 Margin4,0 Command{Binding DataContext.ReviewCommand, RelativeSource{RelativeSource AncestorTypeDataGrid}} CommandParameter{Binding}/ Button Content删除 Margin4,0 Command{Binding DataContext.DeleteCommand, RelativeSource{RelativeSource AncestorTypeDataGrid}} CommandParameter{Binding}/ /StackPanel /DataTemplate /DataGridTemplateColumn.CellTemplate /DataGridTemplateColumn逻辑说明RelativeSource的AncestorTypeDataGrid表示从当前元素沿视觉树向上找找到DataGrid实例后取它的DataContext作为命令源。CommandParameter{Binding}把当前行OrderItem传给命令命令里就能拿到是哪一行。ViewModel里对应命令用RelayCommand实现public ICommand ReviewCommand { get; } private void OnReview(OrderItem order) { order.OrderStatus 已支付; } public MainViewModel() { ReviewCommand new RelayCommandOrderItem(OnReview); }参数说明RelayCommand 的泛型参数与CommandParameter一致。如果不传参数直接写CommandParameter{Binding}忽略类型通常也能工作但命令里拿到的可能是DataGrid对象所以显式传OrderItem更安全。3.4 表头模板多行表头与带说明单位复杂列不只是单元格表头也需要定制。比如金额列表头要显示“金额”和“单位元”宽度有限时还得分两行展示。用HeaderTemplate可以完全控制表头内容。DataGridTemplateColumn Width120 CanUserSortTrue SortMemberPathAmount DataGridTemplateColumn.HeaderTemplate DataTemplate StackPanel TextBlock Text金额 FontWeightBold HorizontalAlignmentCenter/ TextBlock Text单位元 FontSize10 ForegroundGray HorizontalAlignmentCenter/ /StackPanel /DataTemplate /DataGridTemplateColumn.HeaderTemplate DataGridTemplateColumn.CellTemplate DataTemplate TextBlock Text{Binding Amount, StringFormat{}{0:F2}} HorizontalAlignmentRight Margin0,0,8,0/ /DataTemplate /DataGridTemplateColumn.CellTemplate /DataGridTemplateColumn逻辑说明DataGridTemplateColumn本身没有SortMemberPath属性但可以在这里设置这样点击自定表头也能排序。SortMemberPath告诉DataGrid按数据的哪个属性排序和显示模板无关。注意StringFormat里的{}转义因为直接在XAML里写{F2}会被当成标记扩展。4. 性能与交互优化模板复杂了滚动怎么才不卡4.1 开启行虚拟化与Recycling模式自定义模板意味着每行会有更多控件如果列表有几千行默认虚拟化策略可能不足。DataGrid默认开启行虚拟化但列虚拟化不一定。模板列里放了按钮、圆点这些交互元素后滚动掉帧很常见。我会在DataGrid上显式设置DataGrid VirtualizingPanel.IsVirtualizingTrue VirtualizingPanel.VirtualizationModeRecycling EnableRowVirtualizationTrue EnableColumnVirtualizationTrue ScrollViewer.IsDeferredScrollingEnabledTrue逻辑说明VirtualizationModeRecycling让DataGrid回收移出可视区域的行容器而不是销毁重建。这样从底部滚回顶部时控件实例被复用DataContext被替换UI重建成本大幅降低。ScrollViewer.IsDeferredScrollingEnabledTrue让滚动优先响应内容是延迟重画的适合大表格。注意Recycling模式对模板里的x:Name有约束。如果一个模板里有两个用了x:Name的控件复用后名称作用域可能冲突所以在复杂列模板里不要使用x:Name所有控件都走绑定。4.2 冻结列操作列固定在可视区域当列数达到十几列操作列往往被滚动到屏幕外。DataGrid的FrozenColumnCount属性可以把左侧N列固定住。我把操作列放在Columns集合的第一位然后FrozenColumnCount1。DataGrid FrozenColumnCount1 DataGrid.Columns DataGridTemplateColumn Header操作 Width160 !-- 操作按钮模板 -- /DataGridTemplateColumn DataGridTemplateColumn Header订单号 Width150/ !-- 其他列 -- /DataGrid.Columns /DataGrid逻辑说明冻结列的本质是把列从水平滚动区域排除。操作列冻结后水平滚动时保持不动。需要权衡的是冻结列宽度不能太宽否则挤压滚动区域空间。如果操作列按钮很多宽度建议控制在180以内再多可以折叠成“更多”菜单。4.3 行级样式触发器按状态高亮整行有时高亮的是整行而不是某一格。DataGrid.RowStyle里可以写DataTrigger绑定行数据的某个属性改变行的Background和Foreground。这样用户扫一眼就知道哪些行异常。DataGrid.RowStyle Style TargetTypeDataGridRow Setter PropertyBackground ValueWhite/ Style.Triggers DataTrigger Binding{Binding OrderStatus} Value已作废 Setter PropertyBackground Value#FFF4F4/ Setter PropertyForeground Value#999999/ /DataTrigger DataTrigger Binding{Binding OrderStatus} Value待支付 Setter PropertyBackground 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:KeyCellTextStyle TargetTypeTextBlock Setter PropertyVerticalAlignment ValueCenter/ Setter PropertyMargin Value8,0/ Setter PropertyTextTrimming ValueCharacterEllipsis/ /Style然后在DataTemplate里用TextBlock Text{Binding Amount} Style{StaticResource CellTextStyle}/。这样同一套样式在多列复用时只保留一份资源定义修改时全局生效。5. 避坑指南自定义DataGrid列样式常见问题与排查5.1 现象ComboBox编辑模板选中后单元格显示不更新有次把ComboBox放在CellEditingTemplate里选择新状态后按回车退出编辑看单元格还是旧值。原因有两个一是ComboBox的SelectedItem绑定没有设置UpdateSourceTriggerPropertyChanged默认是LostFocus才提交但DataGrid的编辑提交和ComboBox失焦顺序有时候会错位二是ComboBox的ItemsSource里放的是ComboBoxItem对象SelectedItem类型和OrderStatus的string类型不匹配导致绑定失败。解决ItemsSource直接绑ViewModel里的List SelectedItem加UpdateSourceTriggerPropertyChanged。如果还要显示图标要在ItemTemplate里做不能把Item直接改成复杂对象。5.2 现象DataTemplateSelector不生效所有行都显示同一个模板典型原因是同时设置了CellTemplate和CellTemplateSelectorDataGrid的优先级是显式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.AutoEnableRecyclingTrue local:SmartColumn TemplateKeyStatus Header状态/ local:SmartColumn TemplateKeyAction Header操作/ /DataGrid验证这套改造时我会单独建一个测试页塞入两千条模拟数据测三件事第一滚动是否流畅CPU占用是否稳定第二修改某一行状态后单元格是否立刻变化第三冻结列和模板选择器是否互相冲突。从那以后凡是新窗口里出现DataGrid复杂列我都强制自己走一遍这个封装流程把模板Key、命令上下文、虚拟化开关列成清单逐一确认再也没有因为列样式翻车。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →