65.9K
CodeProject 正在变化。 阅读更多。
Home

在 MVVM 设置中对 WPF ListView 进行排序和过滤

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (8投票s)

2010 年 9 月 17 日

CPOL

14分钟阅读

viewsIcon

82854

downloadIcon

4622

一个简单的 WPF 应用程序,在 MVVM 框架中实现了具有排序和过滤的主从(Master-Detail)设置。

引言

在本文中,我将使用 WPF 技术和 MVVM 范例来实现一个简单的 Master-Detail 场景。对于 UI,我将使用两个 ListView 控件。第一个 ListView 将支持排序和过滤。该应用程序的构建旨在提供对 .NET 编程中许多现代最佳实践的概述。

背景

您应该对 WPF 应用程序的工作原理有基本了解。此外,对 Model View ViewModel (MVVM) 范例的基本理解也会很有帮助(MSDNhttp://live.visitmix.com/MIX10/Sessions/EX14)。

对于这个例子,我使用了 MVVM Light Toolkit,您可以在 这里找到它。

对于单元测试,我使用了 NUnit,您可以在 这里找到它。在单元测试中,我使用了 Moq 来模拟对象;Moq 可以在 这里找到。

作为控制反转容器,我使用了 Castle Windsor,您可以在 这里找到它。

虽然您可以单独下载每个库,但在可下载的项目中,所有必要的 DLL 文件都已包含在内。

Model View ViewModel 范例

那么,这到底是什么呢?它是 WPF 应用程序的一个非常有用的设计模式,因为它允许将应用程序的实现细节与其表示层解耦,从而实现关注点分离。描述该模式的三个词分别代表了一个独立的抽象层:Model 层包含应用程序使用的所有实体(在 MasterDetailDemo 中,诸如 PersonCar 之类的类),以及从外部位置检索这些实体所需的所有功能(在 MasterDetailDemo 中,诸如 XmlPersonsRepositoryXmlCarsRepository 之类的类);View 层包含为应用程序提供可见界面的 UI 组件;ViewModel 层包含应用程序的实际实现,并充当 View 和 Model 之间的中介,基本上,它以 View 可以理解的格式准备数据。

您应该使用 MVVM 的原因有很多,但现在我不会详细介绍,因为网上有很多关于它的演示。所以,请自行搜索,或者查看“背景”部分提供的链接。

应用程序

我们将要构建的应用程序将尽可能简单。我们将有一份人员列表,每个人都拥有一些汽车。在主窗口上,我们将创建两个表:一个用于人员,一个用于所选人员拥有的汽车。为了增加复杂性,我们希望人员表是可排序和可过滤的。单击每个列的标题将触发排序。在每个列的标题上,我们将有一个类似切换按钮的东西,如果激活,它将弹出一个包含复选框的过滤列表。选中/取消选中复选框将触发过滤。

现在,我们开始吧,好吗?

如果您打开 MasterDetailDemo 的 VS 解决方案,您会看到四个项目。我现在将详细描述每个项目。

MasterDetailDemo.Model

这部分会很容易。我们的应用程序只需要两种实体:一种是人员,另一种是汽车。Person 类具有一些带有属性的属性(我使用了 DescriptionAttribute 来存储将在 UI 上显示的属性描述,而 BindableAttribute 用于知道哪些属性是可排序和可过滤的;更多内容将在 MasterDetailDemo.ViewModel 部分介绍)。

Repositories文件夹中,您将找到人员和汽车存储库的接口和具体类。这样,从外部位置检索人员和汽车就可以被干净地分离出来。为虚构文件和 XML 文件创建了具体存储库。对于 XmlPersonsRepositoryXmlCarsRepository,实际数据由 Persons.xml 文件提供。虚构存储库具有硬编码的实体值。

这种设计是出于测试目的而需要的;当我们编写单元测试时,您就会明白这一点。

MasterDetailDemo.ViewModel

这里应该会变得有趣。ViewModelLocator 类是由 MVVM Light Toolkit 创建的。这个类负责创建 MainViewModel 对象,然后该对象被用作 MainWindow 类(在 MasterDetailDemo.View 项目中,稍后会详细介绍)的绑定源。我修改了 CreateMain() 方法,以便能够使用 Castle Windsor 框架实例化 IPersonsRepository 对象。

if (MainViewModel.IsInDesignModeStatic)
    personsRepository = new FakePersonsRepository ();
else
{
    var container =
        new WindsorContainer (
            new XmlInterpreter (new ConfigResource ("castle")));

    personsRepository = 
      container.Resolve (typeof (IPersonsRepository)) as IPersonsRepository;
}

_main =
    new MainViewModel (personsRepository, new MainViewModelEvents (), 
                       new ColumnHeaderFactory ());

这段代码所做的就是首先检查我们是否处于设计模式。如果是,则创建一个 FakeRepository 对象;否则,它会创建一个 WindsorContainer,并根据 App.config 文件中的设置在运行时实例化具体的 IPersonsRepository 对象。App.config 文件位于 MasterDetailDemo.View 项目中。它的配置如下:

<component
      id="personRepository" 
      service="MasterDetailDemo.Model.Repositories.Abstract.IPersonsRepository, 
               MasterDetailDemo.Model"
      type="MasterDetailDemo.Model.Repositories.Concrete.XmlPersonsRepository, 
            MasterDetailDemo.Model">
    <parameters>
        <filePath>c:\Users\aszalos\Documents\Visual Studio 2010\
           Projects\WPF projects\MasterDetailDemo\
           MasterDetailDemo.Model\bin\Debug\Persons.xml</filePath>
    </parameters>
</component>

此配置告诉 WindsorContainer,每当请求 IPersonsRepository 对象时,都创建一个 XmlPersonsRepository 对象。请注意,您需要提供 Persons.xml 文件所在位置的正确路径。XmlPersonsRepository 的构造函数有一个 filePath 参数,该参数应在配置文件中指定。它是 Persons.xml 所在的位置。使用 Castle Windsor IoC 容器可以轻松地通过修改配置文件来设置 MainViewModel 的依赖关系,因此代码将实例化 ViewModel 使用的实际 IPersonsRepository 实现,从而无需更改 ViewModelLocator 类的定义。

MainViewModel 类也是由 MVVM Light Toolkit 模板自动创建的。这个类代表 MainWindow(MasterDetailDemo.View 项目的一部分)的 ViewModel;这是 ViewModelLocator 创建的类。在详细介绍实现之前,我想总结一下 MasterDetailDemo.ViewModel 项目的全部内容。

基本上,我们想显示人员及其汽车,因此我们需要存储一个 IPersonsRepository 对象的引用。通过调用 Person.CarsOwned 可以直接获取一个人的汽车,因此我们无需在单独的对象中存储汽车。(实际上,要维护人员和汽车之间的主从关系,ViewModel 中不需要任何额外的代码;关系将在 View 中维护,我将在下一部分解释这一点。)

整个应用程序的棘手部分是实现人员的排序和过滤。如前所述,我们希望用户能够为人员表的每一列设置过滤条件。

masterdetaildemo.jpg

每一列都需要知道它包含什么类型的值(例如,“First Name”列需要知道值“Nancy”、“Andrew”、“Janet”和“Margaret”),因此我们需要一个实现此功能的 VM 类。ColumnHeaderViewModel 将是人员表中每一列的绑定源 VM 类。除了存储相应列的值列表之外,它还需要知道带有复选框的弹出窗口当前是否显示。为什么我们需要这个?嗯,当用户单击另一列中的另一个切换按钮时,我们希望隐藏当前显示的弹出窗口(如果已激活),这样一次只会显示一个弹出窗口。

在弹出窗口中,我们将有过滤文本以及复选框,因此我们需要一个 VM 类来存储有关过滤器的信息。FilterViewModel 将存储将在 UI 上显示的过滤器文本和一个布尔值,该值指示过滤器是否处于活动状态。

下图说明了主要 VM 类的通信将如何发生。

MasterDetailDemo_vm_interaction.jpg

让我们看看如何实现这三个类之间的通信。主要想法是我不想在 FilterViewModelColumnHeaderViewModel 类中拥有对 MainViewModel 的硬引用。出于这个原因,我使用了 IMainViewModelEvents 接口,该接口有两个事件,MainViewModel 对象可以订阅,以及两个方法,其他 VM 类可以使用这些方法通知订阅者(MainViewModel 对象)某个特定操作已发生。

MainViewModelEvents.jpg

这样,所有三个 VM 类都将具有对同一个 IMainViewModelEvents 类的引用(为什么我为此使用接口将在我们进行单元测试时清楚,但我们可以立即说明我们需要测试交互是否发生)。VM 类的基本结构将是这样的:

MasterDetailDemo_simple_diagram.jpg

在详细介绍 VM 类实现之前,我想说明一下 MainViewModel 对象在上述事件触发时应该做什么。当 IMainViewModelEvents 类中的 ColumnHeaderFiltersChanged 事件触发时,我们需要进行过滤。我们将如何做到这一点?对于每个 Person 记录,我们需要遍历 MainViewModel 对象中 ColumnHeaders 集合,对于每个 ColumnHeaderViewModel 对象,我们需要检查其 Filters 集合。对于每个 FilterViewModel 对象,我们需要检查它是否处于活动状态。(有关过滤过程的逻辑,请参见代码。)当 IsHeaderPopupOpenChanged 事件触发时,我们需要遍历 MainViewModel 对象中 ColumnHeaders 集合,并将其他 ColumnHeaderViewModel 对象的 IsHeaderPopupOpen 属性设置为 false

为了进行单元测试,我们需要隔离地测试 VM 类,而无需与其他类进行依赖。但是,如果我们实现上述方案,MainViewModel 将依赖于 ColumnHeaderViewModel 的实现,而 ColumnHeaderViewModel 又会依赖于 FilterViewModel 的实现。因此,我们为 ColumnHeaderViewModelFilterViewModel 创建了接口,这将允许我们通过接口定义依赖关系。这样,我们就可以创建模拟对象来打破这些依赖关系。

vm_interfaces.jpg

主图将如下所示:

masterdetaildemo_main.jpg

我们可以立即假设 MainViewModel 的构造函数将负责初始化 ColumnHeaders 属性。但是,因为我们需要能够控制构造函数将创建哪种类型的 IColumnHeaderLocator 对象,所以我们创建了一个工厂类(ColumnHeaderFactory),它将负责创建实际的 IColumnHeaderLocator 对象。

public class ColumnHeaderFactory
{
    public virtual IColumnHeaderLocator Create (IMainViewModelEvents 
           mainVMEvents, String headerText, String propertyName, 
           List<String> filterTexts)
    {
        return new ColumnHeaderViewModel (mainVMEvents, 
                   headerText, propertyName, filterTexts);
    }
}

ColumnHeaderFactoryCreate 方法将在 MainViewModel 类的构造函数中被调用。在单元测试的情况下,我们将覆盖 Create 方法并创建 IColumnHeaderLocatorIFilterLocator 的模拟对象。

MainViewModel 类的构造函数如下所示:

public MainViewModel (
     IPersonsRepository personRepository, 
     IMainViewModelEvents mainVMEvents, 
     ColumnHeaderFactory chFactory)

我使用了构造函数注入来为该类提供依赖项。ColumnHeaders 属性在构造函数中初始化;它的类型是 Dictionary<String, IColumnHeaderLocator>,并存储了所有应该显示在 UI 上的列。字典的键属性存储可绑定对象的属性名称(例如,Person.FirstName => FirstName),值属性存储一个 IColumnHeaderLocator 对象。为具有 BindableAttribute 定义的 Person 类的每个属性创建一个列标题。通过执行 SortCommand 命令可以按属性名称排序。SortCommand 的类型是 RelayCommand<String>,它接收属性名称作为参数来执行 Sort() 方法。对 SortCommand 使用相同的属性名称参数两次将按降序对此属性对人员列表进行排序。

ColumnHeaderViewModel 类的情况下,我想澄清 IsHeaderPopupOpen 属性,如下所示:

public bool IsHeaderPopupOpen
{
    get
    {
        return _isHeaderPopupOpen;
    }
    set
    {
         _isHeaderPopupOpen = value;


         RaisePropertyChanged ("IsHeaderPopupOpen");

         if (_isHeaderPopupOpen)
             _mainVMEvents.RaiseIsHeaderPopupOpenChanged (this);
    }
}

每当 IsHeaderPopupOpen 被设置为新值时,就会调用 RaisePropertyChanged() 方法(该方法由 ViewModelBase 定义,它是所有 VM 类的基类),以通知任何绑定目标该属性值已更改。同时,我检查该值是否为 true(这意味着标题弹出窗口应该打开),如果是这种情况,我将在 _mainVMEvents 对象上调用 RaiseIsHeaderPopupOpenChanged 方法,以通知 MainViewModel 对象检查其他列标题。FilterViewModel 类的 IsActive 属性的定义方式类似,因此我将跳过其解释。

MasterDetailDemo.View

MainWindow.xaml 包含设置我们应用程序 UI 的标记。MainWindowDataContext 属性绑定到 ViewModelLocatorMain 属性(类型为 MainViewModel)。这在创建 MVVM Light Toolkit 项目模板时已设置好,因此您无需修改。

SortCommand 被定义为 MainWindow 上的附加属性。(您可以在 这里找到有关附加属性的信息。)我们需要此属性,因为我们希望在用户单击人员表的列标题时执行此命令。SortCommand 附加属性通过 XAML 文件中的代码绑定到 MainViewModelSortCommand 属性:

main:MainWindow.SortCommand="{Binding Source={StaticResource Locator}, Path=Main.SortCommand}"

要以主从设置显示人员和汽车,我们所需要做的就是选择 ListView 控件的公共祖先,并将 MainViewModelPersons 属性定义为其 DataContext 的绑定源。此外,您还需要在两个 ListView 控件上设置 IsSynchronizedWithCurrentItem="true"(请注意,在示例项目中,这是通过样式设置的)。

<StackPanel
    DataContext="{Binding Persons}">
    <ListView 
        ItemsSource="{Binding}"
        GridViewColumnHeader.Click="ListView_Click" ... />
    <ListView 
        ItemsSource="{Binding CurrentItem.CarsOwned.Cars}" ... />
</StackPanel>

请注意第二个 ListView 的绑定表达式中的 CurrentItem 属性。CurrentItem 属性设置为对应于第一个 ListView 选中行的 Person 对象。这样,每当第一个 ListView 中选择一个新的 Person 对象时,Person.CarsOwned.Cars 集合就会绑定到第二个 ListView

人员 ListViewGridView 属性如下所示:

<GridView 
    ColumnHeaderTemplate="{StaticResource dt_ColumnHeader}"
    ColumnHeaderContainerStyle="{DynamicResource stl_ColumnHeaderContainer}">
    <GridViewColumn  
        Width="125"
        DisplayMemberBinding="{Binding FirstName}" 
        Header="{Binding DataContext.ColumnHeaders[FirstName], 
                 RelativeSource={RelativeSource AncestorType={x:Type Window}, 
                 Mode=FindAncestor}}"/>
