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

可搜索的 WPF TreeView

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (68投票s)

2014年1月29日

CPOL

12分钟阅读

viewsIcon

178925

downloadIcon

8752

展示如何修剪 TreeView 的节点以进行搜索。

引言

在本文中,我将演示一种 WPF 的方法,如何创建一个可以应用筛选来搜索树中的特定节点或项的树状视图。我发现使用筛选来修剪或限制树状视图非常有用,而且当我无法快速找到我要更改的选项(例如在 Visual Studio Options 中)时,我真的会感到恼火。我通常对我要找的东西有一个大概的了解,通过输入一部分内容来查找通常比目视检查整个树更快,Window 7 的开始菜单或 Windows 8 的 UI 都是这种方法得到良好利用的绝佳示例。

这显然不是一个新问题,网络上也不乏示例实现,本文基于我为朋友做的一个东西,我在 YouTube 上发布后收到了不少索要源代码的请求,所以现在分享出来。

由于主题内容有限,本文将相对简短。

Using the Code

提供了两个存档,一个用于 C#,一个用于 VB.NET,以便每个人都可以使用自己喜欢的语言阅读源代码。

对于本文,由于涉及的代码量非常少,我决定同时包含 C#VB.NET 代码。

要求

当我的朋友要求实现这个功能时,他给了我一个简短的需求列表,要求它必须满足:

  1. 它必须基于 System.Windows.Controls.TreeView
  2. 在未进行筛选时,树状视图应表现得像一个普通的树状视图。
  3. 文本输入字段应实时接受输入来修剪树状视图(实时意味着不需要按 Enter 或类似按钮来触发筛选)。
  4. 筛选条件应被记住,以便于重用(我个人认为这有点多余,因为当用于筛选的标准足够简单,容易记住时,这种控件才真正有用)。
  5. 文本输入字段不应占用太多屏幕空间,同时又要足够明显,以便首次使用的用户能够找到并理解。

此外,实现组件应有利于 MVVM 方法,因为 UI 设计师很可能会更改视觉外观。

基础知识

我决定我的实现将是一个包含 TreeView(满足需求 #1)的 DataTemplate,以及一个可编辑的 ComboBox,它既可以用作输入筛选条件的方式,也可以作为先前条件的列表(满足需求 #3 和 #4)。

注意:由于这是为了适应保存应用程序设置的内容而构建的,因此 DataTemplate(以及配套的 view-model)命名为 Settings-something,这对于通用实现来说有点太具体了,但我还是保留了它。

我想要一个稍好一些的树状视图外观,它主要在稍后将介绍的 Style 中定义。筛选 ComboBox 有一个图标,也主要由 Style 覆盖,因为为了满足要求 #5,我希望它在未获得焦点时能动画缩小为一个较小的元素,然后在需要时恢复到完整大小。用于“控件”的 DataTemplateXAML 如下所示;

<DataTemplate DataType="{x:Type vm:SettingsViewModel}">
    <Grid>
        <TreeView Style="{StaticResource ResourceKey=SearchableTreeView}" 
                  ItemsSource="{Binding Path=Roots, Mode=OneWay}"/>
        <Border Style="{StaticResource ResourceKey=SearchBox}">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <Image Grid.Column="0" Source="pack://application:,,,/Resources/Images/Search.png"/>
                <ComboBox Grid.Column="1"
                            IsEditable="True"
                            ItemsSource="{Binding Path=PreviousCriteria, Mode=OneWay}"
                            SelectedItem="{Binding Path=SelectedCriteria}"
                            Text="{Binding Path=CurrentCriteria, 
                                  UpdateSourceTrigger=PropertyChanged}"
                            i:EventCommand.Event="UIElement.LostFocus"
                            i:EventCommand.Command=
                                "{Binding Path=StoreInPreviousCommand, Mode=OneWay}"/>
            </Grid>
        </Border>
    </Grid>
</DataTemplate>        

稍后我将介绍 i:EventCommand.Eventi:EventCommand.Command 属性的用途。

应用了 SearchBox StyleBorder 基本上就是搜索“控件”。

