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

基于 View-View Model 的 WPF 和 XAML 实现模式。(WPF 和 XAML 代码复用模式简易示例。第 2 部分)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (26投票s)

2014年7月28日

CPOL

26分钟阅读

viewsIcon

69407

downloadIcon

833

基于 View-View Model 的实现模式。

引言

正如在WPF 控件模式。(WPF 和 XAML 代码复用模式简易示例。第 1 部分)中提到的,WPF 框架催生了全新的编程概念,这些概念也可以用于 WPF 之外的纯非可视化编程。在我看来,这些概念至少与面向对象编程(OOP)相对于过程式编程的进步幅度相同,是 OOP 之上的一步。

围绕这些新概念,可以构建各种 WPF/XAML 模式。这些模式共同作用,可以在应用程序功能内部实现巨大的代码复用和关注点分离。

在本系列文章中,我将尝试通过简单的 WPF 示例来介绍和阐明这些模式。

在本系列的第一篇文章——WPF 控件模式。(WPF 和 XAML 代码复用模式简易示例。第 1 部分)——中,我介绍了一些处理 WPF ControlControlTemplate 的模式。该文章中描述的模式完全不依赖于 WPF Control 的一个非常特殊的 DataContext 属性,也没有处理 ContentControlItemControl 将非可视化对象转换为可视化对象的能力。本文将填补这一空白。

我将本文中描述的模式称为“实现模式”,因为它们用于实现某些可视化行为。更复杂的模式(我称之为“架构模式”)留待本系列的第三部分,我希望很快能发表一篇描述它们的文章。

通过使用本文和上一篇文章中描述的 XAML/WPF 范式,我成功地以极高的速度进行编程,同时生成了高质量和高密度的代码(具有高复用率和单位代码大量功能的代码)。

通常,随着代码库的扩展,开发人员的生产力会下降——开发人员对自己的代码有点困惑,他们需要更长时间才能找出复用旧功能的最佳方法。事实上,应用本系列文章中描述的原则,随着代码库的扩展,生产力和代码复用会上升,因为开发人员实际上是在用相同的模式和思想构建一个可复用的框架。

本文主要讨论基于视图和视图模型之间关系的模式——关于使可视化对象模仿完全非可视化对象。

本文不适合初学者——我假设读者熟悉基本的 WPF 概念——依赖属性和附加属性、绑定、样式、模板、数据模板、触发器。

可视化对象和非可视化对象

可视化对象和非可视化对象

WPF 控件模式。(WPF 和 XAML 代码复用模式简易示例。第 1 部分)中,我们讨论了骨架-肉体范式。无外观控件扮演着骨架的角色,而ControlTemplate则是肉体。请注意,无外观控件仍然必须派生自 WPF 的Control类型(或其子类)的对象。

在本文中,我们将主要讨论提供完全非可视化甚至非 WPF 骨架肉体的可视化对象。这是 MVVM 范式的 View-View Model 部分。这里我们不关心 MVVM 的 Model 部分——它可以很容易地合并到 View Model 中或由 View Model 复制。

本节主要是一个复习——但仍然强烈建议您仔细阅读,因为它从大多数 WPF 教科书不同的角度讨论了已知的 WPF 控件。

DataContext 属性

每个 FrameworkElement 对象都有 DataContext 依赖属性。除非被重写,否则此属性会从逻辑树中的对象父级继承。DataContext 对象是 WPF Binding 的默认源对象,其含义是,如果绑定中未指定 RelativeSourceSourceElementName,则绑定假定其 Source 对象由绑定目标的 DataContext 提供。这是由实现 WPF 的微软人员特意这样做的,以便让可视化对象更容易将其属性绑定到由其 DataContext 属性提供的非可视化对象。

ContentControl

一些 WPF 教科书指出,您可以在 WPF ContentControlContent 属性中放置任何 XAML 代码。例如,您可以有一个 Button(它是 ContentControl 的后代),其中包含一个 StackPanel 对象,而 StackPanel 又包含多个 Image 对象。

  <Button>
    <StackPanel>
      <Image Source="Image1">
      <Image Source="Image2">
    /<StackPanel>
  /<Button>

虽然这是事实,但这并不是使用ContentControlContent属性的最佳方式,应尽可能避免。

ContentControl更实用的定义应该是——一个允许将其Content属性指向的非可视化对象与其ContentTemplate属性指向的DataTemplate“结合”成一个可视化对象的控件。

项目 SimpleContentControlSample 演示了 ContentControl 的这种用法。

非可视化对象由TextContainer类表示。它只有一个属性TheText和一个方法ChangeText(),用于在“Hello World”和“Bye World”之间切换TheText属性。

public class TextContainer : INotifyPropertyChanged
{
    #region INotifyPropertyChanged Members
    public event PropertyChangedEventHandler PropertyChanged;
    #endregion