...
</GridView>

请注意,GridViewDataContext 隐式设置为 MainViewModelPersons 属性(类型为 ObservableCollection<Person>),因此当我们设置 GridViewColumnDisplayMemberBinding 属性时,绑定隐式引用当前 Person 的属性(例如,FirstName)。但是,当我们想将 GridViewColumnHeader 属性绑定到 MainViewModelColumnHeaders[xyz] 属性时,我们需要将 Binding 的 RelativeSource 设置为主 MainViewModel 对象(在 Window 上定义)。

dt_ColumnHeader DataTemplate 定义在 XAML 文件的 Window.Resources 部分,并且包含两个 Path 元素(一个用于向上箭头,一个用于向下箭头)和一个 TextBlock 元素。相应的 Path 元素的 Visibility 仅在 ColumnHeaderViewModelPropertyName 属性等于 MainViewModelSortBy 属性并且 MainViewModelSortDirection 属性等于为 MultiBinding 指定的 ConverterParameter 值(“asc”或“desc”)时设置为 VisibleMultiBinding 使用 ColumnPropertyToVisibilityConverter 将三个值转换为 Visibility 对象。

stl_ColumnHeaderContainer 样式定义在 MainSkin.xaml 文件中,并为 GridViewColumnHeader 元素定义了一个 ControlTemplate