TreeView 样式

TreeViewStyle 主要处理 TreeView 的视觉方面,例如设置一个包含图标和节点名称的 ItemTemplate,它还设置了定义每个节点如何提供子节点的 HierarchicalDataTemplate
使用 ItemContainerStyle,它还配置了处理折叠/展开、可见/不可见和选中/未选中状态的绑定。

树节点知道它当前是否被当前搜索条件覆盖,并通过 view-model 上的名为 IsMatch 的属性暴露出来,这样搜索条件的更改就可以传播到所有节点,并设置 IsMatch 状态,从而根据用户的搜索结果使节点可见或隐藏。搜索时不仅仅是查看当前节点,因为如果一个子节点匹配但父节点不匹配,我们仍然希望父节点可见,以便清楚地显示到找到的节点的路径。我将在稍后详细介绍 view-model 实现时对此进行更详细的介绍。
TreeViewStyle 如下所示

<Style x:Key="SearchableTreeView" TargetType="{x:Type TreeView}">
    <Setter Property="Background" Value="Transparent"/>
    <Setter Property="ItemContainerStyle">
        <Setter.Value>
            <Style TargetType="{x:Type TreeViewItem}">
                <Setter Property="BorderThickness" Value="1.5"/>
                <Setter Property="IsExpanded" Value="{Binding Path=IsExpanded, Mode=TwoWay}" />
                <Setter Property="Visibility" Value="{Binding Path=IsMatch, Mode=OneWay, 
                        Converter={StaticResource ResourceKey=boolToVisibility}}"/>
                <Style.Triggers>
                    <Trigger Property="IsSelected" Value="True">
                        <Setter Property="BorderBrush" Value="#FFABC0F0"/>
                    </Trigger>
                    <MultiTrigger>
                        <MultiTrigger.Conditions>
                            <Condition Property="IsSelected" Value="True"/>
                            <Condition Property="IsSelectionActive" Value="False"/>
                        </MultiTrigger.Conditions>
                        <Setter Property="BorderBrush" Value="LightGray"/>
                    </MultiTrigger>
                </Style.Triggers>
                <Style.Resources>
                    <Style TargetType="Border">
                        <Setter Property="CornerRadius" Value="3"/>
                    </Style>
                </Style.Resources>
            </Style>
        </Setter.Value>
    </Setter> 
        
    <Setter Property="ItemTemplate">
        <Setter.Value>
            <HierarchicalDataTemplate DataType="{x:Type vm:TreeNodeViewModel}" 
                       ItemsSource="{Binding Path=Children, Mode=OneWay}">
                <StackPanel Orientation="Horizontal" Margin="2 0 4 0">
                    <Image Width="18" Height="18" Margin="0 0 4 0" 
                       Source="{Binding Converter={StaticResource ResourceKey=treeNode}}"/>
                    <TextBlock Text="{Binding Path=Name, Mode=OneWay}" />
                </StackPanel>
            </HierarchicalDataTemplate>
        </Setter.Value>
    </Setter>
    
    <Style.Resources>
        <SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="Black" />
        <SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}" Color="Black" />
        <LinearGradientBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" 
                    EndPoint="0,1" StartPoint="0,0">
            <GradientStop Color="#FFE0F0FF" Offset="0"/>
            <GradientStop Color="#FFABE0FF" Offset="1"/>
        </LinearGradientBrush>
        <LinearGradientBrush x:Key="{x:Static SystemColors.ControlBrushKey}" 
                    EndPoint="0,1" StartPoint="0,0">
            <GradientStop Color="#FFEEEEEE" Offset="0"/>
            <GradientStop Color="#FFDDDDDD" Offset="1"/>
        </LinearGradientBrush>
    </Style.Resources>
</Style>        

SearchBox 样式

构成搜索框的 BorderStyle 设置了一些视图属性,并定义了允许搜索字段在获得焦点时“弹出”的动画。
这是通过将 Storyboard 连接到 路由事件 Mouse.MouseEnterMouse.MouseExit 来实现的。