    protected void OnPropertyChanged(string propertyName)
    {
        if (this.PropertyChanged != null)
        {
            this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    #region TheText Property
    private string _text = "Hello World";
    public string TheText
    {
        get
        {
            return this._text;
        }
        set
        {
            if (this._text == value)
            {
                return;
            }

            this._text = value;
            this.OnPropertyChanged("TheText");
        }
    }
    #endregion TheText Property


    bool _showOriginalText = true;
    public void ChangeText()
    {
        _showOriginalText = !_showOriginalText;

        if (_showOriginalText)
        {
            TheText = "Hello World";
        }
        else
        {
            TheText = "Bye World";
        }
    }
}  

请注意,OnPropertyChanged 函数会触发在 INotifyPropertyChanged 接口中声明的 PropertyChanged 事件,以通知 WPF Binding 属性已更改。

在 MainWindow.xaml 文件的 WindowResources 部分,我们定义了非可视化对象和 DataTemplate

<!-- non-visual object -->
<this:TextContainer x:Key="TheTextContainer" />

<!-- data template built around the TextContainer -->
<DataTemplate x:Key="TextContainerDataTemplate">
    <StackPanel x:Name="TopLevelDataTemplatePanel"
                Orientation="Horizontal">
        <TextBlock Text="{Binding Path=TheText}" 
                   VerticalAlignment="Center"/>
        <Button x:Name="ChangeTextButton"
                Width="120"
                Height="25"
                Content="Change Text"
                Margin="20,5" 
                VerticalAlignment="Center">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="Click">
                    <ei:CallMethodAction MethodName="ChangeText" 
                                         TargetObject="{Binding }"/>
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Button>
    </StackPanel>
</DataTemplate>  

TextBlockText属性绑定到TextContainer对象的TheText属性。单击ChangeTextButton(通过 MS Expression Blend SDK 管道)将导致调用ChangeText()方法。

注意:正如在WPF 控件模式。(WPF 和 XAML 代码复用模式简易示例。第 1 部分)中所述,您无需安装 MS Expression Blend 即可使用其 SDK。它可以从MS Expression Blend SDK免费下载和使用。此外,我们只需要 SDK 中的两个 dll 文件:Microsoft.Expression.Interactions.dll 和 System.Windows.Interactivity.dll,并且我们将这些文件与我们的代码一起提供在 MSExpressionBlendSDKDlls 文件夹下。

由于您是从互联网下载这些文件,您可能需要取消阻止它们。要做到这一点,右键单击每个文件,选择“属性”菜单项,然后单击“取消阻止”按钮。

 

最后,在Window中,我们创建了ContentControl,其Content属性设置为TextContainer对象,其ContentTemplate属性设置为TextContainerDataTemplate

<ContentControl HorizontalAlignment="Center"
                VerticalAlignment="Center" 
                Content="{StaticResource TheTextContainer}"
                ContentTemplate="{StaticResource TextContainerDataTemplate}"/>

这是我们运行项目后得到的结果

按下“更改文本”按钮将在“Hello World”和“Bye World”之间切换文本。

请注意,所有用于显示文本以及处理“Hello World”和“Bye World”字符串之间切换的有意义功能都定义在 TextContainer 对象中,该对象充当我们简单应用程序的视图模型。视图在 MainWindow.xaml 文件中由 TextContainerDataTemplate 定义,仅负责应用程序的可视化方面,并为 TextContainer 类中定义的逻辑提供了一个包装器或外壳。

重要提示

除了Content属性之外,ContentControl还具有通常的DataContext属性,其值如上所述在逻辑树中从父级传播到子级。Content属性不会自动设置为DataContext属性——如果您希望将其设置为DataContext属性——您需要提供一个绑定:Content={Binding}。请注意,您不必指定绑定路径——默认情况下,{Binding}将绑定到同一对象的DataContext属性。

ContentControlContent对象成为数据模板中顶层对象的DataContext——在我们的例子中,“TopLevelDataTemplatePanel”StackPanelDataContext属性设置为TextContainer视图模型对象。相应地,相同的对象在DataTemplate中沿着逻辑树向下传递到TextBlockButton元素,作为DataContext

ItemsControl

虽然 ContentControl 非常适合显示单个非可视化对象,但 ItemsControl 用于显示非可视化对象的集合。ItemsControlItemsSource 属性被设置为(通常通过绑定)非可视化对象的集合。其 ItemTemplate 属性被设置为 DataTemplate,为集合中的每个项目提供可视化表示。

请注意,ItemsSource 可以设置为任何集合,但只有当 ItemsSource 集合实现 INotifyCollectionChanged 接口(或实现 ObservableCollection<T>)时,当项目从 ItemsSource 集合中添加或删除时,可视化效果才会更新。否则,可视化效果只会在 ItemsSource 本身被重置时改变。

项目SimpleItemsControlSample演示了如何使用ItemsControl

它的TextContainer类代表一个非可视化(视图模型)对象,而TextContainerTestCollection类代表一个此类非可视化对象的集合。

TextContainer类与上一个示例中的不同。我们不再硬编码按钮点击前后的显示字符串,而是允许在TextContainer的构造函数中传递这些字符串。

string _originalText = null;
string _alternateText = null;

public TextContainer(string originalText, string alternateText)
{
    _originalText = originalText;
    _alternateText = alternateText;
}  

TheText属性的实现也略有改变,仅仅是为了演示一种更优雅的实现方式。它不再有 Setter,并且根据 _showOriginalString 标志的值返回 _originalText_alternativeText 字符串。

#region TheText Property
public string TheText
{
    get
    {
        if (_showOriginalText)
        {
            return _originalText;
        }
        else
        {
            return _alternateText;
        }
    }
}
#endregion TheText Property  

ChangeText()方法切换_showOriginalString标志并调用OnPropertyChanged("TheText"),以便通知UITheText属性已更改。

bool _showOriginalText = true;
public void ChangeText()
{
    _showOriginalText = !_showOriginalText;

    OnPropertyChanged("TheText");
}  

TextContainerTestCollection类派生自ObservableCollection<TextContainer>。它的构造函数向自身添加了3个TextContainer项。

public TextContainerTestCollection()
{
    Add(new TextContainer("Hi World", "Bye World"));
    Add(new TextContainer("Hi Friend", "Bye Friend"));
    Add(new TextContainer("Hi WPF", "Bye WPF"));
}

一个TextContainerTestCollection对象在XAML的Window.Resources部分中定义。我们还在那里以与上一个示例完全相同的方式定义了TextContainerDataTemplate

<Window.Resources>
    <!-- non-visual object -->
    <this:TextContainerTestCollection x:Key="TheTextContainerTestCollection" />

    <!-- data template built around the TextContainer -->
    <DataTemplate x:Key="TextContainerDataTemplate">
        <StackPanel x:Name="TopLevelDataTemplatePanel"
                    Orientation="Horizontal">
            <TextBlock Text="{Binding Path=TheText}"
                       VerticalAlignment="Center" />
            <Button Width="120"
                    Height="25"
                    Content="Change Text"
                    Margin="20,5"
                    VerticalAlignment="Center">
                <i:Interaction.Triggers>
                    <i:EventTrigger EventName="Click">
                        <ei:CallMethodAction MethodName="ChangeText"
                                             TargetObject="{Binding }" />
                    </i:EventTrigger>
                </i:Interaction.Triggers>
            </Button>
        </StackPanel>
    </DataTemplate>
</Window.Resources>  

最后,我们定义了ItemsControl,将其ItemsSource设置为非可视化集合,将其ItemTemplate设置为数据模板。

<!-- Resulting ItemsControl -->
<ItemsControl HorizontalAlignment="Center"
              VerticalAlignment="Center"
              ItemsSource="{StaticResource TheTextContainerTestCollection}"
              ItemTemplate="{StaticResource TextContainerDataTemplate}"/> 

这是我们运行应用程序时的样子

按下每个项目上的按钮,都会将其文本更改为备用文本。再次按下则会改回。

将 ControlTemplate 用于 ContentControl

本节我要讨论的最后一个主题是如何使用ControlTemplate来改变内容控件的外观。

此小节的解决方案名为CustomToggleControl。它由主项目(也同名)组成,并引用了一个项目CustomControls

该项目的目的是将ToggleButton定义为ContentControl,并展示如何为其提供几种不同的ControlTemplate

MyToggleButton类代表Lookless 骨架的切换按钮。它派生自ContentControl类。它定义了一个布尔依赖属性IsChecked

#region IsChecked Dependency Property
public bool IsChecked
{
    get { return (bool)GetValue(IsCheckedProperty); }
    set { SetValue(IsCheckedProperty, value); }
}

public static readonly DependencyProperty IsCheckedProperty =
DependencyProperty.Register
(
    "IsChecked",
    typeof(bool),
    typeof(MyToggleButton),
    new PropertyMetadata(false)
);
#endregion IsChecked Dependency Property  

它还为MouseUp事件设置了一个事件处理程序,以便在MouseUp时切换IsChecked属性。

public MyToggleButton()
{
    this.MouseUp += MyToggleButton_MouseUp;
}

void MyToggleButton_MouseUp(object sendr, System.Windows.Input.MouseButtonEventArgs e)
{
    this.IsChecked = !this.IsChecked;
}  

在 CustomControls.xaml 文件中,我们为相同的 MyToggleButton 控件在两种不同的样式中定义了两个控件模板——“MyToggleButtonCheckBoxStyle”将切换按钮显示为复选框,而“ToggleButton_ButtonStyle”将切换按钮显示为按钮。

在描述模板之前,让我们先看看应用程序的主文件。它在垂直的StackPanel中包含MyToggleButton控件的两个实例。

<StackPanel HorizontalAlignment="Center"
            VerticalAlignment="Center">
    <customControls:MyToggleButton Content="My Toggle Button - Check Box Style:"
                                   Style="{StaticResource MyToggleButtonCheckBoxStyle}"
                                   Foreground="White"
                                   Background="Black"
                                   Margin="10,10" />
    <customControls:MyToggleButton Content="My Toggle Button - Button Style:"
                                   Foreground="White"
                                   Background="Black"
                                   Style="{StaticResource ToggleButton_ButtonStyle}"
                                   Margin="10,10" />
</StackPanel>  

当我们运行应用程序时,我们会得到以下结果

复选框样式按钮在其 IsChecked 属性为 true 时,在其右侧的方框中显示一个对勾标记,而按钮样式则将背景更改为更暗。

现在让我们看看相应的ControlTemplate

复选框模板有一个水平的StackPanel,其中包含一个ContentPresenter和一个Border。边框表示带有复选框的方块。ContentPresenter是一个特殊对象,它控制ContentControlDataTemplate将在何处渲染。它采用由ContentControlDataTemplate定义的形状和形式。如果未定义DataTemplate,它将简单地将ContentPresenter渲染为一个TextBlock,显示ContentControlContent属性的字符串表示。

<ControlTemplate TargetType="this:MyToggleButton">
    <Grid Background="Transparent">
        <StackPanel Orientation="Horizontal"
                    Background="{TemplateBinding Background}">
            <ContentPresenter Content="{TemplateBinding Content}"
                              ContentTemplate="{TemplateBinding ContentTemplate}"
                              HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                              VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />

            <!-- the square on the right hand side-->
            <Border Width="20"
                    Height="20"
                    BorderThickness="1"
                    BorderBrush="{TemplateBinding Foreground}"
                    Background="{TemplateBinding Background}"
                    Margin="10, 1, 0, 1">

                <!-- this the the check mark. It is visible when 
                     IsChecked property of the control is true and 
                     invisible otherwise -->
                <TextBlock Text="V"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Foreground="{TemplateBinding Foreground}"
                           Visibility="{Binding Path=IsChecked,
                                                Converter={StaticResource TheBoolToVisConverter},
                                                RelativeSource={RelativeSource AncestorType=this:MyToggleButton}}" />
            </Border>
        </StackPanel>
    </Grid>
</ControlTemplate>  

按钮样式切换按钮有一个包含边框的Grid面板,边框内有一个ContentPresenter。它还有一个不透明的Border对象,覆盖在原始边框上。默认情况下,不透明边框的透明度为0.5。通过触发器,我们在鼠标悬停时将其透明度降低到0.3,当控件被选中时,透明度进一步降低到0。

<ControlTemplate TargetType="this:MyToggleButton">
    <Grid Background="Transparent"
          x:Name="ItemPanel">
        <Border x:Name="TheItemBorder"
                Background="Black"
                BorderBrush="White"
                BorderThickness="1">
            <ContentPresenter Content="{TemplateBinding Content}"
                              ContentTemplate="{TemplateBinding ContentTemplate}"
                              HorizontalAlignment="Center"
                              VerticalAlignment="Center"
                              Margin="10, 5" />
        </Border>