<DockPanel>
    <Popup
        IsOpen="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, 
                 Path=Content.IsHeaderPopupOpen, Mode=OneWay}"
        PlacementTarget="{Binding ElementName=exp_Filter}" Placement="Bottom">
        <ItemsControl
            ItemsSource="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, 
                          Path=Content.Filters, Mode=OneWay}"
            ItemTemplate="{DynamicResource dt_CheckBoxItem}" ... >
            <ItemsControl.Resources>
                <DataTemplate
                x:Key="dt_CheckBoxItem">
                <CheckBox
                   IsChecked="{Binding IsActive, Mode=TwoWay, 
                               UpdateSourceTrigger=PropertyChanged}"
                   Content="{Binding FilterText}" />
            </DataTemplate>                       
         </ItemsControl.Resources>
         </ItemsControl>
    </Popup>
        <Expander 
            Name="exp_Filter" 
            IsExpanded="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, 
                         Path=Content.IsHeaderPopupOpen, Mode=TwoWay, 
                         UpdateSourceTrigger=PropertyChanged}"
        </Expander>
</DockPanel>
...

PopupIsOpen 属性绑定到 ColumnHeaderViewModelIsHeaderPopupOpen 属性。Popup 包含一个 ItemsControl 元素,该元素在其 ItemTemplate 属性中定义了 CheckBox 元素。CheckBox 元素的 IsChecked 属性和 Content 属性分别绑定到 FilterViewModelIsActive 属性和 FilterText 属性。exp_Filter Expander 元素定义了一个切换按钮,该按钮将作为调出 Popup 控件的输入元素。它的 IsExpanded 属性绑定到 ColumnHeaderViewModelIsHeaderPopupOpen 属性。Binding 模式设置为 TwoWay,因为我们需要在用户单击切换按钮时通知 VM 类。