<Style x:Key="SearchBox" TargetType="{x:Type Border}">
    <Style.Resources>
        <ElasticEase x:Key="EaseInEase" 
        EasingMode="EaseOut" Oscillations="2" Springiness="7"/>
        <SineEase x:Key="EaseOutEase" EasingMode="EaseIn"/>
    </Style.Resources>

    <Setter Property="Width" Value="16"/>
    <Setter Property="Height" Value="16"/>
    <Setter Property="HorizontalAlignment" Value="Right"/>
    <Setter Property="VerticalAlignment" Value="Top"/>
    <Setter Property="Margin" Value="4 4 20 4"/>
    <Setter Property="CornerRadius" Value="3"/>
    <Setter Property="BorderBrush" Value="DarkGray"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Setter Property="Padding" Value="2"/>
    <Setter Property="Background">
        <Setter.Value>
            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                <GradientStop Color="#F0F0F0" Offset="0.0" />
                <GradientStop Color="#C0C0C0" Offset="1.0" />
            </LinearGradientBrush>
        </Setter.Value>
    </Setter>

    <Style.Triggers>
        <EventTrigger RoutedEvent="Mouse.MouseEnter">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="(Border.Width)" 
                     EasingFunction="{StaticResource ResourceKey=EaseInEase}" 
                     To="200" Duration="0:0:1.0"/>
                    <DoubleAnimation Storyboard.TargetProperty="(Border.Height)" 
                     EasingFunction="{StaticResource ResourceKey=EaseInEase}" 
                     To="30" Duration="0:0:1.0"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
        <EventTrigger RoutedEvent="Mouse.MouseLeave">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="(Border.Width)" 
                     EasingFunction="{StaticResource ResourceKey=EaseOutEase}" 
                     To="16" Duration="0:0:0.2"/>
                    <DoubleAnimation Storyboard.TargetProperty="(Border.Height)" 
                     EasingFunction="{StaticResource ResourceKey=EaseOutEase}" 
                     To="16" Duration="0:0:0.2"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Style.Triggers>
</Style>        

顶部是使用的两个缓动函数,令人困惑的是,EaseInEase 使用 EasingMode EaseOut,而 EaseOutEase 使用 EaseIn。我用 EaseInEase 来指代将搜索框拉出的动画(它会缓和退出),用 EaseInEase 来指代使用后“隐藏”搜索框的动画。

我花了一些时间才将这些动画的缓动函数和持续时间设置正确,我不想让它们运行太长时间(那样会很烦人,不得不等待搜索框可用),但我也希望它们看起来流畅。最后,我得到了上面的效果,我不太满意,但我觉得看起来还可以。

记住先前的条件

对于需求 #4,搜索框需要记住先前使用的条件,我希望它在搜索框失去焦点时存储它们,因为我认为那时用户已经找到了他们想要的东西并点击了它。重要的是只存储相关的条件,例如,在 PropertyChanged 时存储会存储过多信息,因为它会在用户输入条件时存储部分字符串。

采用 MVVM 方法,我希望视图ComboBox 失去焦点时,在view-model上执行一个 ICommand。为了实现这一点,我采用了一种我认为相当丑陋的解决方案,即使用附加属性将命令绑定到 RoutedEvent 的触发。
这方面的 XAML 可以在上面列出的 SettingsViewModelDataTemplate 中看到;

<ComboBox Grid.Column="1"
            IsEditable="True"
            ItemsSource="{Binding Path=PreviousCriteria, Mode=OneWay}"
            SelectedItem="{Binding Path=SelectedCriteria}"
            Text="{Binding Path=CurrentCriteria, UpdateSourceTrigger=PropertyChanged}"
            i:EventCommand.Event="UIElement.LostFocus"
            i:EventCommand.Command="{Binding Path=StoreInPreviousCommand, Mode=OneWay}"/>        

最后两个属性设置了 RoutedEventEventCommand 属性,以及到view modelICommand 的绑定。虽然这在 XAML 中看起来不太丑,但附加属性的实现却必须使用反射和奇怪的方法来创建委托;

public static class EventCommand {
    private static readonly MethodInfo HandlerMethod = typeof(EventCommand).GetMethod
                      ("OnEvent", BindingFlags.NonPublic | BindingFlags.Static);