        <!-- provide some opacity for the control -->
        <Border x:Name="TheOpacityBorder"
                Background="White"
                Opacity="0.5" />
    </Grid>
    <ControlTemplate.Triggers>

        <!-- reduce the opacity of the opacity border
             to 0.3 on Mouse Over -->
        <Trigger Property="IsMouseOver"
                 Value="True">
            <Setter TargetName="TheOpacityBorder"
                    Property="Opacity"
                    Value="0.3" />
        </Trigger>


        <!-- reduce the opacity of the opacity border
             to 0 when the button is checked -->
        <Trigger Property="IsChecked"
                 Value="True">
            <Setter TargetName="TheOpacityBorder"
                    Property="Opacity"
                    Value="0" />
        </Trigger>
    </ControlTemplate.Triggers>
</ControlTemplate>  

本小节我想要表达的主要思想是,ContentControlControlTemplate可以定义一个ContentPresenter对象,其视觉效果将由ContentControlDataTemplate决定。

基于 MVVM 的模式

现在我们终于要讨论基于MVVM的模式了。

单选视图模型模式

我喜欢基本的 WPF 控件,例如 ContentControlItemsControl。我对派生的 WPF 控件(例如 RadioButtonListViewListBox)则不那么喜欢——在我看来,它们是在 WPF 最佳实践尚未为人所知之前创建的,并未充分利用它们。

在本小节中,我们将展示如何使用视图模型概念来创建一个具有单选功能的ItemsControl,模拟RadioButton的功能——即一次最多只能选择一个项目:如果一个项目已经选中,用户选择另一个项目,则第一个选中项目将变为未选中。

稍后我们将展示如何创建更通用的功能,以保持 2 个或更多最后一个项目被选中。

请注意,ListViewListBox已经具备单项选择功能,但它们无法轻易地推广到保持两个或更多项目被选中。

另请注意,由于项目选择功能是在非可视化视图模型中定义的,因此我们可以轻松地对其进行单元测试,如下所示。

包含代码的解决方案名为“UniqueSelectionPatternSample”。其主项目同名,并引用了同一解决方案中的另外两个项目:ViewModelsTestViewModels。该解决方案还包含 MS 单元测试项目SelectionTests

ViewModels项目定义了本示例和几个其他示例的非可视化对象。在本示例中,我们将查看在ViewModels项目中定义的接口ISelectable和类SelectableItemBaseSelectableItemWithDisplayName以及CollectionWithUniqueItemSelection

TestViewModels 项目包含许多类,这些类表示在 ViewModels 项目中定义的集合,并填充了一些测试数据。在本示例中,我们将使用其中的 MyTestButtonsWithSelectionVM 类。

SelectionTests 项目为 ViewModels 项目下定义的非可视化类提供了单元测试。

让我们从展示示例功能开始。将UniqueSelectionPatternSample项目设为解决方案的启动项目。然后运行解决方案。您将看到一行四个按钮。尝试单击这些按钮——您会发现当一个按钮被选中时,之前被选中的按钮会取消选中。

控制选择/取消选择的功能位于纯非可视化类CollectionWithUniqueItemSelection<T>中,该类定义在ViewModels项目下。此集合扩展了ObservableCollection<T>,其泛型参数T必须实现ISelectable接口。

ISelectable声明了IsItemSelected属性和IsItemSelectedChangedEvent事件。

public interface ISelectable
{
    bool IsItemSelected { get; set; }

