简介:这份资源面向已掌握WPF基础、希望在MVVM模式下解决DataGrid动态列与编辑难题的开发者,提供一套可直接运行的示例工程。内容围绕ViewModel中维护ObservableCollection 、借助反射遍历数据模型属性动态生成列、为列配置EditingTemplate与双向绑定、以ICommand处理保存取消、用INotifyDataErrorInfo做输入校验等关键环节展开,并涉及CellEditEnding等事件与触发器的配合,帮助读者理解界面与逻辑分离下的表格扩展思路。压缩包为rar格式,共43个文件,约88KB,以17个cs源码文件为核心,辅以xaml界面、csproj与sln工程配置、resx资源及编译输出文件,结构完整便于对照调试。目前已有2936人学习下载,适合作为动态列与编辑功能的参考实现,并可按需扩展排序、过滤、分页等能力。
1. 动态列不是黑科技,是绑定没写对
后台管理系统里最容易被产品经理一句话推翻的界面,就是那张“列不固定”的表格。今天要显示工号、姓名、部门,明天客户说再加三列考勤字段,后天又要按角色隐藏薪资列。如果你在 XAML 里手写DataGridTextColumn,每加一列就得改一次前端、重新编译、重新发版,改到第三轮基本就想砸键盘。这个资源包解决的正是这件事:在 MVVM 模式下,把 DataGrid 的列集合做成可绑定的数据源,让列的定义跟着 ViewModel 走,同时把每列的编辑模板(EditingTemplate)也一并动态化。它适合已经写过 WPF、懂基础 Binding、但被动态列和编辑态切换折磨过的从业者。核心关键词就三个:MVVM、WPF DataGrid、Dynamic Columns,外加 EditingTemplate 的落地写法。下面按“原理立住 → 代码复现 → 踩坑排查 → 进阶验证”的顺序拆。
2. 为什么原生 DataGrid 的列绑不了:从依赖属性到列集合的断层
2.1 DataGrid.Columns 为什么不是依赖属性
WPF 的DataGrid.Columns是一个普通的ObservableCollection<DataGridColumn>,它没有注册成依赖属性(DependencyProperty),所以你不能在 XAML 里写Columns="{Binding MyColumns}"。这是整个动态列方案的第一道墙。很多人第一次尝试时会写:
<DataGrid Columns="{Binding ColumnDefinitions}" />编译能过,运行时直接抛异常或者静默不生效,因为Columns不是 DP,绑定引擎根本不认。血泪经验是:WPF 里凡是集合类型的属性,先查它是不是 DP,不是 DP 就别指望直接绑。
那怎么办?常见做法有三条路:
| 方案 | 思路 | 适用场景 | 代价 |
|---|---|---|---|
| 附加属性代理 | 写一个 AttachedProperty,在回调里手动同步列集合 | 列结构变化不频繁 | 需要处理增删同步 |
| 行为(Behavior) | 用System.Windows.Interactivity挂行为 | 想保持 XAML 干净 | 多一个依赖 |
| 自定义 DataGrid 子类 | 重写列生成逻辑 | 需要深度定制 | 侵入性强 |
资源包里走的是附加属性代理这条路,因为它不引入额外依赖,纯 WPF 就能跑,而且同步逻辑可以完全收在一个静态类里,ViewModel 侧零感知。
2.2 列定义模型:把 DataGridColumn 抽象成可序列化的描述
直接绑DataGridColumn对象有个问题:DataGridColumn是 UI 元素,放在 ViewModel 里会破坏 MVVM 的分层,而且它不好序列化、不好从服务端下发。所以资源包的做法是先定义一个轻量的列描述模型:
public class ColumnDefinition { public string Header { get; set; } // 列标题 public string BindingPath { get; set; } // 绑定的属性名 public string Width { get; set; } = "Auto"; // 列宽,支持 * 和像素 public bool IsReadOnly { get; set; } // 是否只读 public string EditorType { get; set; } // 编辑器类型:Text/Combo/Date public bool IsVisible { get; set; } = true; // 是否显示 }这个模型的关键在于EditorType字段。它决定了这一列在编辑态用哪种模板:文本框、下拉框还是日期选择器。ViewModel 只需要维护一个ObservableCollection<ColumnDefinition>,增删改列就是操作这个集合,UI 层通过附加属性监听集合变化,动态生成对应的DataGridColumn。
参数说明:Width用字符串而不是DataGridLength,是为了让 ViewModel 不依赖 WPF 类型,方便从 JSON 配置或数据库读取;EditorType用字符串枚举而不是直接塞DataTemplate,同样是为了解耦,模板的映射关系放在 View 层的资源字典里。
2.3 附加属性的同步时机:CollectionChanged 与 Loaded 的配合
附加属性的回调里要做两件事:首次绑定时全量生成列,集合变化时增量同步。这里有个时序坑——DataGrid还没加载完时Columns集合可能为空,直接操作会丢列。资源包的处理是在回调里判断dataGrid.IsLoaded,没加载完就挂一个一次性的Loaded事件,加载后再同步。
public static class DataGridColumnsBehavior { public static readonly DependencyProperty ColumnsSourceProperty = DependencyProperty.RegisterAttached( "ColumnsSource", typeof(IEnumerable<ColumnDefinition>), typeof(DataGridColumnsBehavior), new PropertyMetadata(null, OnColumnsSourceChanged)); private static void OnColumnsSourceChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not DataGrid grid) return; // 先解绑旧集合,避免内存泄漏 if (e.OldValue is INotifyCollectionChanged oldCollection) oldCollection.CollectionChanged -= OnCollectionChanged; if (e.NewValue is not IEnumerable<ColumnDefinition> source) return; // 未加载完成时延迟到 Loaded 再生成 if (!grid.IsLoaded) { RoutedEventHandler? handler = null; handler = (_, _) => { grid.Loaded -= handler; RebuildColumns(grid, source); }; grid.Loaded += handler; } else { RebuildColumns(grid, source); } if (source is INotifyCollectionChanged newCollection) newCollection.CollectionChanged += OnCollectionChanged; } private static void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { // 增量同步逻辑见 3.2 } private static void RebuildColumns(DataGrid grid, IEnumerable<ColumnDefinition> source) { grid.Columns.Clear(); foreach (var def in source) grid.Columns.Add(CreateColumn(def)); } }逻辑说明:OnColumnsSourceChanged是附加属性的变更回调,负责解绑旧集合、延迟加载、全量重建三件事。RebuildColumns里先Clear再逐列Add,这是最简单也最不容易出错的同步方式,列数量在几十列以内性能完全够用。参数上,ColumnsSource的类型用IEnumerable<ColumnDefinition>而不是ObservableCollection,是为了兼容数组、List 等只读场景,只有实现了INotifyCollectionChanged的集合才会走增量同步。
3. 把列定义变成真实列:工厂方法、编辑模板与增量同步
3.1 列工厂:根据 EditorType 生成不同列类型
CreateColumn是核心工厂方法,它根据ColumnDefinition的字段决定生成DataGridTextColumn、DataGridComboBoxColumn还是DataGridTemplateColumn。资源包的做法是:只读列用DataGridTextColumn,可编辑且需要自定义编辑器的列用DataGridTemplateColumn,因为只有模板列才能同时控制显示模板和编辑模板。
private static DataGridColumn CreateColumn(ColumnDefinition def) { // 只读列:直接文本列,性能最好 if (def.IsReadOnly) { return new DataGridTextColumn { Header = def.Header, Binding = new Binding(def.BindingPath), Width = ParseWidth(def.Width), IsReadOnly = true }; } // 可编辑列:模板列,显示态和编辑态分开 var templateColumn = new DataGridTemplateColumn { Header = def.Header, Width = ParseWidth(def.Width), CellTemplate = BuildDisplayTemplate(def), CellEditingTemplate = BuildEditingTemplate(def) }; return templateColumn; } private static DataGridLength ParseWidth(string width) { // 支持 "Auto"、"*"、"120" 三种写法 if (string.IsNullOrWhiteSpace(width)) return DataGridLength.Auto; if (width == "*") return new DataGridLength(1, DataGridLengthUnitType.Star); if (width.Equals("Auto", StringComparison.OrdinalIgnoreCase)) return DataGridLength.Auto; return double.TryParse(width, out var px) ? new DataGridLength(px) : DataGridLength.Auto; }逻辑说明:CreateColumn先按IsReadOnly分流,只读列走轻量的文本列,避免模板列带来的额外视觉树开销。ParseWidth把字符串宽度转成DataGridLength,支持星号比例、自动和固定像素三种,这是后台表格最常用的宽度策略。参数上,CellTemplate负责非编辑态显示,CellEditingTemplate负责双击进入编辑态后的控件,两者必须成对出现,否则编辑态会退化成默认文本框。
3.2 编辑模板的构建:用 FrameworkElementFactory 还是 XamlReader
构建DataTemplate有两种写法:代码里用FrameworkElementFactory拼,或者用XamlReader.Parse解析 XAML 字符串。前者类型安全但代码冗长,后者灵活但要注意 XAML 命名空间。资源包用的是FrameworkElementFactory,因为它在纯代码环境下不依赖字符串拼接,调试时断点能直接打到。
private static DataTemplate BuildEditingTemplate(ColumnDefinition def) { var factory = new FrameworkElementFactory(typeof(TextBox)); factory.SetBinding(TextBox.TextProperty, new Binding(def.BindingPath) { UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, ValidatesOnExceptions = true }); // 下拉框场景:EditorType 为 Combo 时替换控件 if (def.EditorType == "Combo") { factory = new FrameworkElementFactory(typeof(ComboBox)); factory.SetBinding(ComboBox.SelectedValueProperty, new Binding(def.BindingPath) { UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }); factory.SetValue(ComboBox.ItemsSourceProperty, new Binding("DataContext.Options") // 选项来自行数据上下文 { RelativeSource = RelativeSource.Self }); } return new DataTemplate { VisualTree = factory }; }逻辑说明:FrameworkElementFactory的第一个参数决定编辑控件类型,SetBinding把控件的关键属性绑到行数据的BindingPath上。UpdateSourceTrigger.PropertyChanged是关键参数——默认的LostFocus会导致用户还在输入时 ViewModel 拿不到最新值,动态列场景下经常需要实时校验,所以必须改成即时更新。下拉框的ItemsSource绑定用了RelativeSource.Self指向行数据上下文,这样每行可以有独立的选项列表,适合“部门-岗位”这种级联场景。
3.3 增量同步:CollectionChanged 里只处理 Add 和 Remove
全量重建在列少时没问题,但列多、数据量大时每次增删一列都Clear再重建,会触发整个表格重新测量布局,滚动位置也会丢。资源包的增量同步只处理Add和Remove两种动作,Reset才走全量重建。
private static void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (sender is not ObservableCollection<ColumnDefinition> source) return; // 需要拿到关联的 DataGrid,实际实现里通过字典或附加属性反查 var grid = ResolveGrid(source); if (grid == null) return; switch (e.Action) { case NotifyCollectionChangedAction.Add: for (int i = 0; i < e.NewItems!.Count; i++) { var def = (ColumnDefinition)e.NewItems[i]!; grid.Columns.Insert(e.NewStartingIndex + i, CreateColumn(def)); } break; case NotifyCollectionChangedAction.Remove: foreach (var _ in e.OldItems!) grid.Columns.RemoveAt(e.OldStartingIndex); break; case NotifyCollectionChangedAction.Reset: RebuildColumns(grid, source); break; } }逻辑说明:Add时按NewStartingIndex插入,保证列顺序和 ViewModel 集合一致;Remove时按OldStartingIndex逐个删,注意RemoveAt后索引会前移,所以循环里不能再用原索引。Reset是Clear()触发的,走全量重建兜底。参数上,ResolveGrid的反查在实际项目里通常用一个ConditionalWeakTable或附加属性存反向引用,避免 ViewModel 直接持有 View 引用。
4. 动态列最容易翻车的五个地方:绑定、模板与性能排查
4.1 现象:列出来了但编辑后值不回写
原因:CellEditingTemplate里的控件绑定没设UpdateSourceTrigger,默认LostFocus,而 DataGrid 在提交编辑时可能先切走焦点,导致绑定没触发。解决:编辑模板里所有输入控件的绑定统一加UpdateSourceTrigger=PropertyChanged,并在DataGrid上设RowEditEnding时手动CommitEdit。
4.2 现象:动态列一多,滚动卡顿明显
原因:每列都用DataGridTemplateColumn且CellTemplate里套了复杂布局,视觉树膨胀。解决:只读列一律用DataGridTextColumn,模板列只留给真正需要编辑的列;同时给DataGrid开EnableRowVirtualization="True"和VirtualizingPanel.VirtualizationMode="Recycling",这两项在动态列场景下收益最直接。
4.3 现象:切换列集合后旧列还在,或者列重复
原因:附加属性变更时只加了新列没清旧列,或者CollectionChanged和Loaded回调重复触发。解决:在OnColumnsSourceChanged里先解绑旧集合事件,RebuildColumns开头强制Columns.Clear();延迟加载的Loaded处理器用完即摘,避免多次触发。
4.4 现象:下拉列编辑时选项串行
原因:ComboBox.ItemsSource绑到了全局单例集合,所有行共享同一份选项。解决:选项集合挂在行数据模型上,编辑模板里用RelativeSource指向行上下文;如果选项是全局的,用x:Static或资源引用,不要绑到 ViewModel 单例上再被多行复用。
4.5 现象:列宽设了星号但实际不生效
原因:DataGridLength的星号比例需要父容器有确定宽度,如果DataGrid放在StackPanel或ScrollViewer里且没约束宽度,星号会退化成Auto。解决:确保DataGrid的父容器是Grid或DockPanel并给了*行高列宽,或者直接给DataGrid设HorizontalAlignment="Stretch"。
提示:动态列调试时,先在
RebuildColumns里打一行日志输出实际生成的列数和类型,比在 XAML 里猜绑定快得多。
5. 进阶验证:用配置驱动列、用单元测试锁住同步逻辑
动态列方案上线前,我一般会做两件事:一是把列定义从硬编码挪到外部配置,验证“不改代码加列”这条链路真的通;二是给附加属性的同步逻辑写单元测试,因为这部分代码在 UI 线程里跑,出问题最难复现。
配置驱动的做法是把ColumnDefinition集合序列化成 JSON,启动时反序列化后赋给ColumnsSource。验证点是:改 JSON 加一列、重启、列出现且能编辑回写,三步都过才算落地。
// 从配置加载列定义,验证动态列链路 var json = File.ReadAllText("columns.json"); var defs = JsonSerializer.Deserialize<List<ColumnDefinition>>(json); viewModel.Columns = new ObservableCollection<ColumnDefinition>(defs!);单元测试则针对ParseWidth和CreateColumn这类纯函数,用[Theory]覆盖Auto、*、120、空串四种输入,断言返回的DataGridLengthUnitType正确。同步逻辑本身依赖DataGrid,不好直接测,我的习惯是把它抽成一个不依赖 UI 的ColumnSyncService,输入旧集合和新集合,输出增删指令列表,再在附加属性里执行指令。这样测试覆盖的是“该加哪列、该删哪列”,而不是 WPF 的视觉树。
| 验证项 | 方法 | 通过标准 |
|---|---|---|
| 配置加列 | 改 JSON 重启 | 新列出现且可编辑 |
| 列顺序 | 调整 JSON 顺序 | UI 列顺序一致 |
| 只读列 | IsReadOnly=true | 双击不进编辑态 |
| 星号宽度 | 窗口拉伸 | 列按比例伸缩 |
| 集合清空 | Columns.Clear() | 表格列清空不报错 |
最后说个习惯:从那以后我每次做动态列,都先把“列定义模型 → 工厂方法 → 附加属性同步”这三段拆成独立文件,任何一段出问题都能单独替换验证,不会一改就牵动整个表格。希望帮到你。
本文还有配套的精品资源,点击获取