    public static readonly DependencyProperty EventProperty = DependencyProperty.RegisterAttached
    ("Event", typeof(RoutedEvent), typeof(EventCommand), new PropertyMetadata(null, OnEventChanged));
    public static readonly DependencyProperty CommandProperty = DependencyProperty.RegisterAttached
    ("Command", typeof(ICommand), typeof(EventCommand), new PropertyMetadata(null));

    public static void SetEvent(DependencyObject owner, RoutedEvent value) {
        owner.SetValue(EventProperty, value);
    }

    public static RoutedEvent GetEvent(DependencyObject owner) {
        return (RoutedEvent)owner.GetValue(EventProperty);
    }

    public static void SetCommand(DependencyObject owner, ICommand value) {
        owner.SetValue(CommandProperty, value);
    }

    public static ICommand GetCommand(DependencyObject owner) {
        return (ICommand)owner.GetValue(CommandProperty);
    }

    private static void OnEventChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) {
        if (e.OldValue != null) {
            var @event = d.GetType().GetEvent(((RoutedEvent)e.OldValue).Name);
            @event.RemoveEventHandler
                  (d, Delegate.CreateDelegate(@event.EventHandlerType, HandlerMethod));
        }

        if (e.NewValue != null) {
            var @event = d.GetType().GetEvent(((RoutedEvent)e.NewValue).Name);
            @event.AddEventHandler
              (d, Delegate.CreateDelegate(@event.EventHandlerType, HandlerMethod));
        }
    }

    private static void OnEvent(object sender, EventArgs args) {
        var command = GetCommand((DependencyObject)sender);
        if (command != null && command.CanExecute(null))
            command.Execute(null);
    }
}  
Module EventCommand
  Public ReadOnly HandlerMethod As MethodInfo = GetType(EventCommand).GetMethod
                       ("OnEvent", BindingFlags.NonPublic Or BindingFlags.Static)

  Public ReadOnly EventProperty As DependencyProperty = DependencyProperty.RegisterAttached
      ("Event", GetType(RoutedEvent), GetType(EventCommand), 
      New PropertyMetadata(Nothing, AddressOf OnEventChanged))
  Public ReadOnly CommandProperty As DependencyProperty = DependencyProperty.RegisterAttached
        ("Command", GetType(ICommand), GetType(EventCommand), New PropertyMetadata(Nothing))

  Public Sub SetEvent(ByVal element As UIElement, ByVal value As RoutedEvent)
    element.SetValue(EventProperty, value)
  End Sub

  Public Function GetEvent(ByVal element As UIElement) As RoutedEvent
    Return CType(element.GetValue(EventProperty), RoutedEvent)
  End Function

  Public Sub SetCommand(ByVal element As UIElement, ByVal value As ICommand)
    element.SetValue(CommandProperty, value)
  End Sub

  Public Function GetCommand(ByVal element As UIElement) As ICommand
    Return CType(element.GetValue(CommandProperty), ICommand)
  End Function

  Private Sub OnEventChanged(d As DependencyObject, e As DependencyPropertyChangedEventArgs)
    If Not IsNothing(e.OldValue) Then
      Dim evt As EventInfo = d.GetType().GetEvent(CType(e.OldValue, RoutedEvent).Name)
      evt.RemoveEventHandler(d, System.Delegate.CreateDelegate(evt.EventHandlerType, HandlerMethod))
    End If

    If Not IsNothing(e.NewValue) Then
      Dim evt As EventInfo = d.GetType().GetEvent(CType(e.NewValue, RoutedEvent).Name)
      evt.AddEventHandler(d, System.Delegate.CreateDelegate(evt.EventHandlerType, HandlerMethod))
    End If
  End Sub

  Private Sub OnEvent(sender As Object, args As EventArgs)
    Dim command As ICommand = GetCommand(CType(sender, DependencyObject))
    If Not command Is Nothing And command.CanExecute(Nothing) Then
      command.Execute(Nothing)
    End If
  End Sub
End Module      