    event Action ItemSelectedChangedEvent;
}

此接口的实现必须在IsItemSelected属性更改后触发ItemSelectedChangedEvent

SelectableItemBase是此接口的基本实现。当IsItemSelected属性更改时,它会触发ItemSelectionChangedEvent;它还会触发PropertyChanged事件,以便UI可以检测到属性更改。

public class SelectableItemBase : ISelectable, INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected void OnPropChanged(string propName)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(propName));
    }

    public event Action<ISelectable> ItemSelectedChangedEvent;

    bool _isItemSelected = false;
    public bool IsItemSelected
    {
        get
        {
            return _isItemSelected;
        }
        set
        {
            if (_isItemSelected == value)
                return;

            _isItemSelected = value;

            if (ItemSelectedChangedEvent != null)
                ItemSelectedChangedEvent(this);

            OnPropChanged("IsItemSelected");
        }
    }
}

SelectableItemWithDisplayName通过添加DisplayName属性和ToggleSelection()方法来扩展SelectableItemBase。此方法简单地切换项目的选择状态。

public class SelectableItemWithDisplayName : SelectableItemBase
{
    public SelectableItemWithDisplayName(string displayName)
    {
        DisplayName = displayName;
    }

    public string DisplayName 
    {
        get; 
        private set; 
    }

    public void ToggleSelection()
    {
        this.IsItemSelected = !this.IsItemSelected;
    }
}  

现在让我们仔细看看CollectionWithUniqueItemSelection<T>类。它定义了SelectedItem属性,该属性应包含当前选定的项目。如果集合中没有选定的项目,则此属性应为null。当SelectedItem设置为集合中的某个项目时,该项目的IsItemSelected属性将设置为true,而先前选定的项目的IsItemSelected属性将设置为false

T _selectedItem = null;
public T SelectedItem
{
    get
    {
        return _selectedItem;
    }

    set
    {
        if (_selectedItem == value)
            return;

        if (_selectedItem != null) // unselect old item
        {
            _selectedItem.IsItemSelected = false;
        }

        _selectedItem = value;

        if (_selectedItem != null) // select the new item
        {
            _selectedItem.IsItemSelected = true;
        }

        OnPropertyChanged(new PropertyChangedEventArgs("SelectedItem"));
    }
}  

每当向集合中添加项目时,我们都会将方法item_ItemSelectedChagnedEvent(ISelectable item)添加为该项目的ItemSelectedChangedEvent的处理程序。每当从集合中删除项目时,我们都会删除相应的处理程序。添加处理程序通过ConnectItems方法实现。

void ConnectItems(IEnumerable items)
{
    if (items == null)
        return;

    foreach (T item in items)
    {
        item.ItemSelectedChangedEvent += item_ItemSelectedChangedEvent;
    }
}  

此方法由Init()方法(用于集合中的原始项目)调用。

void Init()
{
    ConnectItems(this);
    this.CollectionChanged += CollectionWithUniqueItemSelection_CollectionChanged;
}  

它也会在CollectionChanged事件的处理程序中为集合中的新项目调用。

void CollectionWithUniqueItemSelection_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    DisconnectItems(e.OldItems);

    ConnectItems(e.NewItems);
}  

DisconnectItems(IEnumerable items)方法用于断开从集合中移除的项目的处理程序。

void DisconnectItems(IEnumerable items)
{
    if (items == null)
        return;

    foreach (T item in items)
    {
        item.ItemSelectedChangedEvent -= item_ItemSelectedChangedEvent;
    }
}  

如上所示,它由CollectionWithUniqueItemSelection_CollectionChanged方法(它是CollectionChanged事件的处理程序)调用,以断开从集合中移除的项目的连接。

方法item_ItemSelectedChangedEvent(ISelectable item)(被指定为集合中每个项目的IsItemSelectedChangedEvent处理程序)负责为当前选中的项目设置SelectedItem属性。

void item_ItemSelectedChangedEvent(ISelectable item)
{
    if (item.IsItemSelected)
    {
        this.SelectedItem = (T)item;
    }
    else
    {
        this.SelectedItem = null;
    }
}  

因此,在CollectionWithUniqueItemSelection类中选择选中项有两种等效方式。

  1. 通过设置相应项目的IsItemSelected属性。
  2. 通过将集合的SelectedItem属性设置为相应的项目。

此外,这两种方法是完全等效的——如果您使用上述任一方法选择(或取消选择)一个项目,则另一种方法的条件也将满足。为了验证其真实性,我在SelectionTests项目中创建了一个单元测试UniqueSelectionTests类。

 

在类的构造函数中,我们创建了一个CollectionWithUniqueItemSelection<SelectableItemWithDisplayName>_testCollection,并用三个DisplayName为“1”、“2”和“3”的项填充了它。

public UniqueSelectionTests()
{
    _testCollection = new CollectionWithUniqueItemSelection<SelectableItemWithDisplayName>();

    _testCollection.Add(new SelectableItemWithDisplayName("1"));
    _testCollection.Add(new SelectableItemWithDisplayName("2"));
    _testCollection.Add(new SelectableItemWithDisplayName("3"));
}  

方法TestAllUnselected验证集合中没有选定项目——它检查SelectedItem属性是否设置为null,并且集合中所有单独项目的IsItemSelected属性是否设置为false。

// test that no item is selected
void TestAllUnselected()
{
    // testing for no items selected
    Assert.IsNull(_testCollection.SelectedItem);

    // test that all items is the collected have IsItemSelected property
    // set to false.
    foreach (SelectableItemWithDisplayName selectableItem in _testCollection)
    {
        Assert.IsFalse(selectableItem.IsItemSelected);
    }

    return;
}  

方法TestSelected(string selectedItemDisplayName)测试具有相应显示名称的项是否被选中,并且只有该项被选中——它检查SelecteItem是否具有所需的显示名称,并且只有具有该显示名称的项的IsItemSelected属性设置为true,而其余项的该属性设置为false

// test that item with the specified display name is selected
void TestSelected(string selectedItemDisplayName)
{
    Assert.IsNotNull(_testCollection.SelectedItem);
    Assert.AreEqual(_testCollection.SelectedItem.DisplayName, selectedItemDisplayName);
    Assert.IsTrue(_testCollection.SelectedItem.IsItemSelected);

    foreach (SelectableItemWithDisplayName selectableItem in _testCollection)
    {
        if (selectedItemDisplayName.Equals(selectableItem.DisplayName))
        {
            Assert.IsTrue(selectableItem.IsItemSelected);
        }
        else
        {
            Assert.IsFalse(selectableItem.IsItemSelected);
        }
    }
}

负责选择项目的方法有两个,负责取消选择项目的方法也有两个——每对中的一个对应于通过SelectedItem属性进行选择(或取消选择),另一个则通过IsItemSelected标志进行。

// select the specified item by setting the testCollection's SelectedItem
// property to that item.
void SelectItemViaSettingSelectedItem(string itemDisplayName)
{
    SelectableItemWithDisplayName itemToSelect = FindItemByDisplayName(itemDisplayName);

    _testCollection.SelectedItem = itemToSelect;
}

// unselect the currently selected  item by 
// setting the test collection's selected item to null
void UnselectItemViaUnsettingSelectedItem()
{
    _testCollection.SelectedItem = null;
}

// select the specified item by setting its IsItemSelected property to true.
void SelectItemViaFlag(string itemDisplayName)
{
    SelectOrUnselectItemViaFlag(itemDisplayName, true);
}

// unselect the specified item by setting its IsItemSelected property to false.
void UnselectItemViaFlag(string itemDisplayName)
{
    SelectOrUnselectItemViaFlag(itemDisplayName, false);
}

