简介:面向C# WPF开发者的DataGrid单元格双击编辑示例文档,解决原生DataGrid不支持双击单元格直接编辑的问题,采用Xceed.Wpf.DataGrid库实现,适合需要丰富表格交互和自定义编辑器的桌面应用开发者。包体为单个docx文件,大小22KB,内含完整源码与讲解,文件虽小但涵盖项目背景、Demo预览、代码结构等关键内容。资源已有290人学习,代码遵循MVVM模式,划分为Views、Models、MainWindowViewModel、Converters等模块,清晰展示如何绑定数据、创建列并响应CellEditEnding事件。通过DataTemplate和DefaultCellEditors为枚举、浮点、布尔、DateTime等不同类型定制编辑器,帮助读者掌握在WPF中实现灵活双击编辑及数据保存的完整思路。
1. 先想清楚:为什么双击编辑一个 Cell 还要绕这么大一圈
做上位机或工控界面的朋友应该都有过这种经历:产线上的配方编辑页面,用户不想打开弹窗,也不想在属性格里来回切换,就想双击 DataGrid 里的某个格子直接改,改完按回车数据落库。这个诉求听起来很简单,但原生的 WPF DataGrid 默认只支持 F2 进入编辑,双击的行为通常被选中单元格吃掉;就算你把 IsReadOnly 设成 False,弹出的还是那个没有类型感知的 TextBox。枚举要下拉、布尔要勾选、DateTime 要日期选择器,原生控件并不会自动帮你换编辑器。
Xceed.Wpf.DataGrid 解决的是“单元格编辑状态机”这件事。它把 Cell 的显示、编辑、提交拆分成了不同的状态,并提供 DefaultCellEditors 这类扩展入口,你只要把编辑器模板挂到对应类型上,双击进入编辑时就会自动匹配。这个 Demo 用的正是 2.5.0.0 版本,配合 MVVM 结构,把枚举、浮点、布尔、DateTime 四种类型塞进了同一个 DataGridControl。适合谁?适合那些已经被原生 DataGrid 的编辑体验逼疯、又想保留表格整体交互的 C# WPF 开发。
2. 先看懂 Xceed DataGridControl 的编辑触发链路
2.1 原生双击为什么不好使:Cell 的编辑状态机差异
原生 WPF DataGrid 的编辑入口绑定在DataGridCell的IsEditing状态上,外部触发路径主要是 F2、单击后再次单击、或者代码里调用BeginEdit()。双击的时候,第一次点击已经完成了焦点切换和选中,第二次点击被解释成“检查是否进入编辑”,但它的默认逻辑并不判断数据类型的编辑器匹配,而是统一走DataGridTextColumn的 TextBox,所以你看到的永远是文本框,而不是翻转为自定义控件。
而 Xceed 的DataGridControl在架构上把 Cell、Row、Editor 三者拆开了。SelectionUnit="Cell"与SelectionMode="Single"只决定选择动作,编辑动作由CellEditor和EditTemplate接管。当用户双击一个 Cell 时,控件内部会:
- 根据 Cell 的
DataType去DefaultCellEditors里找对应注册的CellEditor; - 如果找到,就把当前 Cell 的值重新打包成
CellEditorBinding上下文; - 用
EditTemplate里的 DataTemplate 替换掉 DisplayTemplate,完成编辑态切换。
所以想要双击生效,不是去给 DataGrid 写MouseDoubleClick事件然后手动塞控件,而是把DefaultCellEditors配好。事件模式的问题在于你永远需要手工管理焦点和值回写,而 Xceed 的编辑器模式自带提交与取消语义。
<xceed:DataGridControl x:Name="DataGridControl" AutoCreateColumns="False" SelectionUnit="Cell" SelectionMode="Single" ItemsSource="{Binding Source={StaticResource recipeData}}"> <xceed:DataGridControl.DefaultCellEditors> <xceed:CellEditor Key="{x:Type models:SmartCellViewModel}"> <xceed:CellEditor.EditTemplate> <DataTemplate> <views:SmartCellEditor Content="{xceed:CellEditorBinding}" VerticalAlignment="Center"/> </DataTemplate> </xceed:CellEditor.EditTemplate> </xceed:CellEditor> </xceed:DataGridControl.DefaultCellEditors> </xceed:DataGridControl>这里最值得注意的参数是Key,它不绑定具体类型,而是绑定SmartCellViewModel。也就是说这个编辑器不是给某一个数据类型专用的,而是所有希望走进这套逻辑的 Cell 都共用同一入口,再由编辑器内部按值类型路由到具体模板。xceed:CellEditorBinding是 Xceed 版的双向绑定标记,和普通 Binding 的区别在于它额外感知 Cell 的编辑状态:只有处于编辑状态时才把值写给编辑器,编辑确认后才把编辑器的值回写到数据源。
2.2 让 DataGrid 的 View 属性配合编辑行为
DefaultCellEditors决定了双击之后弹什么,而 View 决定双击前 Cell 长什么样、坐标轴怎么排。Demo 里用的是TableflowView,有三个属性必须和编辑配合理解。
<xceed:TableflowView FixedColumnCount="1" ContainerHeight="30" UseDefaultHeadersFooters="False"> <xceed:TableflowView.FixedHeaders> <DataTemplate> <xceed:ColumnManagerRow AllowColumnReorder="False" AllowColumnResize="True" AllowSort="False"/> </DataTemplate> </xceed:TableflowView.FixedHeaders> </xceed:TableflowView>FixedColumnCount的作用是锁定左侧 N 列不参与横向滚动。Demo 中第一列是配方变量名称,锁住之后用户横向滚动后面的 Step 列时,依然能看清当前行是哪条变量。这里容易踩坑:如果把FixedColumnCount设置的比实际列数还大,编辑器在滚动时会错位,表现是双击某个 Cell 弹出来的编辑器出现在相邻列。ContainerHeight设置的是每行高度,注意SmartCellEditor在模板里绑定了父级Border的ActualHeight,所以行高直接决定编辑器高度,修改行高后编辑器会自动跟随,不用在编辑器里写死尺寸。
UseDefaultHeadersFooters="False"是为了配合自定义的ColumnManagerRow。既然AllowSort=False和AllowColumnReorder=False,用户就不能通过点击列头排序或拖拽列位置,这对配方类表格很重要,因为 Step 列的顺序是有业务含义的,一旦被用户拖乱,保存的数据顺序就错了。
2.3 UpdateSourceTrigger 与 CellContentChanged 的关系
Xceed 的绑定时机和原生 WPF 不同。原生 DataGrid 在CellEditEnding时才把文本提交给属性,而 Xceed 的UpdateSourceTrigger="CellContentChanged"是把编辑确认的时机前移到了 Cell 内容变化的瞬间。
<xceed:DataGridControl UpdateSourceTrigger="CellContentChanged" ItemsPrimaryAxis="Horizontal" PagingBehavior="LeftToRight">ItemsPrimaryAxis="Horizontal"表示项目的排布主方向是水平方向,这让 DataGrid 更适合动态列的场景。理解这个属性的意义在于:如果你沿用原生 DataGrid 的“行主方向”思维,会想不通为什么单元格编辑器能在一行内多次触发。在这个布局下,数据源里每一项映射的是表格里的一行,而 Step 列是在运行时动态追加到行集合上的。
CellContentChanged的副作用是编辑器内容一变就会更新数据源,所以编辑过程中如果用户按了 Esc,是不应该回写脏数据的。Xceed 在这一点上的处理是:Esc 走的是Cancel路径,不会把编辑器的值交还给绑定源,而回车或失焦走的是Commit路径,此时CellContentChanged才真正生效。所以如果你的业务要求“编辑过程中不做异步校验”,这个默认行为是对的;但如果你需要在输入文字时就实时计算总和,那可以监听CellEditor的ContentChanged事件。
3. SmartCellEditor 是怎么做到一个编辑器适配四种类型的
3.1 为什么所有编辑器收敛到一个 UserControl
有经验的 WPF 开发者看到这里,第一反应大概率是“每种类型建一个 CellEditor 不行吗,为什么非要搞一个 SmartCellEditor 包一层?”没毛病,单独注册确实简单,但实际做下来有很别扭的问题:Xceed 的CellEditor注册表是按类型键匹配的,枚举、浮点、布尔、DateTime 四种类型要注册四个xceed:CellEditor,每个模板里复制一遍布局容器、边框、焦点样式。一旦表格列多了,XAML 里会堆出大量重复模板,后期改一个全局对齐方式要动多处。
这个 Demo 的做法是用一个SmartCellViewModel把所有类型统一包一层。CellEditor只注册一次,EditTemplate里只放一个SmartCellEditor用户控件,由这个控件根据值实际类型去路由到 ComboBox、TextBox、CheckBox 或者 DatePicker。这样做的好处有三点:
- 双击进入编辑时焦点管理集中在一个 UserControl 的代码里,不会出现类型编辑器各自抢焦点的问题;
- 显示时用的是
Display属性,编辑时用的是值类型属性,两者的转换逻辑可以收拢在 ViewModel 中; - 后续要加一种新类型(比如数值范围),只需在
SmartCellEditor中新增一个模板分支,主 XAML 不用动。
至于为什么 Key 写的是models:SmartCellViewModel而不是System.Double,因为 Xceed 在查找编辑器时用的键是Cell的数据类型。原生类型一堆,你不可能为 double、float、decimal 各写一份模板,包一层自定义类型作为键更符合实际的项目组织方式。
3.2 SmartCellViewModel 的内容分类与路由逻辑
这个类承担了“当前 Cell 是什么、编辑完怎么写回”的职责。在 MVVM 结构里,它是 View 和 Model 之间的适配层,不直接持有RecipeControlVariable引用,而是把值、类型、显示文本拆成三个字段。
public class SmartCellViewModel : INotifyPropertyChanged { private object _rawValue; private string _displayText; private EditorKind _editorKind; public object RawValue { get => _rawValue; set { _rawValue = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsBoolEditor)); OnPropertyChanged(nameof(IsEnumEditor)); OnPropertyChanged(nameof(IsDateTimeEditor)); } } public string DisplayText { get => _displayText; set { _displayText = value; OnPropertyChanged(); } } public EditorKind CurrentEditorKind { get => _editorKind; set { _editorKind = value; OnPropertyChanged(); } } public bool IsBoolEditor => CurrentEditorKind == EditorKind.Bool; public bool IsEnumEditor => CurrentEditorKind == EditorKind.Enum; public bool IsDateTimeEditor => CurrentEditorKind == EditorKind.DateTime; }这里的EditorKind是一个枚举,在赋值RawValue的时候同时确定。典型做法是:如果值是Enum类型,走枚举分支;bool走布尔分支;DateTime走日期分支;float/double/decimal统一走数值分支。枚举判断要特别小心:C# 里所有枚举的底层类型都是int或其他数值类型,直接value is Enum判断会拦截一部分意图,所以正确做法是先判Enum,再判数值类型,顺序反了会把所有枚举当成int处理。
IsBoolEditor这些计算属性是为了给DataTrigger用的。在SmartCellEditor的UserControl.Resources里,用一个ContentControl承载编辑器实例,不同的触发器切换ContentTemplate:
<ContentControl Content="{Binding}"> <ContentControl.Style> <Style TargetType="ContentControl"> <Setter Property="ContentTemplate" Value="{StaticResource FloatEditorTemplate}"/> <Style.Triggers> <DataTrigger Binding="{Binding IsEnumEditor}" Value="True"> <Setter Property="ContentTemplate" Value="{StaticResource EnumEditorTemplate}"/> </DataTrigger> <DataTrigger Binding="{Binding IsBoolEditor}" Value="True"> <Setter Property="ContentTemplate" Value="{StaticResource BoolEditorTemplate}"/> </DataTrigger> </Style.Triggers> </Style> </ContentControl.Style> </ContentControl>为什么不用DataTemplateSelector?数据模板选择器当然可以,但它拿不到控件的视觉树上下文,做焦点处理时需要走附加属性钩子,写起来很绕。DataTrigger方案的好处是写 XAML 就能完成分支逻辑,后面做单元测试时可以直接给SmartCellViewModel塞不同值类型断言计算属性,不用启动 UI。代价是SmartCellViewModel每次编辑都要重新创建,这对 WPF 的INotifyPropertyChanged机制来说并不是问题,反而保证了上一个 Cell 的编辑器状态不会残留到下一个 Cell。
3.3 编辑器模板与行高联动
SmartCellEditor的 XAML 中有一个关键操作:Height绑定到祖先Border的ActualHeight。这保证编辑器单元格高度和显示模式下完全一致,不会出现双击后编辑器“跳一下”的视觉抖动。很多第三方编辑控件在进入编辑态时会默认使用输入法候选框高度或控件默认高度,导致容器高度突变,在密集表格中体验很差。
<views:SmartCellEditor Height="{Binding ActualHeight, RelativeSource={RelativeSource AncestorType={x:Type Border}, AncestorLevel=1}}"/>这个绑定的逻辑是:向上找第一层Border,取它当前的实际渲染高度作为编辑器的高度。AncestorLevel=1的意思是只向上跨一层,如果模板内还有嵌套 Border,就要根据实际层级调整数值。常见错误是模板外包了Grid,于是编辑器高度被Grid重新计算,导致绑定丢失,所以项目中所有外层容器统一用Border。
编辑器内部的 ComboBox、DatePicker 不建议设置固定高度,用Stretch让它跟随内容即可。浮点数编辑器用TextBox时要把InputScope设置成数字键盘,工控触摸屏环境下这个细节很重要,否则软键盘会默认英文布局,用户输入小数点非常费劲。
4. MVVM 下的动态列生成与数据提交
4.1 RecipeControlVariable 模型的设计边界
Models 层里的RecipeControlVariable是这个 Demo 的数据根。它至少包含两个显示层面的字段:DisplayName用于行头显示,Value用于当前值。但 Xceed 的编辑链路并不直接绑定Value,因为 Value 类型是变化的,直接暴露给用户控件会导致类型转换散落在 View 层。
public class RecipeControlVariable : INotifyPropertyChanged { private string _displayName; private object _value; public string DisplayName { get => _displayName; set { _displayName = value; OnPropertyChanged(); } } public object Value { get => _value; set { _oldValue = _value; _value = value; OnPropertyChanged(); } } public object OldValue { get; private set; } public string Display { get { if (_value is Enum enumValue) return GetEnumDisplayName(enumValue); return _value?.ToString() ?? string.Empty; } } }Display属性是假的显示字段:行头模板直接绑定DisplayName,单元格显示模板绑定Display。这样在编辑状态下切换模板时,原来的显示文本不会因为绑定对象变化而丢失。OldValue在这个类里承担的是“回滚”任务,用户按 Esc 时把Value还原为OldValue,这是原生 DataGrid 经常被忽略的问题。
注意这里Value用object而不是泛型。如果强行把RecipeControlVariable定义成泛型RecipeControlVariable<T>,在集合绑定上会非常痛苦:Xceed 的DataGridCollectionViewSource需要明确的元素类型才能做列自动生成,动态列场景下类型不统一会直接报绑定错误。所以往集合里放一个非泛型基类、实际值用object承载是最稳的做法。
4.2 MainWindowViewModel 的 Step 列追加逻辑
动态列有一个很容易误解的点:Step 列不是定义在 XAML 里的,而是在运行时根据配方工艺步骤数量往RecipeVariables集合里追加的。Xceed 的AutoCreateColumns="False"意味着不会根据数据源自动生成列,那么这些 Step 列到底从哪来?
答案是:MainWindowViewModel中维护一个列定义集合,并在运行时把它转换成Column对象注册到DataGridControl.Columns。每个 Step 列是一个独立的Column,它的FieldName对应模型上的某个属性路径,比如Step3.Value,这样SmartCellEditor才能正确地读取值。
private void AddStepColumns(int stepCount) { for (int i = 1; i <= stepCount; i++) { var column = new Column { FieldName = $"Step{i}.Value", Title = $"Step {i}", Width = new GridLength(120) }; Columns.Add(column); } }这里FieldName用点号路径是 Xceed 的一个特性,它支持属性链绑定。加了Step3.Value之后,绑定引擎会自动定位到当前行数据对象上的Step3属性,再取Value。这一层的组装无法在 XAML 里用DataGridTextColumn声明完成,因为步骤数量是运行时才知道的,所以AddStepColumns方法需要放在 ViewModel 里,并通过ObservableCollection<Column>暴露给 View。
ObservableCollection的线程亲和性要留意:如果步骤数目是从后台线程加载的,Columns.Add必须通过Dispatcher回到 UI 线程,否则 WPF 会抛出“调用线程无法访问此对象”的异常。我一般把AddStepColumns设计成幂等操作,先清空旧列再添加新列,避免重复调用时列叠加。
4.3 DataGridCollectionViewSource 与数值回写
View 层用xceed:DataGridCollectionViewSource包裹RecipeVariables,主要是为了让 Xceed 控件拿到行级编辑状态。它和原生CollectionViewSource的区别在于:Xceed 版额外支持单元格级缓存和确认逻辑,编辑状态下值暂存在内部缓存,确认后才写回源集合,这也是UpdateSourceTrigger能生效的基础。
编辑完成后,数据不会自动保存到数据库,ViewModel 里还要提供Save()方法,遍历所有变量把它们的Value拿出来做业务处理。这里有意不把 Save 放到SmartCellEditor里——编辑器不该知道持久化细节,它只负责把界面改动转换成属性变更。保存按钮或失焦时统一调Save(),这样如果中途某一行校验失败,能定位到是哪个变量出错。
public void Save() { foreach (var variable in RecipeVariables) { if (!Validate(variable)) continue; PersistStep(variable.DisplayName, variable.Value); } }这种做法把“编辑体验”和“持久化策略”解耦,后续如果要从数据库加载第二步的完成时间,只需再写一个Load()方法把时间塞回Step对象,编辑器部分完全不用动。
5. 最后这一层:把编辑器的边界摸清楚
5.1 按 Tab 跨列时的提交行为差异
CellContentChanged模式下,双击编辑、输入新值、再按 Tab 跳到下一格,这个过程中编辑器是先执行提交还是先执行失焦?Xceed 的处理顺序是:先提交当前编辑器的值,再移动焦点。这意味着你把编辑器做成了带延时效果的控件(比如 ComboBox 的动画收起),提交时机可能和视觉动画错位。
如果遇到按 Tab 后数据没更新的问题,先检查定义编辑器时是否加了Focusable。CellEditor的EditTemplate根节点必须是可聚焦的,否则 Tab 键会直接跳过当前编辑器,事件顺序变成“先移动焦点,再尝试提交”,最后数据源收到的还是旧值。
5.2 快速定位编辑器焦点丢失的技巧
双击进入编辑后,如果编辑器里输入文字时频闪或光标不出现,多半是外层容器抢走了焦点。定位方法:在SmartCellEditor里加一个临时断点或输出日志,打印Keyboard.FocusedElement。如果它不是你的输入控件,说明模板中某个元素把Focusable设成了默认值且层级更高。
protected override void OnLoaded(object sender, RoutedEventArgs e) { var textBox = FindVisualChild<TextBox>(this); textBox?.Focus(); textBox?.SelectAll(); }SelectAll()是双击编辑比较重要的细节:用户双击某个 Cell 后,大概率是想直接覆盖输入,而不是把光标移到末尾一点点删。所有文本类编辑器都建议做这一步。FindVisualChild用VisualTreeHelper遍历可视子树,注意ComboBox内部的文本框是ComboBoxTemplate生成的,要用ApplyTemplate()先让模板生效再查找。
5.3 2.5.0.0 版本兼容性验证清单
下表是本项目在 2.5.0.0 下验证时最容易出问题的点:
| 检查项 | 验证结果 | 说明 |
|---|---|---|
xceed:CellEditor Key类型匹配 | 必须绑定包装类 | 直接绑System.Double会覆盖框架默认数值编辑器 |
AncestorLevel层级 | 1 或 2 | 模板外层有嵌套 Border 时要调高 |
FixedColumnCount与锁定列 | 等于列数时表现正常 | 设太大会出现编辑器错位 |
| 按 Esc 取消编辑 | 数据不写回 | 依赖 StdCellEditor 的 Cancel 语义 |
| 数值列输入小数点 | 需要处理区域设置 | 中文环境下CultureInfo会影响double.TryParse |
区域设置是最隐蔽的一个坑:CultureInfo默认逗号是千分位分隔符,如果用户输入“1,5”而解析逻辑在CurrentCulture下执行,会得到一个错误值。建议在SmartCellViewModel中对double统一用InvariantCulture解析,这样无论系统区域怎么变,编辑器的行为是一致的。
视线再回到最初的需求:双击编辑、保存数据、支持多类型。Project 里真正值得复用的不是某个具体模板,而是“显示模板 + CellEditor + 包装 ViewModel”这套三层结构。它把编辑器的关注点从“怎么捕获双击”转移到了“编辑时怎么呈现”,这才是用 Xceed 做 DataGrid 的正确打开方式。
本文还有配套的精品资源,点击获取