这段代码唯一的优点是它似乎完成了任务,并且允许将 ICommand 绑定到任何 RoutedEvent

注意解决此问题的另一种方法是使用一种更具体的附加属性类型,该类型在代码中显式地连接相关事件并将它们委托给view-model上的方法。这种做法通常称为附加行为。虽然我不讨厌这种方法,但我更希望为此提供一个更通用的(这在技术上是愚蠢的,因为我违反了 YAGNI 规则)。

注意还有另一种解决方法,如果依赖 Blend 库不是问题,可以使用 Blend Interaction 命名空间中的 EventTriggers,它与上面的代码功能相同,甚至更多。

View-models

该项目包含两个view-models;一个用于拥有树和搜索的主视图,另一个用于每个单独的树节点。技术上,还应该有相应的models,但我为了简洁起见,在文章中省略了它们。

TreeNodeViewModel

支持 TreeView 节点的view-model基本上是一个标准的树节点,并增加了一个方法

public void ApplyCriteria(string criteria, Stack<TreeNodeViewModel> ancestors)        
Public Sub ApplyCriteria(criteria As String, ancestors As Stack(Of TreeNodeViewModel))      

该方法允许节点应用搜索条件。该方法同时接受 string 条件和 Stack<TreeNodeViewModel> 的祖先,是因为如果节点本身找到匹配项,它需要能够使其所有祖先都可见。

一个更丰富的实现可能会在匹配节点的祖先上设置不同的状态,以便那些仅因为子节点是搜索匹配项而可见的祖先可以以略微不同的方式渲染。

节点将检查它是否匹配条件,如果匹配,它将遍历所有祖先,将它们设置为匹配,并展开它们。

如果节点不是叶子节点,它将把自己作为祖先推入祖先的 Stack<TreeNodeViewModel> 并递归遍历所有子节点。完成后,它将自己从堆栈中弹出。仅当节点是匹配项的祖先时,它才会自动展开,而不是仅仅因为它是匹配项就展开,因为那样可能会使不正确的子节点可见。

        private void CheckChildren(string criteria, TreeNodeViewModel parent) {
            foreach (var child in parent.Children) {
                if (child.IsLeaf && !child.IsCriteriaMatched(criteria)) {
                    child.IsMatch = false;
                }
                CheckChildren(criteria, child);
            }
        }

        public void ApplyCriteria(string criteria, Stack<TreeNodeViewModel> ancestors) {
            if (IsCriteriaMatched(criteria)) {
                IsMatch = true;
                foreach (var ancestor in ancestors) {
                    ancestor.IsMatch = true;
                    ancestor.IsExpanded = !String.IsNullOrEmpty(criteria);
                    CheckChildren(criteria, ancestor);
                }
                IsExpanded = false;
            } 
            else 
                IsMatch = false;

            ancestors.Push(this);
            foreach (var child in Children)
                child.ApplyCriteria(criteria, ancestors);

            ancestors.Pop();
        }        
        Private Sub CheckChildren(criteria As String, parent As TreeNodeViewModel)
            For Each child In parent.Children
                If child.IsLeaf And Not child.IsCriteriaMatched(criteria) Then
                    child.IsMatch = False
                End If
                CheckChildren(criteria, child)
            Next
        End Sub

        Public Sub ApplyCriteria(criteria As String, ancestors As Stack(Of TreeNodeViewModel))
            If IsCriteriaMatched(criteria) Then
                IsMatch = True
                For Each ancestor In ancestors
                    ancestor.IsMatch = True
                    ancestor.IsExpanded = Not String.IsNullOrWhiteSpace(criteria)
                    CheckChildren(criteria, ancestor)
                Next
                IsExpanded = False
            Else
                IsMatch = False
            End If

            ancestors.Push(Me) ' and then just touch me
            For Each child In Children
                child.ApplyCriteria(criteria, ancestors)
            Next
            ancestors.Pop()
        End Sub
      

决定该节点是否匹配的方法在本文中是一个简单的 string 比较