IsItemSelected标志更改函数调用SelectOrUnselectItemViaFlag实用方法,该方法又调用FindItemByDisplayName实用方法。

SelectableItemWithDisplayName FindItemByDisplayName(string itemDisplayName)
{
    SelectableItemWithDisplayName itemToSelect =
        _testCollection.
            Where((item) => (item as SelectableItemWithDisplayName).DisplayName.Equals(itemDisplayName)).First();

    return itemToSelect;
}

// select or unselect the specified item by setting its IsItemSelected 
// property to true or false.
void SelectOrUnselectItemViaFlag(string itemDisplayName, bool selectOrUnselect)
{
    SelectableItemWithDisplayName itemToSelect = FindItemByDisplayName(itemDisplayName);

    itemToSelect.IsItemSelected = selectOrUnselect;
}

“主要”测试方法是TestSelectingItem(),它调用各种选择/取消选择方法,然后调用测试相应项目确实已选择(或未选择)的方法。

[TestMethod]
public void TestSelectingItem()
{
    TestAllUnselected();

    SelectItemViaFlag("2");

    TestSelected("2");

    SelectItemViaFlag("1");

    TestSelected("1");

    UnselectItemViaFlag("1");

    TestAllUnselected();

    SelectItemViaSettingSelectedItem("2");

    TestSelected("2");

    SelectItemViaSettingSelectedItem("3");

    TestSelected("3");

    UnselectItemViaUnsettingSelectedItem();

    TestAllUnselected();
}  

您可以通过右键单击其声明并选择“运行测试”或“调试测试”选项来运行此方法。

最后,让我们谈谈“主要”的UniqueSelectionPatternSample项目。其所有代码都位于MainWindow.xaml文件中。它使用MyTestButtonsWithSelectionVM对象(在TestViewModels项目定义)作为其视图模型。

<testVMs:MyTestButtonsWithSelectionVM x:Key="TheTestButtonsWithSelectionVM" />

MyTestButtonsWithSelectionVM只是一个CollectionWithUniqueItemSelection<SelectableItemWithDisplayName>,它用四个项填充自身。

public class MyTestButtonsWithSelectionVM : CollectionWithUniqueItemSelection<SelectableItemWithDisplayName>
{
    public MyTestButtonsWithSelectionVM()
    {
        Add(new SelectableItemWithDisplayName("Button 1"));
        Add(new SelectableItemWithDisplayName("Button 2"));
        Add(new SelectableItemWithDisplayName("Button 3"));
        Add(new SelectableItemWithDisplayName("Button 4"));
    }
}  

回到 MainWindow.xaml 文件——让我们看看代表可选按钮的 ItemsControl

<ItemsControl x:Name="SingleButtonSelectionControl"
              ItemsSource="{StaticResource TheTestButtonsWithSelectionVM}"
              ItemTemplate="{StaticResource ItemButtonDataTemplate}"
              VerticalAlignment="Center"
              HorizontalAlignment="Center">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <!-- arrange the items horizontally -->
            <StackPanel Orientation="Horizontal" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemContainerStyle>
        <!-- make sure that each item has margin 5 pixels to the left and right of it-->
        <Style TargetType="FrameworkElement">
            <Setter Property="Margin"
                    Value="5,0" />
        </Style>
    </ItemsControl.ItemContainerStyle>
</ItemsControl>

ItemControlItemsSource属性被设置为TheTestButtonsWithSelectionVM资源对象(包含TestButtonsWithSelectionVM集合),其ItemTemplate属性指向ItemButtonDataTemplate资源(稍后将讨论)。

这是ItemsControl中单个项目的DataTemplate

<DataTemplate x:Key="ItemButtonDataTemplate">
    <Grid Background="Transparent"
          x:Name="ItemPanel">
        <Border x:Name="TheItemBorder"
                Background="Black"
                BorderBrush="White"
                BorderThickness="1">
            <TextBlock HorizontalAlignment="Center"
                       VerticalAlignment="Center"
                       Foreground="White"
                       Text="{Binding Path=DisplayName}"
                       Margin="10, 5" />
        </Border>
        <Border x:Name="TheOpacityBorder"
                Background="White"
                Opacity="0.5" />
        <i:Interaction.Triggers>
            <!-- Call ToggleSelection method when when MouseDown event 
                 is fired on the item -->
            <i:EventTrigger EventName="MouseDown">
                <ei:CallMethodAction MethodName="ToggleSelection"
                                     TargetObject="{Binding Path=DataContext, ElementName=ItemPanel}" />
            </i:EventTrigger>
        </i:Interaction.Triggers>
    </Grid>
    <DataTemplate.Triggers>
        <!-- make TheOpacityBorder less opaque when the mouse is over-->
        <Trigger Property="IsMouseOver"
                 Value="True">
            <Setter TargetName="TheOpacityBorder"
                    Property="Opacity"
                    Value="0.3" />
        </Trigger>
        <DataTrigger Binding="{Binding Path=IsItemSelected}"
                     Value="True">
            <!-- make TheOpacityBorder completely transparent when the 
                 item is selected-->
            <Setter TargetName="TheOpacityBorder"
                    Property="Opacity"
                    Value="0" />

            <!-- make the ItemPanel non-responsive to the event when
                 the item is selected (this is to prevent unselection
                 when clicking on the same item again) -->
            <Setter TargetName="ItemPanel"
                    Property="IsHitTestVisible"
                    Value="False" />
        </DataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>  

可视化项本身由一个Border(名为TheItemBorder)对象组成,其中包含一个TextBlock对象。TextBlockText属性绑定到其自己的DataContextDisplayName属性(其中包含集合中的相应项)。还有一个TheOpacityBorder Border覆盖在TheItemBorder对象上。默认情况下,其不透明度为0.5。DataTemplate的触发器确保当鼠标悬停在可视化项上时,其不透明度降低到0.3,然后当项被选中时,其不透明度进一步降低到0。使用MS Expression Blend SDK管道,我们确保每次单击项时,都会在相应项的视图模型对象上调用ToggleSelection()方法。

还有一个触发器,用于在选中项时将 IsHitTestVisible 值设置为 False ——这样做是为了防止单击时取消选中项——以模拟 RadioButton 的功能。

使用自定义控件实现单选

上面的示例从零开始创建ItemTemplate来实现单选。这可能会给代码复用带来一些问题。例如,如果我们想将上面的DataTemplate复用于不同的视图模型,新的视图模型也必须具有相同的属性和方法名称,即我们想要在项中显示的任何文本都应由DisplayName属性提供,选择状态应由IsItemSelected属性提供,并且它需要一个ToggleSelection()方法来更改选择状态。

与其从头开始创建此类模板,不如使用具有反映要显示的文本和选择状态的属性的自定义控件。即使我们使用具有不同名称的类似属性的视图模型,我们所需要做的也只是更改此类控件上的绑定以供属性使用。

我们上面展示了如何创建MyToggleButton自定义控件。该控件可以直接用于我们的单选实现。它的Content属性可以绑定到视图模型的DisplayName属性以显示其中包含的文本,而它的IsChecked属性可以绑定到视图模型的IsItemSelected属性。

