ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

WPF DataGrid 动态列实现:MVVM下的动态绑定与编辑器切换

WPF DataGrid 动态列实现:MVVM下的动态绑定与编辑器切换 简介面向WPF/.NET开发者的DataGrid动态列源码示例核心解决表格列需要在运行时按数据模型自动生成的问题。实现思路涵盖反射读取属性生成列、列集合动态管理、自定义编辑模板、保存取消命令及数据验证等环节适合正处于MVVM学习阶段、或需要为表格实现动态列与可编辑单元格的C#桌面开发者参考。压缩包共43个文件、约88KB主体为17个C#源码文件包含视图模型、动态列生成类、实体模型及两个XAML界面另有工程配置、编译产物和辅助调试文件三层代码结构完整且可直接运行。该资源已有2931人学习浏览体量小而实用读者可借助源码掌握动态列与编辑模板的绑定写法、双向同步与校验逻辑也可先运行编译好的程序观察效果再对照代码理解能有效缩短WPF下MVVM模式的入门与排错时间。 做动态列需求前我一直以为WPF里DataGrid的列绑定是件很顺手的事。直到接了一个上位机报表的项目接口返回的字段列表是运行时才确定的列名可能今天叫A明天叫B客户还要求不同的列用不同的输入控件数字列要能输小数、状态列要下拉选择、日期列要点出日历。那会儿才意识到DataGrid的Columns在MVVM里就是一块硬骨头它根本不支持从ViewModel直接绑定。这篇文章把我后来沉淀下来的完整方案拆开讲包括动态行模型、列定义模型、动态列生成、单元格编辑器切换以及在刷屏量级数据下踩过的各种坑给同样被动态列折磨的人一个可以直接抄作业的存档。1. 动态列到底难在哪DataGrid与MVVM的适配层裂缝1.1 从一次上位机报表需求说起当时的需求其实并不复杂数据源来自一个通用查询接口返回字段清单和行数据字段数量、字段类型、字段顺序全部动态。也就是说ViewModel里不可能写死一个包含固定属性集合的实体类也不可能在XAML里预置死若干个DataGridTextColumn。客户还提了两个附加要求不同字段要用合适的编辑器输入文本、数字、下拉、日期以及列的显示隐藏希望在运行时通过菜单切换。这两个要求单独看不难但叠加上MVVM三个字麻烦就来了。如果你用AutoGenerateColumns DataTable偷懒列确实能自动生成但编辑器完全不可控默认文本绑定、类型转换、下拉列、日期列全都得另想办法更别提把列显隐和用户配置序列化回ViewModel了。1.2 为什么 DataGrid.Columns 不能直接绑定DataGrid.Columns是ObservableCollectionDataGridColumn类型按道理是支持监听的。但问题在于DataGrid内部并不会在Columns集合变化时重新生成可见列或者准确说它只监听集合的增删事件来同步UI可你很难把一个DataGridTextColumn序列化为配置存到数据库也很难把它作为纯数据对象绑定到ViewModel。更本质的问题是DataGridColumn不是依赖对象能直接“用数据驱动”的那种模型。它的Header、Binding等属性虽然有些是依赖属性但整个Columns集合是控件内部的UI集合不属于DataContext的数据范围。你要在MVVM里动态生成列本质上是在“数据模型”和“控件元素”之间做翻译这个翻译层要么写在View的代码后台要么封装成附加行为。1.3 三个必须拆分的设计层面做完这个项目后我的结论是动态列必须拆成三层来看。数据源层提供行的数据行不再是一个固定实体类而是一个可以按字段名取值的动态容器。列定义层描述每一列的信息标题、绑定路径、宽度、类型、编辑器类型、是否只读、是否可见。UI生成层根据列定义创建对应的DataGridColumn并处理增删、显隐、宽度变化等UI操作。这三层各管各的ViewModel只管前两层UI生成层用附加属性或轻量代码后台承载。这样既保住了MVVM的数据驱动优势又不用把UI细节塞进ViewModel。2. 动态行的数据层设计字典行模型与索引器通知2.1 为什么不用DataTable很多人第一反应是DataTable毕竟它能动态加列加行配合AutoGenerateColumns也很省事。但DataTable放在MVVM里会有几个致命问题。第一DataTable的行是DataRowView绑定到DataGrid后类型信息很弱校验、格式化都得依赖DataTable的列类型配置但列类型又是全局的没法做到同一个列在不同行有不同的编辑器行为。第二DataTable和ViewModel之间缺一层映射你很难把业务校验、命令、按钮列这些逻辑挂到行对象上。第三DataTable自带的通知机制在WPF里偶尔会有刷新时机的问题尤其是编辑后立即排序、筛选偶尔会出现值对不上的诡异现象。所以对于中大型项目我更推荐自己做一个“动态行”容器而不是直接用DataTable。2.2 DynamicRow内核字典加索引器加INPC动态行的核心其实就是一个字典加一个索引器属性public class DynamicRow : ObservableObject { private readonly Dictionarystring, object? _values new(StringComparer.OrdinalIgnoreCase); public object? this[string key] { get _values.TryGetValue(key, out var v) ? v : null; set { if (_values.TryGetValue(key, out var old) Equals(old, value)) return; _values[key] value; OnPropertyChanged(key); // 通知对应列的绑定刷新 OnPropertyChanged(Item[]); // 兼容集合索引器监听 } } public IReadOnlyDictionarystring, object? Values _values; }这里有一个细节必须提醒如果只发OnPropertyChanged(key)在多数场景下DataGrid的单元格能刷新但如果有人用CollectionViewSource对动态行做了分组或排序或者绑定了ItemsSource的路径索引就必须额外发一个Item[]通知否则部分场景刷新不到。2.3 索引器通知触发机制的原理为什么CollectionView会监听Item[]这种伪属性名因为WPF的绑定引擎把this[string key]这类索引器当成一个名为Item[]的属性来看待。当绑定路径是[OrderQty]时绑定引擎实际监听的是Item[]这个属性的PropertyChanged事件然后把变更结果映射到对应索引键上。所以只发OrderQty通知能作用到显式绑定{Binding OrderQty}的地方但索引器绑定{Binding [OrderQty]}不一定都能收到稳妥做法是两者都发。实际项目里我还遇到过一个坑如果字典里存的是decimal而绑定的源是double值写进去再读出来类型是不一致的。所以DynamicRow的赋值入口我会做一层统一转换public void SetValue(string key, object? value, Type targetType) { if (value ! null targetType ! null) { var converted Convert.ChangeType(value, targetType); this[key] converted; } else { this[key] value; } }这样列定义里只要声明类型写入的值就不会因为数据结构混乱导致后端序列化出问题。3. 列定义模型把Columns翻译成ViewModel能管的数据3.1 ColumnMeta列的一切信息都在这里动态列的源头是ColumnMeta它是列在ViewModel世界里的化身。我给它的最小定义大致是这样public enum CellEditorKind { Text, // 文本框 Numeric, // 数字输入 ComboBox, // 下拉选择 DatePicker, // 日期选择 CheckBox, // 布尔勾选 Button // 命令按钮 } public class ColumnMeta : ObservableObject { public string BindingPath { get; set; } string.Empty; public string Header { get; set; } string.Empty; public Type DataType { get; set; } typeof(string); public int Width { get; set; } 120; private CellEditorKind _editorKind CellEditorKind.Text; public CellEditorKind EditorKind { get _editorKind; set { if (SetProperty(ref _editorKind, value)) OnPropertyChanged(nameof(Template)); } } public bool IsReadOnly { get; set; } private bool _visible true; public bool Visible { get _visible; set SetProperty(ref _visible, value); } public IEnumerable? Options { get; set; } // ComboBox数据源 public ICommand? Command { get; set; } // 按钮列命令 public string? CommandParameterPath { get; set; } public ObservableCollectionColumnMeta? Children { get; set; } // 表头分组的进阶玩法 }这里我把EditorKind与模板资源隔离ViewModel里面不直接持有一个DataTemplate对象只声明编辑器类型。UI生成层拿到EditorKind再去查对应的模板资源这样ViewModel可以不引用任何UI命名空间。3.2 附加属性让Columns变得可观察有了列定义集合后View层需要把它翻译成DataGrid.Columns。我封装了一个附加属性挂在DataGrid上public static class DataGridColumnsBehavior { public static readonly DependencyProperty BindableColumnsProperty DependencyProperty.RegisterAttached( BindableColumns, typeof(ObservableCollectionColumnMeta), typeof(DataGridColumnsBehavior), new PropertyMetadata(null, OnBindableColumnsChanged)); public static void SetBindableColumns(DependencyObject element, ObservableCollectionColumnMeta value) element.SetValue(BindableColumnsProperty, value); public static ObservableCollectionColumnMeta GetBindableColumns(DependencyObject element) (ObservableCollectionColumnMeta)element.GetValue(BindableColumnsProperty); private static void OnBindableColumnsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not DataGrid grid) return; if (e.OldValue is ObservableCollectionColumnMeta oldList) oldList.CollectionChanged - OnColumnCollectionChanged; if (e.NewValue is ObservableCollectionColumnMeta newList) newList.CollectionChanged OnColumnCollectionChanged; grid.Columns.Clear(); foreach (var meta in (IEnumerableColumnMeta?)e.NewValue ?? Enumerable.EmptyColumnMeta()) { grid.Columns.Add(CreateColumn(meta)); } } private static void OnColumnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { // 拿到DataGrid实例根据e.Action对grid.Columns做增量处理 } }这段代码的核心思想是ViewModel里维护一个ObservableCollectionColumnMeta所有列的新增、删除、顺序调整都走集合事件附加属性负责把变更同步到UI的DataGrid.Columns。有一个关键点DataGrid.Columns里每一项是DataGridColumn它们不是普通的轻量对象频繁整体Clear再重建会导致视觉闪烁、列宽丢失还有可能短暂破坏虚拟化状态。所以集合事件里尽量做增量增删而不是每次都清空重建。3.3 创建DataGridColumn的方法每个ColumnMeta对应一个DataGridTemplateColumn模板根据EditorKind来选private static DataGridColumn CreateColumn(ColumnMeta meta) { var column new DataGridTemplateColumn { Header meta.Header, Width new DataGridLength(meta.Width), IsReadOnly meta.IsReadOnly }; if (!meta.IsReadOnly) { column.CellTemplate CellTemplateFactory.CreateDisplayTemplate(meta); column.CellEditingTemplate CellTemplateFactory.CreateEditTemplate(meta); } else { column.CellTemplate CellTemplateFactory.CreateDisplayTemplate(meta); } return column; }CellTemplateFactory是一个纯UI层的辅助类负责按EditorKind构建具体模板。这个工厂放在View层很正常它解决的是把列定义转成UI这个纯表现问题。重要提醒DataGridTemplateColumn在非编辑态默认不加载CellEditingTemplate所以要在展示和编辑之间做不同的样式这个机制是天然的不要为了编辑样式去污染CellTemplate。4. 单元格编辑器动态切换从文本到下拉到日期4.1 显示模板与编辑模板的分工在DataGrid里CellTemplate负责非编辑状态下的呈现一般是一个只读的TextBlockCellEditingTemplate才是编辑器真正的交互控件在进入编辑模式以后才会被加载。这个分工是我实现动态编辑器的关键显示模板决定看起来是什么编辑模板决定点进去怎么改。比如Numeric类型的列显示模板我还是用TextBlock直接展示数值编辑模板换成带数值校验的TextBoxComboBox类型的列显示模板是TextBlock显示当前选中值编辑模板是ComboBox下拉。这样列在视觉上很干净编辑体验又灵活。4.2 动态构造编辑模板我用的方法是代码动态构造DataTemplate。虽然FrameworkElementFactory在.NET Core 3.0以后进入了维护模式官方更推荐XamlReader或者直接写XAML模板但在动态生成、模板结构由枚举值决定的场景下FrameworkElementFactory仍然是最直接的工具实测在.NET 8的WPF项目里能稳定工作。文本编辑器的构建public static DataTemplate CreateEditTemplate(ColumnMeta meta) { var textBox new FrameworkElementFactory(typeof(TextBox)); textBox.SetBinding(TextBox.TextProperty, new Binding($[{meta.BindingPath}]) { Mode BindingMode.TwoWay, UpdateSourceTrigger UpdateSourceTrigger.PropertyChanged }); var template new DataTemplate { VisualTree textBox }; template.Seal(); return template; }Numeric可以在需要时给TextBox的PreviewTextInput挂事件但事件挂在工厂上会比较绕。我一般换成在控件里放一个轻量验证的依赖属性或者干脆依赖绑定层的类型转换。下拉编辑器public static DataTemplate CreateComboBoxEditTemplate(ColumnMeta meta) { var combo new FrameworkElementFactory(typeof(ComboBox)); combo.SetBinding(ComboBox.SelectedValueProperty, new Binding($[{meta.BindingPath}]) { Mode BindingMode.TwoWay, UpdateSourceTrigger UpdateSourceTrigger.PropertyChanged }); combo.SetBinding(ComboBox.ItemsSourceProperty, new Binding(nameof(ColumnMeta.Options)) { Source meta }); var template new DataTemplate { VisualTree combo }; template.Seal(); return template; }注意这里ItemsSource的Binding把Source指定为ColumnMeta实例本身而不是行数据。这保证所有行的下拉列表共享同一个Options集合不受DataContext变化的影响。这也是我前文说的列元数据闭包技巧。日期和勾选列同理只是换成DatePicker和CheckBox。4.3 运行时切换编辑器类型如果需求是用户切换某列类型后编辑器跟着变那ColumnMeta里必须有EditorKind变更的通知然后UI层要处理列模板的替换。在附加属性的集合事件里对单列的EditorKind变更我建议直接找到DataGrid.Columns里对应的那一列替换它的CellTemplate和CellEditingTemplateprivate static void OnColumnMetaEditorKindChanged(ColumnMeta meta) { if (meta.Tag is DataGridTemplateColumn column) { column.CellTemplate CellTemplateFactory.CreateDisplayTemplate(meta); column.CellEditingTemplate CellTemplateFactory.CreateEditTemplate(meta); column.IsReadOnly meta.IsReadOnly; } }为了让ColumnMeta能快速定位到UI列可以在创建列时给DataGridTemplateColumn.Tag赋上ColumnMeta实例也可以在字典里维护映射。我习惯用Tag简单直接但Tag本身是object类型使用时要小心线程访问。5. 稳定性和性能刷新、筛选、大数据量的实操复盘5.1 数据刷新不闪烁最早我偷懒刷新时直接dataGrid.ItemsSource null; dataGrid.ItemsSource rows;这种写法在数据量小的时候还能忍数据量一上来就是明显的白屏闪烁、滚动位置丢失体验很差。应改用ICollectionView统一管理public ICollectionView RowsView { get; } RowsView CollectionViewSource.GetDefaultView(Rows); // 刷新时 RowsView.Refresh();如果你的数据源是ObservableCollectionDynamicRow且行内修改走的是索引器INPC其实多数情况下不需要全局Refresh。只有筛选条件、排序规则变化时才需要调用Refresh。配合ICollectionView的LiveFiltering还能实现动态筛选但要注意开启IsLiveFiltering需要数据源支持属性通知DynamicRow已经天然支持了。5.2 列集合增量更新OnColumnCollectionChanged里不要梭哈Clear和Addall。增删列的时候对应到DataGrid.Columns的Index位置做Insert和RemoveAt视觉上几乎无感。排序变化则用Move方法。这里有一个我踩过的坑DataGridTemplateColumn的引用一旦从Columns里移除再插回去模板和绑定经常会丢状态尤其是那列正在被编辑时。所以我处理删除时如果那列正处于编辑态会先让DataGrid结束编辑再删grid.CommitEdit(DataGridEditingUnit.Cell, true); grid.CancelEdit(DataGridEditingUnit.Row);否则偶尔会抛出指定元素已经是另一个元素的逻辑子元素之类的异常排查起来相当费时间。5.3 大数据量下的字典性能DynamicRow用Dictionary存储行数到十万级时滚动性能受两个因素影响。一是每次绑定取值都要查字典但DataGrid在虚拟化模式下只求值可见单元格所以实际开销可控二是排序和分组的时候CollectionView会频繁调用属性获取字典查找比实体类字段反射慢不少。经验数据十万行、三十列以内普通机器上滚动流畅度还能接受超过这个量级建议把DynamicRow改为编译后的强类型行或改用表达式树生成的属性访问器。在项目里我一般提供两层实现小的动态模型先用字典数据量到了预警值再切换成动态生成的强类型类。5.4 编辑校验里怎么拿同行其他列动态行做校验有个天然难题编辑模板里DataContext虽然是行对象但你无法预知要校验哪几个字段。我的做法是提供一个Validate(string column, object? value)方法到DynamicRow上然后用附加行为拦截DataGrid.RowEditEnding或单元格的LostFocus事件。private void OnRowEditEnding(object? sender, DataGridRowEditEndingEventArgs e) { if (e.EditAction ! DataGridEditAction.Commit) return; var row e.Row.DataContext as DynamicRow; if (row null) return; var error row.ValidateAll(); if (!string.IsNullOrEmpty(error)) { e.Cancel true; MessageBox.Show(error); } }这样校验逻辑集中在DynamicRow里ViewModel不需要知道UI细节也符合MVVM的边界划分。5.5 虚拟化与Column Span的关系如果用了动态列又想开启EnableColumnVirtualization要注意模板列的宽度和冻结列设置。列宽不固定的时候横向虚拟化容易计算出错表现是滚动到后面出现空白列。解决方法是统一给动态列设置默认宽度或者用DataGridLength.SizeToCells在列数少的时候使用。列很多且宽度不定时宁可关闭EnableColumnVirtualization保纵向虚拟化稳定优先。6. 和MVVM框架配合时的边界控制6.1 DynamicRow继承ObservableObject如果用CommunityToolkit.Mvvm直接让DynamicRow继承ObservableObject索引器里调用OnPropertyChanged就行。如果用Prism同理也可以继承BindableBase。我建议不要把动态行绑定到某个固定框架的内部类型上而是自己写一个极简的INPC实现避免框架升级带来的迁移成本。CommunityToolkit.Mvvm的ObservableObject是可继承的直接继承即可不必重复造轮子。6.2 代码后台不是洪水猛兽MVVM社区里有一种极端倾向View的代码后台一行都不能写。但DataGrid这种控件列集合本来就是UI层面的东西绑定不了就直接在后台代码里做转换是正常且合理的。我在这套方案里附加属性承载了后台逻辑但从使用方的角度看XAML里只需要一行DataGrid ItemsSource{Binding RowsView} controls:DataGridColumnsBehavior.BindableColumns{Binding Columns} /业务代码完全不用关心DataGrid.Columns怎么生成这就是MVVM的核心价值而不是代码后台零行数。6.3 列配置持久化动态列还有一个隐藏需求用户调好的列宽、顺序、显隐下次打开程序要还原。因为ColumnMeta本身是普通数据对象序列化很方便public class ColumnLayoutItem { public string BindingPath { get; set; } public double Width { get; set; } public bool Visible { get; set; } public int DisplayIndex { get; set; } }保存时遍历Columns集合写入JSON或XML加载时在ViewModel里重建ColumnMeta集合。这里是真正的MVVM甜点区因为你不需要去序列化DataGridColumn这种UI对象只需要存纯数据。我实际项目里还加了一个列配置的右键菜单切换Visible属性DataGrid的列就会即时显隐这个联动也是通过ColumnMeta的PropertyChanged驱动UI层实现的。6.4 给MVVM框架的命令传参留个口子动态列的按钮列比如每一行的详情按钮需要绑定命令命令参数通常是当前行对象。模板工厂里构造按钮时var button new FrameworkElementFactory(typeof(Button)); button.SetBinding(Button.CommandProperty, new Binding(DataContext.YourCommand) { RelativeSource new RelativeSource(RelativeSourceMode.FindAncestor, typeof(DataGrid), 1) }); button.SetBinding(Button.CommandParameterProperty, new Binding());或者更干净的方式在ColumnMeta上挂一个Command参数让Button绑定ColumnMeta.CommandCommandParameter绑定当前行。绑定源用Sourcemeta即可。这样按钮命令也能保持在ViewModel层不会散落到UI事件里。这套方案用下来的一点体会动态列在最开始看起来是个功能做完整套之后回头看它其实是数据模型与UI生成策略的适配问题。把列这个概念从DataGridColumn抽成ColumnMeta把行从固定实体类改成DynamicRow剩下的事情就是翻译和同步。我这个方案里用了附加属性、模板工厂、索引器绑定没有用到任何黑魔法所有环节都可以在调试器里一步步追踪。如果你手头只是几十行数据的临时报表直接用DataTable乱搞也行省事。但只要掺进编辑器可控、列显隐可配置、要接后端序列化、要长期维护这几个词我建议趁早切到列元数据驱动。前期多写一个ColumnMeta的代价很低后面省下的补丁时间可不止翻倍。最后再分享一个小技巧所有动态列模板统一走一个资源字典的静态类不要在自己写的每个页面里各建一套模板否则后期维护每列样式时你会想骂人。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表