private bool IsCriteriaMatched(string criteria) {
    return String.IsNullOrEmpty(criteria) || name.Contains(criteria);
} 
Private Function IsCriteriaMatched(criteria As String) As Boolean
  Return String.IsNullOrEmpty(criteria) Or Name.Contains(criteria)
End Function      

null 或空 string 被认为是匹配项可能看起来很奇怪,但这是为了覆盖没有输入任何条件的情况,因为那样应该使所有节点可见。

一个真正的实现更可能让构造函数接受一个委托,该委托可以在条件和底层model上运行,而不是仅仅查看名称,这样就可以将条件应用于节点所持有的内容,而不仅仅是节点名称。

例如,可以基于各种上下文进行过滤,如用户权限。

view-model的完整列表如下

public class TreeNodeViewModel : Notifier {
    private readonly ObservableCollection<TreeNodeViewModel> children = 
                              new ObservableCollection<TreeNodeViewModel>();
    private readonly string name;

    private bool expanded;
    private bool match = true;
    private bool leaf;

    private TreeNodeViewModel(string name, bool leaf) {
        this.name = name;
        this.leaf = leaf;
    }

    public TreeNodeViewModel(string name, IEnumerable<TreeNodeViewModel> children) 
        : this(name, false) {
        foreach (var child in children)
            this.children.Add(child);
    }

    public TreeNodeViewModel(string name) 
        : this(name, true) {
    }

    public override string ToString() {
        return name;
    }

    private bool IsCriteriaMatched(string criteria) {
        return String.IsNullOrEmpty(criteria) || name.Contains(criteria);
    }

    public void ApplyCriteria(string criteria, Stack<TreeNodeViewModel> ancestors) {
        if (IsCriteriaMatched(criteria)) {
            IsMatch = true;
            foreach (var ancestor in ancestors) {
                ancestor.IsMatch = true;
                ancestor.IsExpanded = !String.IsNullOrEmpty(criteria);
            }
        } 
        else
            IsMatch = false;

        ancestors.Push(this);
        foreach (var child in Children)
            child.ApplyCriteria(criteria, ancestors);

        ancestors.Pop();
    }

    public IEnumerable<TreeNodeViewModel> Children {
        get { return children; }
    }

    public string Name {
        get { return name; }
    }

    public bool IsExpanded {
        get { return expanded; }
        set {
            if (value == expanded) 
                return;

            expanded = value;
            if (expanded) {
                foreach (var child in Children)
                    child.IsMatch = true;
            }
            OnPropertyChanged("IsExpanded");
        }
    }

    public bool IsMatch {
        get { return match; }
        set {
            if (value == match)
                return;

            match = value;
            OnPropertyChanged("IsMatch");
        }
    }

    public bool IsLeaf {
        get { return leaf; }
        set {
            if (value == leaf) 
                return;

            leaf = value;
            OnPropertyChanged("IsLeaf");
        }
    }
}        
Public Class TreeNodeViewModel
    Inherits Notifier

    Private ReadOnly childNodes As ObservableCollection(Of TreeNodeViewModel)
    Private ReadOnly nodeName As String

    Private expanded As Boolean
    Private match As Boolean = True

    Sub New(name As String, children As IEnumerable(Of TreeNodeViewModel))
        childNodes = New ObservableCollection(Of TreeNodeViewModel)(children)
        nodeName = name
    End Sub

    Sub New(name As String)
        Me.New(name, Enumerable.Empty(Of TreeNodeViewModel))
    End Sub

    Public Overrides Function ToString() As String
        Return nodeName
    End Function

    Private Function IsCriteriaMatched(criteria As String) As Boolean
        Return String.IsNullOrEmpty(criteria) Or Name.Contains(criteria)
    End Function

    Public Sub ApplyCriteria(criteria As String, ancestors As Stack(Of TreeNodeViewModel))
        If IsCriteriaMatched(criteria) Then
            IsMatch = True
            For Each ancestor In ancestors
                ancestor.IsMatch = True
                ancestor.IsExpanded = Not String.IsNullOrWhiteSpace(criteria)
            Next
        Else
            IsMatch = False
        End If

        ancestors.Push(Me) ' and then just touch me
        For Each child In Children
            child.ApplyCriteria(criteria, ancestors)
        Next
        ancestors.Pop()
    End Sub

    Public ReadOnly Property Children() As IEnumerable(Of TreeNodeViewModel)
        Get
            Return childNodes
        End Get
    End Property

    Public ReadOnly Property Name() As String
        Get
            Return nodeName
        End Get
    End Property

    Public Property IsExpanded() As Boolean
        Get
            Return expanded
        End Get
        Set(value As Boolean)
            If expanded = value Then Return

            expanded = value
            If expanded Then
                For Each child In Children
                    child.IsMatch = True
                Next
            End If

            OnPropertyChanged("IsExpanded")
        End Set
    End Property

    Public Property IsMatch() As Boolean
        Get
            Return match
        End Get
        Set(value As Boolean)
            If match = value Then Return

            match = value
            OnPropertyChanged("IsMatch")
        End Set
    End Property

    Public ReadOnly Property IsLeaf() As Boolean
        Get
            Return Not Children.Any()
        End Get
    End Property