展示如何使用MyToggleButton自定义控件实现单选的解决方案名为“UniqueSelectionWithCustomControl”。其主项目(同名)与上述项目唯一的区别在于MainWindow.xaml文件的Window.Resources部分中TheItemButtonDataTemplate的构建方式。

<DataTemplate x:Key="ItemButtonDataTemplate">
    <customControls:MyToggleButton x:Name="TheToggleButton"
                                   Content="{Binding Path=DisplayName}"
                                   Foreground="White"
                                   Background="Black"
                                   IsChecked="{Binding Path=IsItemSelected, Mode=TwoWay}"
                                   Style="{StaticResource ToggleButton_ButtonStyle}"/>
    <DataTemplate.Triggers>
        <Trigger SourceName="TheToggleButton"
                 Property="IsChecked"
                 Value="True">
            <Setter Property="IsHitTestVisible"
                    Value="False" />
        </Trigger>
    </DataTemplate.Triggers>
</DataTemplate>  

MyToggleButton类为其Content属性绑定到DisplayName,其IsChecked属性绑定到视图模型对象的IsItemSelected属性,从而为可选按钮提供了充分的实现。

运行此项目时,您将获得与上一个示例完全相同的视觉效果和行为。

现在将MyToggleButton的样式更改为MyToggleButtonCheckBoxStyle。您将看到复选框而不是按钮,具有相同的单选原则——当一个复选框被选中时,之前选中的复选框将被取消选中。

请注意,将可选按钮替换为复选框是通过更改 XAML 文件中的一个单词来实现的。

非可视化行为模式

WPF 控件模式。(WPF 和 XAML 代码复用模式简易示例。第 1 部分)中,我们举了一个 WPF 控件行为模式的例子,作为一种非侵入性修改控件行为的方式。在这里,我们将介绍非可视化行为,作为一种(几乎)非侵入性修改非可视化对象行为的方式。

请注意,我们在前一个示例中用于创建非可视化单选的CollectionWithUniqueItemSelection<T>类,有大量代码专门用于单选功能。事实上,几乎所有代码(约90行)都在处理单选。行为将允许将所有这些代码从类中移出到行为类中。这样我们就可以使用相同的集合类,但具有不同的行为,从而产生完全不同的选择算法。

单选行为

NonVisualBehaviorsSample 解决方案演示了如何创建用于单选的行为。我们不再使用 CollectionWithUniqueSelection<T> 作为我们的视图模型集合,而是使用 SelectableItemCollection<T>。它比 CollectionWithUniqueSelection<T> 简单得多。与 CollectionWithUniqueSelection<T> 一样,它扩展了 ObservableCollection<T>,但其功能仅包含一个类型为 IBehavior 的属性 TheBehavior。此属性用于将行为附加到集合。

public class SelectableItemCollection<T> : ObservableCollection<T>
    where T : class, ISelectable
{

    IBehavior _behavior = null;
    public IBehavior TheBehavior 
    {
        get
        {
            return _behavior;
        }

        set
        {
            if (_behavior == value)
                return;

            if (_behavior != null) // if not null detach old selection behavior
                _behavior.OnDetach();

            _behavior = value;

            if (_behavior != null) // if new selection behavior is not null, attach it
                _behavior.OnAttach(this);
        }
    }
}  

IBehavior是一个非常简单的接口,在同一个项目ViewModels中定义。

public interface IBehavior
{
    void OnAttach(IEnumerable collectionToAttachTo);

    void OnDetach();
}  

大部分选择行为功能都位于SelectionBehaviorBase<T>类中。本文中介绍的两种选择行为都派生自该类。

此类别确保所附加行为的集合中每个ISelectable项目的ItemSelectedChangedEvent都由OnItemSelectionChanged(ISelectable item)函数处理。如果相应的项目从集合中移除,它还会负责移除事件处理程序。

CollectionWithUniqueSelection<T>类似,移除和添加事件处理程序的功能由方法DisconnectItems(IEnumerable items)ConnectItems(IEnumerable items)提供。

void DisconnectItems(IEnumerable items)
{
    if (items == null)
        return;

    foreach (ISelectable item in items)
    {
        item.ItemSelectedChangedEvent -= OnItemSelectionChanged;
    }
}

void ConnectItems(IEnumerable items)
{
    if (items == null)
        return;

    foreach (ISelectable item in items)
    {
        item.ItemSelectedChangedEvent += OnItemSelectionChanged;
    }
}  

当行为的TheCollection属性被重置时,这些方法会被调用。

ObservableCollection _collection = null;
ObservableCollection TheCollection
{
    get
    {
        return _collection;
    }

    set
    {
        if (_collection == value)
            return;

        if (_collection != null)
        {
            _collection.CollectionChanged -= _collection_CollectionChanged;
        }

        // disconnect event handlers from the items in the old collection
        DisconnectItems(_collection);

        _collection = value;

        // connect the event handlers to the items in the new collection
        ConnectItems(_collection);

        if (_collection != null)
        {
            _collection.CollectionChanged += _collection_CollectionChanged;
        }
    }
}  

它们也会为从集合中移除或添加到集合中的项目调用。

void _collection_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    // disconnect the event handlers from the items removed from the collection
    DisconnectItems(e.OldItems);

    // connect the event handlers to the items added to the collection
    ConnectItems(e.NewItems);
}

IBehavior方法OnAttach(IEnumerable selectableItemCollectionToAttachTo)OnDetach()简单地设置行为的TheCollection属性。

public void OnAttach(IEnumerable selectableItemCollectionToAttachTo)
{
    if (TheCollection != null)
        throw new Exception("Programming Error: Selection Behavior cannot attach to more than one collection");

    TheCollection = selectableItemCollectionToAttachTo as ObservableCollection;
}

public void OnDetach()
{
    TheCollection = null;
}  

OnItemSelectionChanged(ISelectableItem item)函数提供的选择事件处理程序未在SelectionBehaviorBase<T>类中实现——它被声明为一个抽象方法,将在子类中重写。

UniqueSelectionBehavior<T>类扩展了SelectionBehaviorBase<T>,提供了单项选择功能。它的SelectedItem属性允许通过选择一个项目进行选择(就像CollectionWithUniqueSelection<T>一样),而其OnItemSelectionChanged(ISelectable item)事件处理程序的实现允许通过更改其IsItemSelected属性来选择或取消选择一个项目。

public class UniqueSelectionBehavior<T> : SelectionBehaviorBase<T>
    where T : class, ISelectable
{
    T _selectedItem = null;
    public T SelectedItem 
    {
        get
        {
            return _selectedItem;
        }

        set
        {
            if (_selectedItem == value)
                return;

            if (_selectedItem != null)
            {
                _selectedItem.IsItemSelected = false;
            }

            _selectedItem = value;

            if (_selectedItem != null)
            {
                _selectedItem.IsItemSelected = true;
            }

            OnPropChanged("SelectedItem");
        }
    }

    protected override void OnItemSelectionChanged(ISelectable item)
    {
        if (item.IsItemSelected)
        {
            this.SelectedItem = (T)item;
        }
        else
        {
            this.SelectedItem = null;
        }
    }
}  

