编写 WPF DataGrid 列模板,实现更好的用户体验
转至:http://msdn.microsoft.com/zh-cn/magazine/gg983481.aspx
最近我在为一个客户做一些 Windows Presentation Foundation (WPF) 方面的工作。虽然我提倡使用第三方工具,但有时也会避免使用这些工具,这样做是为了体验那些坚持使用 Visual Studio 安装附带工具的开发人员会面临什么样的难题。
祝我好运吧!我们来研究一下 WPF DataGrid。即便有 Web 搜索的帮助和来自在线论坛的建议,仍然有一些用户体验问题花了我几天时间才解决。将 DataGrid 列分解为成对的互补模板对这些问题的解决发挥了很大的作用。因为这些解决方法来之不易,所以我在这里与大家分享一下。
本专栏重点讨论 WPF DataGrid 中的 WPF ComboBox 和 DatePicker 控件。
DatePicker 和新的 DataGrid 行
用户与 DataGrid 中日期列的交互给我造成了很大的麻烦。我通过将一个 Data Source 对象拖动到 WPF 窗口上,创建了一个 DataGrid。设计器的默认行为是为该对象中的每个 DateTime 值创建一个 DatePicker。例如,下面是为一个 DateScheduled 字段创建的列:
- <DataGridTemplateColumn x:Name=" dateScheduledColumn"
- Header="DateScheduled" Width="100">
- <DataGridTemplateColumn.CellTemplate>
- <DataTemplate>
- <DatePicker
- SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
- ValidatesOnExceptions=true, NotifyOnValidationError=true}" />
- </DataTemplate>
- </DataGridTemplateColumn.CellTemplate>
- </DataGridTemplateColumn>
这一默认行为对编辑造成不便。编辑时现有行不会更新。DatePicker 不会在 DataGrid 中触发编辑,这表示数据绑定功能不会将所做的更改推送至基础对象。通过向 Binding 元素添加 UpdateSourceTrigger 属性并将属性值设置为 PropertyChanged,可以解决这个特定的问题:
- <DatePicker
- SelectedDate="{Binding Path= DateScheduled, Mode=TwoWay,
- ValidatesOnExceptions=true, NotifyOnValidationError=true,
- UpdateSourceTrigger=PropertyChanged}" />
不过,添加这些新行后,DatePicker 不能触发 DataGrid 编辑模式的问题变得更加严重。在 DataGrid 中,新行由 NewRowPlaceHolder 表示。首次编辑新行中的单元格时,编辑模式会在数据源中触发插入(再次说明,不是在数据库中,而是在内存中的基础源中)。因为 DatePicker 不触发编辑模式,所以这不会发生。
因为我的行中的日期列恰好是第一列,所以我发现了这个问题。我本来想使用它来触发该行的编辑模式的。
图 1 所示为一个新行,在其中的第一个可编辑列中输入了日期。
图 1 在新行占位符中输入日期值
但在编辑下一列中的值之后,前一编辑值丢失,如图 2 所示。
图 2 修改新行中 Task 列值之后日期值丢失
第一列中的键值变为 0,刚刚输入的日期值变为 1/1/0001。编辑 Task 列最终会使 DataGrid 在数据源中添加一个新实体。ID 值变为整数(默认值 0),日期值变为 .NET 默认的最小日期 1/1/0001。如果我为此类指定过默认日期,则用户输入的日期将变为此类的默认日期,而不是 .NET 的默认日期。请注意,Date Performed 列中的日期没有更改为其默认值。这是因为 DatePerformed 是可以为 Null 的属性。
那么,现在用户是不是必须回去重新修复 Scheduled Date?我相信用户肯定不愿意这样做。这个问题困扰了我一段时间。我甚至曾将该列改成 DataTextBoxColumn,但之后我必须处理 DatePicker 起保护作用的验证问题。
最后,WPF 团队的 Varsha Mahadevanset 给我指出了正确的道路。
通过利用 WPF 的组合性质,可以对此列使用两个元素。DataGridTemplateColumn 不仅有 CellTemplate 元素,还有 CellEditingTemplate。我没有要求 DatePicker 控件触发编辑模式,而只在已经编辑时使用 DatePicker。为了在 CellTemplate 中显示日期,我切换到了 TextBlock。下面是 dateScheduledCoumn 的新 XAML:
- <DataGridTemplateColumn x:Name="dateScheduledColumn"
- Header="Date Scheduled" Width="125">
- <DataGridTemplateColumn.CellTemplate>
- <DataTemplate>
- <TextBlock Text="{Binding Path= DateScheduled, StringFormat=\{0:d\}}" />
- </DataTemplate>
- </DataGridTemplateColumn.CellTemplate>
- <DataGridTemplateColumn.CellEditingTemplate>
- <DataTemplate>
- <DatePicker SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
- ValidatesOnExceptions=true, NotifyOnValidationError=true}" />
- </DataTemplate>
- </DataGridTemplateColumn.CellEditingTemplate>
- </DataGridTemplateColumn>
请注意,我不再需要指定 UpdateSourceTrigger。我对 DatePerformed 列也进行了同样的更改。
现在,这些日期列一开始是简单文本,在您进入该单元格后才切换到 DatePicker,如图 3 所示。
图 3 DateScheduled 列同时使用 TextBlock 和 DatePicker
在新行上面的行中没有 DatePicker 日历图标。
但这还是有点不对。开始编辑这一行时仍然会得到默认的 .NET 值。这时,您就可以从在基础类中定义默认值受益。我修改了 ScheduleItem 类的构造函数,使之将新对象初始化为当天日期。如果从数据库检索到数据,它将覆盖该默认值。我的项目使用了实体框架,因此类会自动生成。不过,生成的类是分部类,这样,我就可以将此构造函数添加到附加的分部类中:
- public partial class ScheduleItem
- {
- public ScheduleItem()
- {
- DateScheduled = DateTime.Today;
- }
- }
现在,当我通过修改 DateScheduled 列开始在新行占位符中输入数据时,DataGrid 会为我创建一个新的 ScheduleItem,并且在 DatePicker 控件中显示默认值(当天日期)。现在,当用户继续编辑此行时,输入的值会继续有效。
减少用户在编辑时需要点击的次数
两部分模板的一个弊端是必须点击单元格两次才能触发 DatePicker。这会对数据输入人员造成不便,特别是对那些习惯于使用键盘输入数据而不使用鼠标的人员。因为 DatePicker 位于编辑模板中,所以除非触发编辑模式,否则它不会获得焦点(这是默认行为)。这是针对 TextBox 进行的设计,很适合 TextBox 使用。但这种设计不太适用于 DatePicker。可以结合使用 XAML 和代码来强制 DatePicker 在用户切换到该单元格时准备好键入。
首先,需要在 CellEditingTemplate 中添加一个 Grid 容器,使它成为 DatePicker 的容器。然后,可以使用 WPF FocusManager 强制此 Grid 在用户进入该单元格时成为单元格焦点。下面是作为 DatePicker 容器的新 Grid 元素:
- <Grid FocusManager.FocusedElement="{Binding ElementName= dateScheduledPicker}">
- <DatePicker x:Name=" dateScheduledPicker"
- SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
- ValidatesOnExceptions=true, NotifyOnValidationError=true}" />
- </Grid>
请注意,我为 DatePicker 控件提供了一个名称,并使用 FocusedElement Binding ElementName 指向了该名称。
现在请将注意力转到包含此 Date-Picker 的 DataGrid,注意,我添加了三个新属性(RowDetailsVisibilityMode、SelectionMode 和 SelectionUnit)和一个新的事件处理程序 (SelectedCellsChanged):
- <DataGrid AutoGenerateColumns="False" EnableRowVirtualization="True"
- ItemsSource="{Binding}" Margin="12,12,22,31"
- Name="scheduleItemsDataGrid"
- RowDetailsVisibilityMode="VisibleWhenSelected"
- SelectionMode="Extended" SelectionUnit="Cell"
- SelectedCellsChanged="scheduleItemsDataGrid_SelectedCellsChanged">
对 DataGrid 进行的更改会启用当用户选择该 DataGrid 中的新单元格进行通知的功能。最后,需要确保当用户执行此操作时 DataGrid 确实进入编辑模式,这会在 DatePicker 中向用户提供必要的光标。scheduleItemsDataGrid_SelectedCellsChanged 方法将提供这最后一部分逻辑:
- private void scheduleItemsDataGrid_SelectedCellsChanged
- (object sender,
- System.Windows.Controls.SelectedCellsChangedEventArgs e)
- {
- if (e.AddedCells.Count == 0) return;
- var currentCell = e.AddedCells[0];
- string header = (string)currentCell.Column.Header;
- var currentCell = e.AddedCells[0];
- if (currentCell.Column ==
- scheduleItemsDataGrid.Columns[DateScheduledColumnIndex])
- {
- scheduleItemsDataGrid.BeginEdit();
- }
- }
请注意,在类声明中,我将常量 DateScheduledColumnIndex 定义为 1,即该列在网格中的位置。
完成这些更改后,最终用户会很满意。我费了很大心思才找出使 DatePicker 在 DataGrid 内出色工作的正确 XAML 和代码元素组合,希望这可以帮助您避免经历同样的困难。现在,UI 以用户熟悉的方式工作了。
使受限 ComboBox 显示旧值
获取在 DataGridTemplateColumn 中对元素分层的价值之后,我再次考虑了另一个我几乎已经放弃的 DataGrid-ComboBox 列相关问题。
编写这一特定应用程序的目的是为了用旧数据替换旧应用程序。旧应用程序允许用户不经太多控制输入数据。在新应用程序中,客户端要求通过下拉列表对一些数据输入进行限制。通过使用字符串集合很容易提供下拉列表的内容。难点在于仍要显示旧数据,即使此数据不包含在新的限制列表中也是如此。
我首先尝试使用 DataGridComboBoxColumn:
- <DataGridComboBoxColumn x:Name="frequencyCombo"
- MinWidth="100" Header="Frequency"
- ItemsSource="{Binding Source={StaticResource frequencyViewSource}}"
- SelectedValueBinding=
- "{Binding Path=Frequency, UpdateSourceTrigger=PropertyChanged}">
- </DataGridComboBoxColumn>
在代码隐藏文件中定义源项:
- private void PopulateTrueFrequencyList()
- {
- _frequencyList =
- new List<String>{"",
- "Initial","2 Weeks",
- "1 Month", "2 Months",
- "3 Months", "4 Months",
- "5 Months", "6 Months",
- "7 Months", "8 Months",
- "9 Months", "10 Months",
- "11 Months", "12 Months"
- };
- }
此 _frequencyList 绑定到另一方法中的 frequencyViewSource.Source。
在无数种可能的 DataGridCombo-BoxColumn 配置中,我找不到任何办法来显示可能已经存储在数据库表的 Frequency 字段中的不同值。我就不一一列举我试过的所有解决方法了,其中一个是将这些额外的值动态添加到 _frequencyList 底部,然后根据需要删除它们。我并不喜欢这个解决方法,但恐怕我不得不接受它。
我知道编写 UI 的 WPF 分层方法必须为此提供一种机制,并且已经解决了 Date-Picker 问题,因此我意识到可以对 ComboBox 使用相似的方法。这个技巧的第一部分是避免使用华而不实的 DataGridComboBoxColumn,而是使用更经典的将 ComboBox 嵌入 DataGridTemplateColumn 内部的方法。然后,利用 WPF 的组合性质,可以像使用 DateScheduled 列一样对此列使用两个元素。第一个元素是 TextBlock,用来显示值;第二个元素是 ComboBox,用于编辑目的。
图 4 显示了同时使用这两个元素的方式。
图 4 组合使用显示值的 TextBlock 和用于编辑的 ComboBox
- <DataGridTemplateColumn x:Name="taskColumnFaster"
- Header="Task" Width="100" >
- <DataGridTemplateColumn.CellTemplate>
- <DataTemplate>
- <TextBlock Text="{Binding Path=Task}" />
- </DataTemplate>
- </DataGridTemplateColumn.CellTemplate>
- <DataGridTemplateColumn.CellEditingTemplate>
- <DataTemplate>
- <Grid FocusManager.FocusedElement=
- "{Binding ElementName= taskCombo}" >
- <ComboBox x:Name="taskCombo"
- ItemsSource="{Binding Source={StaticResource taskViewSource}}"
- SelectedItem ="{Binding Path=Task}"
- IsSynchronizedWithCurrentItem="False"/>
- </Grid>
- </DataTemplate>
- </DataGridTemplateColumn.CellEditingTemplate>
- </DataGridTemplateColumn>
TextBlock 与限制列表不存在依赖关系,因此能够显示数据库中存储的任何值。不过,在编辑时就会用到 ComboBox,输入将限制为 frequencyViewSource 中的值。
允许用户在单元格获得焦点时编辑 ComboBox
同样,因为 ComboBox 在用户在单元格中单击两次后方可使用,因此,请注意我将 ComboBox 封装在一个 Grid 中以利用 FocusManager。
考虑到用户可能通过单击 Task 单元格而不是移至第一列的方式开始新行数据输入,我修改了 SelectedCellsChanged 方法。唯一的更改是代码还检查当前单元格是否位于 Task 列中:
- private void scheduleItemsDataGrid_SelectedCellsChanged(object sender,
- System.Windows.Controls.SelectedCellsChangedEventArgs e)
- {
- if (e.AddedCells.Count == 0) return;
- var currentCell = e.AddedCells[0];
- string header = (string)currentCell.Column.Header;
- if (currentCell.Column ==
- scheduleItemsDataGrid.Columns[DateScheduledColumnIndex]
- || currentCell.Column == scheduleItemsDataGrid.Columns[TaskColumnIndex])
- {
- scheduleItemsDataGrid.BeginEdit();
- }
- }
不要忽视用户体验
我们开发人员在构建解决方案时,常常将注意力放在确保数据有效、数据流向正确以及其他方面。我们可能根本不会注意到必须点击两次才能编辑日期。但用户很快就会让您知道,您为帮助他们提高工作效率而编写的应用程序实际上降低了他们的工作效率,因为他们不得不在鼠标和键盘间反复切换。
虽然 Visual Studio 2010 的 WPF 数据绑定功能可以显著节省开发时间,但微调复杂数据网格的用户体验(特别是将数据网格与同样复杂的 DatePicker 和 ComboBox 组合使用时)将使最终用户非常受用。他们甚至不会注意到您付出的额外努力,因为应用程序的工作方式正如他们所料。然而,但这正是我们工作的乐趣之一。
Julie Lerman是 Microsoft MVP、.NET 导师和顾问,住在佛蒙特州的山区。您可以在全球的用户组和会议中看到她对数据访问和其他 Microsoft .NET 主题的演示。她是《Programming Entity Framework》(O'Reilly Media,2010)一书的作者,该书受到广泛称赞,她的博客地址是 thedatafarm.com/blog。请关注她的 Twitter:twitter.com/julielerman。
衷心感谢以下技术专家对本文的审阅:Varsha Mahadevan