WPF/MVVM 中可取消的 TreeView 视图导航






4.92/5 (5投票s)
根据与每个项目关联的文档状态选择 treeviewitems。
引言
面向触摸屏应用程序的应用程序设计方法有时会产生一种需求,即显示的视图应以类似 treeview 的结构进行导航。
我构建了一个小型示例应用程序来演示这个高级概念,它与 Windows 资源管理器窗口非常相似,但绝不限于显示驱动器、文件夹及其内容。网上有一些关于一站式解决方案的暗示[1],但我需要额外一天来完成一个真正可行的示例解决方案。本文记录了我的方法,希望它能对他人有所帮助。我还写下这些信息,希望人们在看到明显的错误和改进之处时能够提出反馈。
在此文章发布后,我还发布了一个相当复杂的应用程序。如果您正在寻找更真实、更复杂的示例来激励本文,请查看 Locult ( http://locult.codeplex.com/ )。
背景
上面图片中的截图显示了应用程序的基本概念。它应该有一个 treeview 来导航不同的项目,并根据选定的项目显示文档内容。如果文档处于不允许更改的(脏)状态,则不允许更改 treeview 中的选择。
您可以将该应用程序设想为一个具有项目资源管理器的应用程序,但一次只能显示一个文档。或者,这也可以是一个类似于 Windows 控制面板的应用程序。
上面 MainWindow 顶部显示的 “取消选择” 复选框绑定到 ApplicationViewModel.CancelTreeVieSelection
属性。这代表了一个应用程序范围内的属性,它决定当前选定的 treeview 项目是否可以更改。
上面的 “取消选择” 复选框可以直接由用户设置,也可以通过设置当前文档的 “文档脏” 复选框来隐式设置。“文档脏” 复选框绑定到 DocumentViewModelBase.IsDirty
属性。
public bool IsDirty
{
get
{
return _IsDirty;
}
set
{
if (_IsDirty != value)
{
if (DirtyFlagChangedEvent != null)
DirtyFlagChangedEvent(this, new DocumentDirtyChangedEventArgs(_IsDirty, value));
_IsDirty = value;
RaisePropertyChanged(() => IsDirty);
}
}
}
此属性的任何更改都会触发 DocumentViewModelBase.DirtyFlagChangedEvent
,该事件进而执行 ApplicationViewModel.CurrentDocument_DirtyFlagChangedEvent
方法。
private void CurrentDocument_DirtyFlagChangedEvent(object sender, DocumentDirtyChangedEventArgs e)
{
this.CancelTreeVieSelection = e.IsDirtyNewValue;
}
这种设计允许我们有一个通用的应用程序范围属性 CancelTreeVieSelection
来确定是否应查询项目选择更改。此属性可以覆盖文档 IsDirty 属性,但始终设置为与新文档选定保持同步。
当用户在 “取消选择” 复选框被选中时选择 treeview 项目的预期行为如下:
如果用户在显示的 messabox 中选择 “是”,则应进行新的 treeview 项目选择;如果用户选择 “否”,则应停留在 Child 2。下一章将解释使用 MVVM 架构实现此行为所需的代码结构。
使用代码
文档视图的 ViewModels
此类中的文档视图由一个 viewmodel 类控制,该类要么是 RootViewModel
类型,要么是 DocumentViewModel
类型。这些类型可用于区分根节点和其他所有文档节点。这两个类都基于 DocumentViewModelBase
类,该类实现了与本文相关的所有属性和事件。然而,可以很容易地想象,RootViewModel 类可以实现额外的命令和属性,让用户与该类型的内容进行交互。
DocumentDirtyChangedEventArgs
事件也由 DocumentViewModelBase
类实现。因此,它在 DocumentViewModel
和 RootViewModel
类中可用,用于发出信号表示该文档的脏状态已更改。
下一节将解释上述 viewmodel 类如何集成到提议的 WPF/MVVM 示例应用程序的视图部分。
视图和绑定
让我们从 MainWindow 开始,然后从那里探索所有其他项,以自顶向下的方式探索应用程序。
<Grid Grid.Row="2">
<Grid.Resources>
<!--
These datatemplates map a type of viewmodel into their view.
This map definition is used below in the ContentPresenter to
show the correct page for each type of view.
-->
<DataTemplate DataType="{x:Type vm:DocumentViewModel}">
<StackPanel Margin="3">
<TextBlock Text="This is a Document View!" Margin="3"/>
<TextBlock Text="{Binding DocumentTitle, StringFormat={} Document Title:{0}}" Margin="3"/>
<CheckBox Content="Document IsDirty" IsChecked="{Binding IsDirty}" Margin="3"/>
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:RootViewModel}">
<StackPanel Margin="3">
<TextBlock Text="This is a ROOT View!" Margin="3"/>
<TextBlock Text="{Binding DocumentTitle, StringFormat={} Document Title:{0}}" Margin="3"/>
<CheckBox Content="ROOT view document IsDirty" IsChecked="{Binding IsDirty}" Margin="3"/>
</StackPanel>
</DataTemplate>
</Grid.Resources>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TreeView Grid.Column="0"
ItemsSource="{Binding TreeViewItems}"
behav:TreeViewSelectionChangedBehavior.UndoSelection="{Binding CancelTreeVieSelection}"
behav:TreeViewSelectionChangedBehavior.ChangedCommand="{Binding SelectItemChangedCommand}"
>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}"
ToolTipService.ShowOnDisabled="True"
VerticalAlignment="Center" Margin="3" />
</StackPanel>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
<TreeView.ItemContainerStyle>
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Style>
</TreeView.ItemContainerStyle>
</TreeView>
<ContentControl Grid.Column="1" Content="{Binding CurrentDocument}" Margin="3" />
</Grid>
上面的 XAML 显示了 Grid.Column=0 中的 TreeView 的定义项,以及 Column=1 中的 ContentControl。理解 XAML 顶部列出的 DataTemplate
定义决定了在 ContentControl 中显示的视图非常重要。
解决方案的核心是附加到 TreeView 的 TreeViewSelectionChangedBehavior
。当 TreeVew 中引发 SelectItemChanged
事件时,此行为会在 ApplicationViewModel
中执行 SelectItemChangedCommand
。
可以通过 PreviewMouseDown
事件取消 SelectItemChanged
事件,该事件在 TreeViewSelectionChangedBehavior
的 OnUndoSelectionChanged
方法中被挂钩/解除挂钩。当通过 ApplicationViewModel
中的 CancelTreeViewSelection
属性更改 UndoSelection
依赖属性时,此方法会被执行。
应用程序启动时将 CancelTreeViewSelection
设置为 false 很重要,因为绑定和生成绑定项也会生成上述事件,并可能导致各种问题,从应用程序冻结到完全无响应。确保在以编程方式操作 TreeView 的 ItemsSource
时,此属性始终设置为 false。
TreeViewSelectionChangedBehavior
中的 uiElement_PreviewMouseDown
方法仅在 UndoSelection
依赖属性设置为 true 时执行。否则,甚至不会评估相应的事件。
private static void uiElement_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
// first did the user click on a tree node?
var source = e.OriginalSource as DependencyObject;
while (source != null && !(source is TreeViewItem))
source = VisualTreeHelper.GetParent(source);
var itemSource = source as TreeViewItem;
if (itemSource == null)
return;
var treeView = sender as TreeView;
if (treeView == null)
return;
bool undoSelection = TreeViewSelectionChangedBehavior.GetUndoSelection(treeView);
if (undoSelection == false)
return;
// Cancel the attempt to select an item.
var result = MessageBox.Show("The current document has unsaved data. Do you want to continue without saving data?", "Are you really sure?",
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (result == MessageBoxResult.No)
{
// Cancel the attempt to select a differnet item.
e.Handled = true;
}
else
{
// Lets disable this for a moment, otherwise, we'll get into an event "recursion"
treeView.PreviewMouseDown -= uiElement_PreviewMouseDown;
// Select the new item - make sure a SelectedItemChanged event is fired in any case
// Even if this means that we have to deselect/select the one and the same item
if (itemSource.IsSelected == true )
itemSource.IsSelected = false;
itemSource.IsSelected = true;
// Lets enable this to get back to business for next selection
treeView.PreviewMouseDown += uiElement_PreviewMouseDown;
}
}
解决方案的核心在于显示 MessageBox 的行(及以下)。取消选择事件等同于声明我们已处理了此事件,而实现该事件需要我们通过操纵 TreeViewItem 的 IsSelected
属性来引发 SelectedItemChanged
事件。
摘要
提议的解决方案需要一个应用程序范围的属性来确定是否可以取消项目选择。附加行为实现了取消行为逻辑,并实现了命令绑定,以便在选定项目发生更改时通知应用程序 viewmodel。
public ICommand SelectItemChangedCommand
{
get
{
if (_SelectItemChangedCommand == null)
{
_SelectItemChangedCommand = new RelayCommand<object>((p) =>
{
var param = p as ItemViewModel;
if (param != null)
{
this.SelectedTreeViewItem = param;
if (this.CurrentDocument != null)
this.CurrentDocument.DirtyFlagChangedEvent -= CurrentDocument_DirtyFlagChangedEvent;
if (param.Name == "Root")
{
this.CurrentDocument = new RootViewModel();
}
else
this.CurrentDocument = new DocumentViewModel();
this.CurrentDocument.DocumentTitle = param.Name;
this.CancelTreeVieSelection = this.CurrentDocument.IsDirty;
this.CurrentDocument.DirtyFlagChangedEvent += CurrentDocument_DirtyFlagChangedEvent;
}
});
}
return _SelectItemChangedCommand;
}
}
应用程序 viewmodel 可以对此作出响应,并确保显示与项目关联的相应文档。请尝试使用附加的示例,并让我知道您是否发现问题,或者更好的是,看到改进的空间。