作为项目集合的视图模型,我们使用在TestViewModels项目中定义的MyTestButtonsWithSelectionAndBehavior类。它扩展了SelectableItemCollection<SelectableItemWithDisplayName>集合,并向其中添加了四个项目。

public class MyTestButtonsWithSelectionAndBehavior : SelectableItemCollection<SelectableItemWithDisplayName>
{
    public MyTestButtonsWithSelectionAndBehavior()
    {
        Add(new SelectableItemWithDisplayName("Button 1"));
        Add(new SelectableItemWithDisplayName("Button 2"));
        Add(new SelectableItemWithDisplayName("Button 3"));
        Add(new SelectableItemWithDisplayName("Button 4"));
    }
}  

以下是在主项目的MainWindow.xaml文件中定义行为和集合的方式:

<!-- define the selection behavior -->
<viewModels:UniqueSelectionBehaviorOnSelectableWithDisplayName x:Key="TheUniqueSelectionBehavior" />

<!-- define the collection and assign the behavior to it -->
<testViewModels:MyTestButtonsWithSelectionAndBehavior x:Key="TheButtonsWithSelectionBehaviorVM"
                                                      TheBehavior="{StaticResource TheUniqueSelectionBehavior}"/>
  

MainWindow.xaml文件的其余部分与上一个示例几乎相同,除了我们使用“TheButtonsWithSelectionBehaviorVM”集合作为ItemsControlItemsSource

结果,我们获得了与上一个示例相同的单选按钮行为。

最后两个项目选择行为

此示例对同一集合实现了不同的行为。根据此行为,我们允许集合中最多同时选中两个项目。当用户选择第三个项目时,选中时间最长的项目将取消选中。

此示例位于“TwoLastItemsSelectionBehaviorSample”解决方案中。除了行为之外,其代码与上一个示例几乎相同。我们使用TwoLastItemSelectionBehaviorOnSelectableWithDisplayName,而不是UniqueSelectionBehaviorOnSelectableWithDisplayName,后者派生自TwoLastItemsSelectionBehavior<SelectableItemWithDisplayName>类。

所有行为代码都位于超类TwoLastItemsSelectionBehavior<T>中。就像UniqueSelectionBehavior<T>一样,它派生自SelectionBehaviorBase<T>并重写了OnItemSelectionChanged(ISelectable item)方法(提醒一下——每当集合中任何项目的IsItemSelected属性发生变化时,都会调用此方法)。

TwoLastItemsSelectionBehavior<T>有一个List<T>字段_selectedItems,它控制选择和取消选择。它包含当前选定的项目,其中选择时间越长的项目越靠近集合的末尾。控制向_selectedItems集合添加新项目的方法是AddSelectedItem(T itemToAdd)

// collection of selected items
List<T> _selectedItems = new List<T>();

// returns the number of selected items
int NumberSelectedItems
{
    get
    {
        return _selectedItems.Count;
    }
}

// if the number of selected items within _selectedItems
// collection is 2, unselect the item that has been selected the longest
// (last item within the collection)
// add a newly selected item to beginning of _selectedItems collection.
void AddSelectedItem(T itemToAdd)
{   
    if (NumberSelectedItems >= 2) 
    {
        // if the current number of selected item is 2 (or greater - which cannot happen)
        // remove the last item within the _selecteItems collection
        T itemToRemove = _selectedItems.Last();
        _selectedItems.Remove(itemToRemove); // remove last item

        // set the IsItemSelected property on the 
        // removed item to false
        itemToRemove.IsItemSelected = false;
    }

    // insert the newly selected item 
    // at the beginning of the collection
    _selectedItems.Insert(0, itemToAdd);
}  

AddSelectedItem(T itemToAdd)OnItemSelectionChanged(ISelectable item)方法调用。

protected override void OnItemSelectionChanged(ISelectable item)
{
    if (!item.IsItemSelected)
    {
        // if item is no longer selected, remove it from _selected items collection
        _selectedItems.Remove((T)item);
    }
    else
    {
        // if the item is selected, add it to the _selectedItems collection
        AddSelectedItem((T)item);
    }
    OnPropChanged("SelectedItems");
    OnPropChanged("NumberSelectedItems");
}  

现在,MainWindow.xaml 文件(与上一个示例相比)的唯一微小更改是定义 TwoLastItemSelectionBehaviorOnSelectableWithDisplayName 对象,并将其用作 MyTestButtonsWithSelectionAndBehavior 对象上的行为,而不是 UniqueSelectionBehaviorOnSelectableWithDisplayName

<viewModels:TwoLastItemSelectionBehaviorOnSelectableWithDisplayName x:Key="TheTwoLastItemsSelectionBehavior" />

<testViewModels:MyTestButtonsWithSelectionAndBehavior x:Key="TheButtonsWithSelectionBehaviorVM"
                                                      TheBehavior="{StaticResource TheTwoLastItemsSelectionBehavior}" />

当我们运行示例时,我们将获得所需的行为——如果选中两个按钮,然后单击另一个按钮,则选中时间最长的按钮将取消选中。

请注意,我们更改了行为,而没有更改视图或视图模型,只更改了附加到视图模型的行为。因此,我们展示了如何以最小的侵入性修改项目集合的行为。

另请注意,只需更改切换按钮的样式/模板,我们就可以使用复选框生成相同的行为。

上述是通过将MainWindow.xaml文件中定义的ItemButtonDataTemplate中对ToggleButton_ButtonStyle的引用替换为对MyToggleButtonCheckBoxStyle的引用来实现的。

我们可以很容易地将2个最后项目的选择泛化为N个最后项目的选择,只需将行为中的数字2替换为一个整数参数。此外,我们可以很容易地基于相同的原理创建更复杂的选择行为。

我使用了“最后两个项目选择行为”来在同一个 Dev Express 的 ChartControl 对象中显示多个图表,对应于两个不同的 Y 轴——一个在左边,一个在右边。如果您有超过 2 个图表,您可以使用“最后两个项目选择原则”,允许用户选择其中任意两个进行显示。

递归数据模板

许多概念,无论是在现实世界还是在软件开发中,都具有递归树结构,其中项目可以用树节点表示——每个节点可能具有多个子节点和一个父节点。没有子节点的节点称为树的叶子。只有一个没有父节点的节点,它被称为树的根。

此类树的示例包括文件系统、组织内部的报告结构、WPF 的逻辑树和可视树。

事实证明,视图中的此类树可以很容易地由引用自身的 WPF DataTemplates 表示——即递归数据模板。

递归数据模板示例位于 RecursiveDatateTemplatesSample 解决方案中,主项目同名。如果您运行解决方案并展开其所有节点,您将看到以下内容:

您可以看到,这是一个模拟文件系统(与您机器上的文件系统无关)的树形表示。它有一个名为 Root 的文件夹,其中包含 Documents 和 Pictures 两个文件夹以及文件 MySystemFile。Documents 文件夹包含 Document1 和 Document2 两个文件,而 Picture 文件夹包含 Picture1 和 Picture2。

如您所见,我们并没有使用 WPF 的 TreeView 控件来显示此文件系统。

首先,让我们解释代表此树结构的视图模型。

