可搜索的 WPF TreeView






4.91/5 (68投票s)
展示如何修剪 TreeView 的节点以进行搜索。
引言
在本文中,我将演示一种 WPF 的方法,如何创建一个可以应用筛选来搜索树中的特定节点或项的树状视图。我发现使用筛选来修剪或限制树状视图非常有用,而且当我无法快速找到我要更改的选项(例如在 Visual Studio Options 中)时,我真的会感到恼火。我通常对我要找的东西有一个大概的了解,通过输入一部分内容来查找通常比目视检查整个树更快,Window 7 的开始菜单或 Windows 8 的 UI 都是这种方法得到良好利用的绝佳示例。
这显然不是一个新问题,网络上也不乏示例实现,本文基于我为朋友做的一个东西,我在 YouTube 上发布后收到了不少索要源代码的请求,所以现在分享出来。
由于主题内容有限,本文将相对简短。
Using the Code
提供了两个存档,一个用于 C#,一个用于 VB.NET,以便每个人都可以使用自己喜欢的语言阅读源代码。
对于本文,由于涉及的代码量非常少,我决定同时包含 C# 和 VB.NET 代码。
要求
当我的朋友要求实现这个功能时,他给了我一个简短的需求列表,要求它必须满足:
- 它必须基于
System.Windows.Controls.TreeView
。 - 在未进行筛选时,树状视图应表现得像一个普通的树状视图。
- 文本输入字段应实时接受输入来修剪树状视图(实时意味着不需要按 Enter 或类似按钮来触发筛选)。
- 筛选条件应被记住,以便于重用(我个人认为这有点多余,因为当用于筛选的标准足够简单,容易记住时,这种控件才真正有用)。
- 文本输入字段不应占用太多屏幕空间,同时又要足够明显,以便首次使用的用户能够找到并理解。
此外,实现组件应有利于 MVVM 方法,因为 UI 设计师很可能会更改视觉外观。
基础知识
我决定我的实现将是一个包含 TreeView
(满足需求 #1)的 DataTemplate
,以及一个可编辑的 ComboBox
,它既可以用作输入筛选条件的方式,也可以作为先前条件的列表(满足需求 #3 和 #4)。
注意:由于这是为了适应保存应用程序设置的内容而构建的,因此 DataTemplate(以及配套的 view-model)命名为 Settings-something,这对于通用实现来说有点太具体了,但我还是保留了它。
我想要一个稍好一些的树状视图外观,它主要在稍后将介绍的 Style
中定义。筛选 ComboBox
有一个图标,也主要由 Style
覆盖,因为为了满足要求 #5,我希望它在未获得焦点时能动画缩小为一个较小的元素,然后在需要时恢复到完整大小。用于“控件”的 DataTemplate
的 XAML 如下所示;
<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.Event
和 i:EventCommand.Command
属性的用途。
应用了 SearchBox
Style
的 Border
基本上就是搜索“控件”。
TreeView 样式
TreeView
的 Style
主要处理 TreeView
的视觉方面,例如设置一个包含图标和节点名称的 ItemTemplate
,它还设置了定义每个节点如何提供子节点的 HierarchicalDataTemplate
。
使用 ItemContainerStyle
,它还配置了处理折叠/展开、可见/不可见和选中/未选中状态的绑定。
树节点知道它当前是否被当前搜索条件覆盖,并通过 view-model 上的名为 IsMatch
的属性暴露出来,这样搜索条件的更改就可以传播到所有节点,并设置 IsMatch
状态,从而根据用户的搜索结果使节点可见或隐藏。搜索时不仅仅是查看当前节点,因为如果一个子节点匹配但父节点不匹配,我们仍然希望父节点可见,以便清楚地显示到找到的节点的路径。我将在稍后详细介绍 view-model 实现时对此进行更详细的介绍。TreeView
的 Style
如下所示
<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 样式
构成搜索框的 Border
的 Style
设置了一些视图属性,并定义了允许搜索字段在获得焦点时“弹出”的动画。
这是通过将 Storyboard
连接到 路由事件 Mouse.MouseEnter
和 Mouse.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 可以在上面列出的 SettingsViewModel
的 DataTemplate
中看到;
<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}"/>
最后两个属性设置了 RoutedEvent
的 EventCommand
属性,以及到view model上 ICommand
的绑定。虽然这在 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出错了”。
我将其保留原样是因为这是要求实现者喜欢的方式,但我建议不要采用这种分组方式。