WPF PropertyGrid - MVVM 技术






4.86/5 (10投票s)
如何构建一个多列 ListView,使其能够根据行数据类型选择单元格模板;以及如何为每个单元格模板动态创建 ViewModel。
引言
WPF 中有两个常见问题:基于数据类型为 ListView
列选择数据模板,以及实现将数据格式化为 UI 可以绑定的内容的胶水逻辑。
本文介绍了解决这两个问题的方法;然而,重要的是解决思路,而非解决方案本身。WPF 的范式截然不同,希望这个例子能鼓励 UI 设计中更具创新性的思考。
假定您已了解 Model-View-ViewModel (MVVM) 模式;如果您之前没有接触过该模式,我强烈建议您阅读 维基百科上关于 MVVM 的文章或 John Gossman 关于 Model-View-ViewModel 的原创博文。
背景:多列 ListViews
多列 ListView
的 View
属性设置为 GridView
的实例,如下面的代码所示:
<ListView.View>
<GridView>
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}"/>
<GridViewColumn Header="Value" Width="200" />
</GridView>
</ListView.View>
由于我们要构建一个属性网格,所以希望第二列根据属性的类型显示不同种类的控件。
GridViewColumn
有两个相关的属性:CellTemplate
和 CellTemplateSelector
。然而,两者似乎都存在障碍:
CellTemplate
只允许您指定一个模板,该模板将用于所有行;CellTemplateSelector
要求您编写一个模板选择器类。
编写模板选择器似乎特别不必要,因为 WPF 已经有一个很好的机制可以根据数据类型选择数据模板。
根据数据选择单元格模板
解决此问题的方法是认识到 CellTemplate
不必呈现单元格本身的内容。诀窍在于使用 CellTemplate
来承载 ContentPresenter
,而 ContentPresenter
实际上会选择合适的数据模板!
<GridViewColumn Header="Value" Width="200">
<GridViewColumn.CellTemplate>
<DataTemplate>
<!-- The cell template is a ContentPresenter, hence the actual
template will be selected by type. -->
<ContentPresenter Content="{Binding}"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
请注意,ContentPresenter
的 Content
属性绑定到 CellTemplate
的数据上下文。因此,CellTemplate
在其 DataContext
设置为正在呈现的行的情况下被实例化,然后它只是将行传递给 ContentPresenter
,后者选择合适的数据模板。
只要为每种行类型定义了数据模板,Value 列现在就会生成特定于该行的内容。因此,让我们定义两个数据模板,一个用于 TextItem
,一个用于 ColorItem
:
<DataTemplate DataType="{x:Type clr:TextItem}">
<TextBox Text="{Binding Text}"/>
</DataTemplate>
<DataTemplate DataType="{x:Type clr:ColorItem}">
<ComboBox />
</DataTemplate>
我希望 ComboBox
显示颜色值的下拉列表,但不幸的是,我的 ColorItem
类是这样的:
[Serializable]
public class ColorItem : ItemBase
{
// Inherited from the base class:
// public string Name { get; set; }
public float Red { get; set; }
public float Green { get; set; }
public float Blue { get; set; }
}
我可以使用 IValueConverter
的实现将 ColorItem
转换为 System.Windows.Media.Color
结构。但是,当选择更改时,如何将其转换回 ColorItem
?特别是,我无法将 Name
属性存储在 Color
结构中,因此没有足够的信息来执行反向转换。
ViewModel 应用
最理想的情况是将每个 ColorItem
包装在一个 ViewModel 中,该 ViewModel 可以执行诸如从 RGB 值转换为 Color
并返回等智能操作。存在两个问题:
- 列表中的并非所有项都是
ColorItem
; - 我们希望避免修改原始列表。
需要的是类似这样的逻辑:“如果该项是 ColorItem
,则创建 ColorItemViewModel
并将 DataTemplate
绑定到它。”那么,我们如何为每个 ColorItem
数据模板生成 ColorItemViewModel
呢?
这可能并不明显,但在 ResourceDictionary
中创建任意类的实例是可能的。
<DataTemplate.Resources>
<clr:ColorItemViewModel x:Key="ViewModel" />
</DataTemplate.Resources>
这就解决了一半问题。每次实例化 DataTemplate
时,都会在其资源字典中创建一个 ColorItemViewModel
实例。现在,问题是如何将其与实际数据连接起来。大胆猜测一下:
<DataTemplate.Resources>
<clr:ColorItemViewModel x:Key="ViewModel" Item="{Binding}" />
</DataTemplate.Resources>
难道就这么简单吗?起初我以为是这样——但事实证明我错了。以我使用的方式进行绑定需要 DataContext
。即使我的 ViewModel 有 DataContext,ResourceDictionary
也没有,因此没有继承链可以让 ViewModel 获取正确的上下文。但并非所有希望都已破灭!ViewModel 没有理由不能是一个(不可见的)FrameworkElement
。这是代码:
<DataTemplate DataType="{x:Type clr:ColorItem}">
<Grid>
<clr:ColorItemViewModel x:Name="Persona" Item="{Binding}"/>
<ComboBox DataContext="{Binding ElementName=Persona}"
ItemsSource="{Binding AvailableColors}"
SelectedItem="{Binding Color}" />
</Grid>
</DataTemplate>
该 Grid 用作占位符,以容纳不可见的 ViewModel 和 ComboBox。只要 ViewModel 继承自 FrameworkElement
,这就能很好地工作。
请注意,我们已经替换了 ComboBox
的 DataContext
,将其设置为 ViewModel 而非原始 ColorItem
。现在,ItemsSource
绑定将从 ColorItemViewModel
实例获取可用颜色。
关注点
这里有两个主要概念。第一,将数据模板嵌套在其他数据模板中,使用 ContentPresenter
的想法。这允许进行各种有趣的技巧,例如在不更改项的原始数据模板的情况下,为列表项的 RenderTransform
添加动画。
第二个(也是更有趣的)技巧是使用 DataTemplate
中的不可见 FrameworkElement
为每个实例拉入逻辑。由于这些是在每次实例化数据模板时创建的,因此每个数据项将拥有您逻辑类的一个唯一实例。然后,您可以使用绑定语法将逻辑连接到视觉元素。
结论
本文并非旨在提供实现 PropertyGrid 的权威示例。在 WPF 中,有许多方法可以解决同一个问题。但这是一个优雅的解决方案,我希望它能为其他优雅的解决方案提供灵感。
历史
在本篇文章的第一个版本中,我犯了一个大错误,将 ViewModel 放在了 Resources 部分。这个想法很巧妙,但我忘了考虑 ViewModel 如何获得 DataContext
。这说明并非所有好的想法都能如你所愿地运作。