ViewModels项目下的TreeNodeBase类是树节点的基本类。它具有ParentChildren属性,分别表示树节点的父节点和子节点。它还有一个HasChildren属性,如果Children集合为null或为空,则为false,否则为true

public class TreeNodeBase : INotifyPropertyChanged
{
    #region INotifyPropertyChanged Members
    public event PropertyChangedEventHandler PropertyChanged;
    #endregion

    protected void OnPropertyChanged(string propertyName)
    {
        if (this.PropertyChanged != null)
        {
            this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    #region Parent Property
    private TreeNodeBase _parent;
    public TreeNodeBase Parent
    {
        get
        {
            return this._parent;
        }
        set
        {
            if (this._parent == value)
            {
                return;
            }

            this._parent = value;
            this.OnPropertyChanged("Parent");
        }
    }
    #endregion Parent Property

    #region Children Property
    private ObservableCollection _children;
    public ObservableCollection Children
    {
        get
        {
            return this._children;
        }
        set
        {
            if (this._children == value)
            {
                return;
            }

            if (this._children != null)
            {
                this._children.CollectionChanged -= _children_CollectionChanged;
            }

            this._children = value;

            if (this._children != null)
            {
                this._children.CollectionChanged += _children_CollectionChanged;
            }

            this.OnPropertyChanged("Children");
            this.OnPropertyChanged("HasChildren");
        }
    }

    void _children_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
    {
        // collection changed, so fire the PropertyChanged event for HasChildren property.
        this.OnPropertyChanged("HasChildren");
    }
    #endregion Children Property


    public bool HasChildren
    {
        get
        {
            return (Children != null) && (Children.Count > 0);
        }
    }
}  

ViewModels.FileTreeNode派生自TreeNodeBase

public class FileTreeNode : TreeNodeBase
{
    // true for a file, false for a folder
    public bool FileOrFolder { get; set; }

    // name of the file or folder
    public string Name { get; set; }

    // adds a new child and returns it
    public FileTreeNode AddChild(bool fileOrFolder, string name)
    {
        if (this.FileOrFolder)
            throw new Exception("Cannot add a child to a file");

        if (this.Children == null)
        {
            this.Children = new ObservableCollection();
        }

        FileTreeNode newNode = new FileTreeNode
        {
            FileOrFolder = fileOrFolder,
            Name = name,
            Parent = this
        };

        Children.Add(newNode);

        return newNode;
    }


    #region IsExpanded Property
    private bool _isExpanded = false;
    public bool IsExpanded
    {
        get
        {
            return this._isExpanded;
        }
        set
        {
            if (this._isExpanded == value)
            {
                return;
            }

            this._isExpanded = value;
            this.OnPropertyChanged("IsExpanded");
        }
    }
    #endregion IsExpanded Property
}  

它有一个布尔属性FileOrFolder,当节点是文件时为 true,当节点是文件夹时为 false。它还有一个Name属性,对应于文件或文件夹的名称。最后,它定义了IsExpanded属性,我们用它来指定相应的节点是否展开(您可以看到它的子节点)。

还有一个public FileTreeNode AddChild(bool fileOrFolder, string name)方法,允许向当前节点添加子节点,并指定子节点的FileOrFolderName属性。它返回子节点本身,以便可以向新创建的子节点添加子节点。

此示例的视图模型由TestViewModels项目下定义的MySimpleFileSystem类构建。它派生自FileTreeNode类。MySimpleFileSystem在其构造函数中创建文件系统树。它将自己的名称设置为“Root”,并将其标记为文件夹。然后它向其中添加“Documents”和“Pictures”文件夹以及文件“MySystemFile”。最后,它填充了其每个子文件夹。

public MySimpleFileSystem()
{
    this.Name = "Root";
    this.FileOrFolder = false; // root folder;

    // create the docFolder object
    FileTreeNode docFolder = this.AddChild(false, "Documents");

    // create the pictureFolder object
    FileTreeNode pictureFolder = this.AddChild(false, "Pictures");

    // create the systemFile object
    FileTreeNode systemFile = this.AddChild(true, "MySystemFile");

    // add two child files Document1 and Document2 to the docFolder object
    docFolder.AddChild(true, "Document1");
    docFolder.AddChild(true, "Document2");

    // add two child files Picture1 and Picture2 to the pictureFolder object
    pictureFolder.AddChild(true, "Picture1");
    pictureFolder.AddChild(true, "Picture2");
}  

此示例的视觉效果定义在RecursiveDataTemplateSample主项目的MainWindow.xaml文件中。我们在此文件中将树视图模型定义为Window的资源。

<!-- The tree-like View Model-->
<testViewModels:MySimpleFileSystem x:Key="TheFileSystem" />

递归数据模板在同一个文件中定义,名为TheFileRepresentationDataTemplate。

<DataTemplate x:Key="TheFileTreeRepresentationDataTemplate"
              DataType="viewModels:FileTreeNode">
    <StackPanel Orientation="Vertical">
        <StackPanel Orientation="Horizontal"
                    HorizontalAlignment="Left">
            <!-- Expander toggle button, it only visible for the items that have children -->
            <customControl:MyToggleButton Style="{StaticResource ToggleButtonExpanderStyle}"
                                          IsChecked="{Binding Path=IsExpanded, Mode=TwoWay}"
                                          Visibility="{Binding Path=HasChildren,
                                                           Converter={StaticResource TheBooleanToVisibilityConverter}}" />
            <!--name of the file or folder-->
            <TextBlock Margin="10,0,0,0"
                       Text="{Binding Name}" />
        </StackPanel>

        <!-- This is the ItemsControl that represents the children of the current node
              It is only visible if the current node is expanded-->
        <!-- Note that ItemTemplate of the ItemsControl is set to the 
             DataTemplate we are currently in: TheFileTreeRepresentation.
             This is a recursive template. 
        -->
        <ItemsControl Margin="30,0,0,0"
                      ItemsSource="{Binding Children}"
                      ItemTemplate="{DynamicResource TheFileTreeRepresentationDataTemplate}"
                      Visibility="{Binding Path=IsExpanded, Converter={StaticResource TheBooleanToVisibilityConverter}}" />
    </StackPanel>
</DataTemplate>  

请注意,当前节点的ChildrenItemsControl引用了相同的数据模板——TheFileTreeRepresentationDataTemplate:ItemTemplate="{DynamicResource TheFileTreeRepresentationDataTemplate}"。我们在这里被迫使用DynamicResource,因为DataTemplate引用了自身。

最后,我们通过使用ContentControlDataTemplate和视图模型“结合”起来。

<ContentControl ContentTemplate="{StaticResource TheFileTreeRepresentationDataTemplate}"
                Content="{StaticResource TheFileSystem}" 
                HorizontalAlignment="Left"
                VerticalAlignment="Top"
                Margin="20,20,0,0"/>

请注意,相同的递归DataTemplate模式可用于比示例中所示复杂得多的控件。例如,我曾用它来显示大型组织的完整组织结构图。

结论

在本文中,我们介绍了可用于创建模仿由非可视化对象定义的行为的视觉元素的模式——视图模仿视图模型的行为。下一篇文章将讨论更复杂的(架构)模式。

© . All rights reserved.