End Class        

注意IEnumerable<TreeNodeViewModel> 也能完成任务时,将子节点包含在 ObservableCollection<TreeNodeViewModel> 中可能显得有点小题大做。本文中这样做的原因是,实际的完整源代码在应用某些设置时会动态地将节点添加到树中。

SettingsViewModel

SettingsViewModel 的职责是拥有树视图根节点(是的,复数)的引用,以及处理搜索条件的更改。
它通过监听条件的变化(由属性 CurrentCriteria 覆盖,该属性使用 PropertyChanged 设置的 UpdateSourceTrigger 绑定属性进行绑定)来完成此操作,并调用 ApplyFilter 方法,该方法遍历所有根节点并调用 TreeNodeViewModel.ApplyCriteria,然后递归遍历节点,如前所述。

当失去焦点时,基于 EventCommand 的方法会从 StoreInPreviousCommand 属性公开的命令触发一个 ICommand。此命令会将当前条件添加到现有先前使用条件的列表中,前提是它不为空且列表中尚不存在。然后它设置 SelectedCriteria 属性,使后备的 ComboBox 考虑从列表中选择的值。

view-model公开三个属性来跟踪条件

  • CurrentCriteria,它绑定到 ComboBox 的可编辑文本
  • SelectedCriteria,它绑定到 ComboBox 项目列表(先前值)中的选定项
  • PreviousCriteria,它绑定到一个包含所有先前使用的条件的列表

除了上面列出的三个属性外,view-model还公开了焦点丢失时触发的命令以及树状视图使用的根列表。

我没有实现但可能应该实现的一个功能是,只有当应用某个条件能产生非空可见树节点集时,SettingsViewModel 才存储先前条件。存储一个永远找不到任何东西的条件没有意义,存储它只会污染下拉列表,使其更难找到相关条件。

关注点

许多使之成为完整实现的部分已被省略,例如所有models以及树状视图叶子的任何实际内容或载荷(显然选择或双击一个叶子应该向用户展示的不只是节点被轻微高亮显示),但我认为剩余部分展示了有趣的部分,我认为这些是

  • MVVM 式树状视图
  • 将命令绑定到任何事件
  • 实时”筛选节点作为搜索手段

诸如值转换器之类的琐碎部分已被省略,但包含在源代码中。

项目的组织遵循了我经常使用但现在开始厌恶的一种模式。有用于view-models、数据模板和值转换器(例如)的文件夹,我认为这种组织方式是错误的。与其按它们是什么来分组,不如按它们做什么来分组可以提高可维护性。这个问题在这么小的解决方案中并不明显,但随着规模的增长会变得显而易见;我想将与(例如)设置相关的项分组在一起,因为当一个 bug 分配给我时,它会说“设置出错了”,而不是“view-models出错了”。
我将其保留原样是因为这是要求实现者喜欢的方式,但我建议不要采用这种分组方式。

历史

  • 2014-02-03:版本 2:添加了代码示例格式(由这位仁兄建议)和 Blend 引用(如这位仁兄所指出的)。
© . All rights reserved.