SortCommand 是如何执行的?每当用户单击列标题时,都会调用 ListView_Click() 事件处理程序方法,并且如果事件的源不是切换按钮(单击此按钮时,您期望弹出窗口显示过滤文本而不是排序),则会使用我们想要对其进行排序的人员集合的 Person 类的属性名来执行 SortCommand

所以,基本上我们已经完成了应用程序的讨论。剩下的是测试项目。

MasterDetailDemo.Tests

我们想测试 MasterDetailDemo.ViewModel 的每个 VM 类。编写的测试基本上是自解释的,所以我不会详细介绍,除了 MainViewModel 类的情况。因为 OnColumnHeaderFilterChanged()OnIsHeaderPopupOpenChanged 方法依赖于 IColumnHeaderLocatorIFilterLocator 接口的实际实现,所以我们需要创建 MainViewModel 可以使用的模拟对象。如前所述,MainViewModes 的构造函数使用 ColumnHeaderFactoryCreate() 方法来创建 IColumnHeaderLocator 对象。我们需要覆盖 Create() 方法并在其中创建模拟对象。模拟对象存储在 XColumnHeaderFactory 类的 ColumnHeaderLocatorMocksFilterLocatorMocks 属性中。有关更多详细信息,请检查代码。

好了,就是这样。希望您喜欢它并从中有所学。

历史

  • 2010 年 9 月 17 日:初始发布。
© . All rights reserved.