使用 MVVM 为文件浏览器玩转带有选项卡的 TreeView
本文介绍了一种使用WPF TreeView结合MVVM模式来创建标签式导航树的方法,适用于文件浏览器。在计划的下一篇文章中,将添加标签式文件夹面板。
目录
- 引言
- 背景
- 模型
- RootItems 用于定义树
- NavTreeItem 接口和抽象类
- DriveItem 示例,一个派生类
- 使用反射和约定列出和创建 RootItems
- 重建树
- ViewModel
- 视图
- MainViewWindow
- NavTreeView
- TabbedNavTreesView
- 关注点
- 限制
- 历史
引言
本文只是介绍了另一种使用WPF TreeView创建标签式TreeView的方法,可用于文件浏览器。
计划写两篇文章。第二篇文章将是关于添加一个标签式文件夹面板,以构建一个极简的玩具“文件浏览器”。最终的程序可以作为原型,体验标签在文件浏览器中的工作方式。
我的主要目的不是写一个功能齐全的文件浏览器,而是玩耍,多学习一点C#/WPF(我读了一些书,但仍然处于复制粘贴阶段),并获得我的第一个MVVM编程实践经验。请报告主要的不足之处,以便我学习。
关于使用WPF TreeView的文章已经写了很多,这只是另一种用于特定应用的TreeView用法。以下主题可能感兴趣
- NavTreeItems:用于定义图标和子项的抽象基类和派生类。
- 通过定义它们的RootItems和子项来设计树。
- 使用反射和约定列出所有RootItems并根据编号创建。
- DriveRootItem、DriveItem、FolderItem、FileItem作为示例的实现。
- 通过在模型中保存所有展开项的快照来重建树,并用它来重建新树。
- 使用按钮和命令设置选定路径。在标签式文件浏览器中,树用于选择,即使当前选定的TreeItem被再次点击,选定路径也必须始终设置。
- 在固定标签中放置树,这里我们不希望选中的标签移动到底部行。
背景
请注意,为了最直接的结果,第一选择是使用ShellItems(Code Project文章中有许多示例)或 Windows API Pack来处理操作系统(Windows 7库、W7文件浏览器收藏夹等)。有关更具功能性的文件浏览器示例,请参阅Leung Yat Chun最近更新的文章 WPF x FileExplorer x MVVM。
有关WPF的介绍,请参阅 Sacha Barber的文章此处。有关微软提供的一些WPF代码示例,请参阅此处,或者我唯一拥有的WPF书籍《WPF Control Development Unleashed》,请查看该页面上的下载选项卡。
关于TreeView的文章已经写了很多。我受到Karl Shiflett关于懒加载TreeView的极简代码的启发和吸引。当我读到这篇文章时,我认为这是一种技巧,但实际上,通过定义一个(分层)DataTemplate来决定一个类如何呈现,这是WPF的标准做法。
TreeView的一个主题是如何将一个项滚动到视图中,请参阅Bea Stollnitz的文章。
第二个主题是选中的项。这个属性是只读的,因此无法像您预期的那样绑定到它,请参阅以下关于此问题的Stackoverflow问题。Josh Smith写了一些关于如何以MVVM方式使用TreeView以及使用IsSelected
属性来设置和检索选中项的文章,请参阅他的文章“Simplifying the WPF TreeView by Using the ViewModel Pattern”。
MVVM(Model-View-ViewModel)是一种用于编写WPF程序的设计模式,当关注点分离和单元测试发挥作用时,这是一篇经典文章“WPF Apps With The Model-View-ViewModel Design Pattern”由Josh Smith撰写。
视图(通常仅包含XAML,最好没有代码隐藏)的职责只是如何呈现数据和描述用户生成的事件。视图可以使用Blend单独设计。视图的元素绑定到ViewModel的元素:INotifyPropertyChanged
属性(或依赖属性)、ObservableCollections
和ICommands
。ViewModel公开数据(例如来自模型或视图属性的表示),并处理数据更改或响应视图事件执行命令。
我将不再深入解释MVVM,而是尝试在一个简单的桌面应用程序中仅使用我作为MVVM初学者所理解的标准基本MVVM技术。请参阅下图,它说明了关注点分离。请注意,在此简单应用程序中,我不会使用诸如Blend交互行为来从事件触发命令、附加依赖属性、MVVM框架或外部DLL之类的常用实践。
模型
RootItems 用于定义树
应该能够设计任意的树(理论上可以创建包含分层收藏夹或当前历史记录的树),并且应该容易添加一个新的标签式树。
目前,我们以如下方式使用MVVM中的TreeView。RootItem始终是树的根节点。RootItem定义其子项,从而定义树的模型。RootItem的子项被复制到ViewModel的可观察集合RootChildren中。ViewModel的RootChildren在View的XAML中绑定到TreeView。
目前我定义了几个RootItems:最重要的是DriveRootItem,为了快速获得一些额外的RootItems,我添加了DriveNoChildRootItem
、“FavoritesRootItem
”和SpecialFolderRootItem
。DriveNoChildRootItem
的子项是DriveNoChild
(无子项),因此我们只得到一个不可展开驱动器的列表,这在驱动器数量很多时可能有用。SpecialFolderRootItem
只是作为一个额外的项,它在文件浏览器中的相关性(或作为替代的KnownFolders)是有疑问的。
在Windows 7文件浏览器树中,我们有标准的节点/组:收藏夹、库、家庭组、计算机(= DriveRootItem)和网络,所以这些是可能被添加的候选。
我认为收藏夹最重要。您可以将驱动器和文件夹拖到此组(将作为快捷方式存储),例如,下载和桌面是默认的收藏夹。然而,我尚未找到有关收藏夹的文档(可能需要查看Windows API Pack源代码)。我做了一个简短的hack,使用了一个固定的位置和名称。快捷方式显示为普通图标,项目按标准顺序排列,并且无法指定顺序。请注意,(对于非Windows 7用户)应该有可能在代码中删除FavoriteRootItem
类,或者更改硬编码的文件夹名称并在该文件夹中放置一些文件夹快捷方式。
NavTreeItem 接口和抽象类
对于树中的所有项(包括RootItems),我决定定义类。请参阅下面的代码,了解这些类实现的接口。请注意,这里的模型公开了FriendlyName、MyIcon(用于在TreeView中显示TreeItems)、FullPathName(用于选择)和Children(用于构建TreeView)。IncludeFileChildren特定于显示文件夹和文件的树。
public interface INavTreeItem : INotifyPropertyChanged
{
string FriendlyName { get; set; }
BitmapSource MyIcon { get; set; }
string FullPathName { get; set; }
ObservableCollection<INavTreeitem> Children { get; }
bool IsExpanded { get; set; }
bool IncludeFileChildren { get; set; }
void DeleteChildren();
}
接下来,我定义了一个抽象类NavTreeItem
,请参阅下面的代码。派生类充当树中的实际项。请注意,对于属性MyIcon
和Children
的赋值,使用了抽象函数。这意味着,对于派生类的定义,只需要指定这些过程,稍后我们将展示一个示例。
以下选择开放讨论。大多数属性在树的生命周期内被视为常量,并在XAML中使用Mode=OneTime
绑定。IsExpanded
和Children属性在树的生命周期内被视为可变,我们为它们实现了INotifyPropertyChanged
。请注意,在代码中,SetProperty()
实现了通知,请参阅此处的讨论。我对在模型中也实现INotifyPropertyChanged
有些疑问,但在没有支持性MVVM框架的情况下,这似乎是最好的选择。
最初,我们有一个IsInitiallyExpanded
属性,没有通知,用于仅控制TreeItems的初始展开。要从模型或ViewModel中的树重建树,IsExpanded
属性必须与View中的TreeView同步,因此我们实现了通知和标准绑定。
我怀疑ObservableCollection
是否需要实现INotifyPropertyChanged
,但出于对称性,我这样做了。我在此处展示ObservableCollection
Children的代码以供演示和讨论。DeleteChildren
用于将子项设置为null,理论上这应该会重置树。
// Highlights abstract class NavTreeItem
public abstract class NavTreeItem : ViewModelBase, INavTreeItem
{
...
protected BitmapSource myIcon;
public BitmapSource MyIcon
{
get { return myIcon ?? (myIcon = GetMyIcon()); }
set { myIcon = value; }
}
...
protected ObservableCollection <INavTreeItem> children;
public ObservableCollection<INavTreeItem> Children
{
get { return children ?? (children = GetMyChildren()); }
set { SetProperty(ref children, value, "Children"); }
}
...
private bool isExpanded;
public bool IsExpanded
{
get { return isExpanded; }
set { SetProperty(ref isExpanded, value, "IsExpanded"); }
}
...
// We will define these Methods in other derived classes ...
public abstract BitmapSource GetMyIcon();
public abstract ObservableCollection<INavTreeItem> GetMyChildren();
}
DriveItem 示例,一个派生类
如前所述,最重要的RootItem是DriveRootItem
,其子项是DriveItems。作为示例,我们将展示定义DriveItem类的代码。请注意,文件夹未经过任何过滤。
// Sample derived class: DriveItem, child of DriveRootItem
public class DriveItem : NavTreeItem
{
public override BitmapSource GetMyIcon()
{
return myIcon = Utils.GetIconFn.GetIconDll(this.FullPathName);
}
public override ObservableCollection<INavTreeItem> GetMyChildren()
{
ObservableCollection<INavTreeItem> childrenList = new ObservableCollection<INavTreeItem>() { };
INavTreeItem item1;
DriveInfo drive = new DriveInfo(this.FullPathName);
if (!drive.IsReady) return childrenList;
DirectoryInfo di = new DirectoryInfo(((DriveInfo)drive).RootDirectory.Name);
if (!di.Exists) return childrenList;
foreach (DirectoryInfo dir in di.GetDirectories())
{
item1 = new FolderItem();
item1.FullPathName = FullPathName + "\\" + dir.Name;
item1.FriendlyName = dir.Name;
item1.IncludeFileChildren = this.IncludeFileChildren;
childrenList.Add(item1);
}
if (this.IncludeFileChildren) .... FileItems added here
return childrenList;
}
}
使用反射和约定列出和创建RootItems
按照约定,所有RootItem类的名称都以“RootItem”结尾。使用反射,我们可以生成模型中所有已定义的RootItem类的列表,或者根据RootNr
创建RootItem,请参阅下面的代码。在TabbedNavTreesVM
的构造函数中,我们使用此列表,然后使用DriveRootItems填充剩余的选项卡。这意味着,如果在模型中定义了额外的RootItem,它将出现在TabbedTree中。
public static NavTreeItem ReturnRootItem(int iRootNr, bool includeFileChildren = false)
{
// Set default System.Type
Type selectedType = typeof(DriveRootItem);
string selectedName = "Drive";
// Can you find other type given the conventions ..RootItem name and iRootNr
var entityTypes =
from t in System.Reflection.Assembly.GetAssembly(typeof(NavTreeItem)).GetTypes()
where t.IsSubclassOf(typeof(NavTreeItem)) select t;
int i = 0;
foreach (var tt in entityTypes)
{
if (tt.Name.EndsWith(LastPartRootItemName))
{
if (i == iRootNr)
{
selectedType = Type.GetType(tt.FullName);
selectedName = tt.Name.Replace(LastPartRootItemName, "");
break;
}
i++;
}
}
// Use selectedType to create root ..
NavTreeItem rootItem = (NavTreeItem)Activator.CreateInstance(selectedType);
rootItem.FriendlyName = selectedName;
rootItem.IncludeFileChildren = includeFileChildren;
return rootItem;
}
重建树
通常,TreeView与在更改时生成通知的数据源一起使用。起初,我将文件系统/树视为常量,但在最后一次重构中,我引入了一个显式的重建命令(可以由FileSystemWatcher
触发)。这允许显示诸如外部创建的新文件夹之类的更改。
我考虑过使用Union、Intersect和Except来更新树,但这对我来说太复杂了。我选择了一个简单、健壮但不太优雅的解决方案:捕获树的展开项的快照,删除ViewModel中的当前RootChildren,创建新的RootChildren,并从快照中展开所有项,请参阅下面的代码。这里的假设是,在(标签式)文件浏览器中,用户展开的文件夹数量是有限的。
对于快照,我们使用FullPathName命名方案,并递归访问所有当前展开的项及其子项。对于驱动器及其子项的命名方案,将每个展开项的FullPathName添加到快照字符串列表中应该足够了。在特殊文件夹中,一个项可能意味着驱动器和文件夹名称的较长路径,因此所有到根的FullPathName都带有分隔符添加到快照字符串中。这意味着支持FullPathName对于子项是唯一的任何命名方案。
// From ViewModel NavTreeVM.
// Take snapshot expanded items, clear RootItems, create new RootItems, expand snapshot
public void RebuildTree(int pRootNr = -1, bool pIncludeFileChildren = false)
{
List<String> SnapShot = NavTreeUtils.TakeSnapshot(rootChildren);
...
NavTreeItem treeRootItem = NavTreeRootItemUtils.ReturnRootItem(RootNr, pIncludeFileChildren);
...
foreach (INavTreeItem item in treeRootItem.Children) { RootChildren.Add(item); }
...
NavTreeUtils.ExpandSnapShotItems(SnapShot, treeRootItem);
}
ViewModel
ViewModel由三个类组成:NavTreeVm
、TabbedNavTreesVm
和MainVm
,请参阅下面的伪代码。在MainVm
中,RootNr
和IncludeFiles的属性setter包含对SingleTree.RebuildTree(RootNr, IncludeFiles)
的函数调用,并且RebuildTreeCommand
对TabbedNavTrees.SelectedNavTree.RebuildTree()
进行额外的调用。
在此示例中,代码相对较少,但在文件浏览器中,存在大量简单的代码和更多的交互,通过属性和命令而不是在各种事件上编写大量代码隐藏是有益的。
请注意,下面的代码中使用Relay Command以标准的MVVM方式实现了ICommandSelectedPathFromTreeCommand
,再次参阅此处。对于单条语句,我们使用了lambda表达式“x => SelectedPath = (x as string)”,但我们可以将其替换为“x => OnSelectedPathFromTree(x),x => TestCanExecuteThis(x)”来调用函数并添加CanExecute的测试。
在编写代码时,我从一个单一树开始,所有代码都在MainVm.cs和MainViewWindow.xaml中。当它工作后,我将代码重构为NavTreeVm.cs和NavTreeView.xaml。对于TabbedTrees也是如此,代码被移到了TabbedNavTreesVm
和TabbedNavTreesView
。
通过使用一个通用的SelectedPathFromTreeCommand
,ViewModel类之间的交互不多。MVVM框架可以支持多个ViewModel的交互。
// Pseudo code for properties and functions in Classes ViewModel // // Class NavTreeVM public string TreeName public int RootNr public ObservableCollection<INavTreeItem> RootChildren public void RebuildTree(int pRootNr = -1, bool pIncludeFileChildren = false) // Constructor public NavTreeVm(int pRootNumber = 0, bool pIncludeFileChildren = false) // // Class TabbedNavTreesVm public List<string> listNamesNavTrees public ObservableCollection<NavTreeVm> NavTrees public NavTreeVm SelectedNavTree ... MaxRowsNavTrees, TabsPerRow = 3 see comment in source // Constructor, determines what trees are placed in Tabs public TabbedNavTreesVm() // // Class MainVm // For Single Tree demo: public NavTreeVm SingleTree public int RootNr public bool IncludeFiles public ICommand RebuildTreeCommand // For TabbedNavTree demo: public TabbedNavTreesVm TabbedNavTrees { get; set; } public string SelectedPath // For now SelectedPath common to all trees // Sample of ICommand using RelayCommand RelayCommand selectedPathFromTreeCommand; public ICommand SelectedPathFromTreeCommand { get { return selectedPathFromTreeCommand ?? (selectedPathFromTreeCommand = new RelayCommand(x => SelectedPath = (x as string) ) ); } } // constructor, sets SingleTree and TabbedNavTrees on creation instance MainVm, // creation here because class MainVm is used as DataContext in MainViewWindow public MainVm()
视图
MainViewWindow
我们有几个XAML文件:MainViewWindow、“用户控件”NavTreeView和TabbedNavTreesView,最后是MainResources(一些公共画笔和滚动条)。请注意,我使用“用户控件”只是为了组织XAML,它们需要ViewModel中的特定类。请参阅下面的代码,其中包含MainViewWindow的一些快照。查看演示的图像和MainVm中的属性,已经可以暗示View中将使用哪些绑定。
在下面的代码中,首先将MainVm定义为Window的DataContext。WPF将在XAML代码的其余部分尝试在绑定中绑定到DataContext的(子)属性。接下来,指定一个NavTreeView并将其绑定到MainVm类的SingleTree
属性,最后指定一个TabbedNavTreesView
并将其绑定到MainVm类的TabbedNavTrees
属性。作为附加项,展示了ComboBox到RootNr的标准绑定。
据我所知,在执行时,会创建一个名为MyMainVm的MainVm类的实例,并将其分配给Window实例的DataContext属性。
<!-- Snippets from MainViewWindow.xaml. Grids, buttons, selected path omitted --> <Window.DataContext> <vm:MainVm x:Name="MyMainVm"/> </Window.DataContext> ... <vw:NavTreeView DataContext="{Binding SingleTree}" /> ... <vw:TabbedNavTreesView DataContext="{Binding TabbedNavTrees}" /> ... // ComboBox shows standard binding RootNr <ComboBox DisplayMemberPath="" ItemsSource="{Binding Path=TabbedNavTrees.listNamesNavTrees, Mode=OneTime}" SelectedIndex="{Binding RootNr}" ToolTip="Choose a RootItem"> </ComboBox>
NavTreeView
请参阅下面的NavTreeView代码,我们看到一个HierarchicalDatatemplate和一个TreeView。TreeView
类派生自ItemsControl
类,并处理Items/Childs。可以为控件本身及其子项设置属性。对于TreeView,指定了ItemsSource、ItemTemplate和ItemContainerStyle
。ItemsSource指定使用数据(MainVm类)的RootChildren属性来生成TreeView的项。ItemTemplate是用于显示每个项的(分层)DataTemplate。ItemContainerStyle
用于绑定到IsExpanded
属性。
分层 DataTemplate还包含一个ItemsSource,绑定到Children属性。接下来,它定义了一个带有Command的Button、一个Image和一个TextBlock。所有这些都绑定到TreeView的NavTreeItem类的相应属性。所有绑定都非常基本,除了Command,它使用了一个超出我专业知识范围的绑定。我使用“用户控件”只是为了组织Xaml代码,但为了使Command更灵活,可以向NavTreeView
引入一个TreeItemClickCommand
属性,并将Button命令绑定到TreeItemClickCommand
,就像此处的问题一样。
// Form UserControl NavTreeView.xaml .... <UserControl.Resources> <HierarchicalDataTemplate x:Key="NavTreeTempl" ItemsSource="{Binding Path=Children}" > <Button Command="{Binding Path=DataContext.SelectedPathFromTreeCommand, RelativeSource = {RelativeSource FindAncestor, AncestorType={x:Type Window}}}" CommandParameter="{Binding FullPathName}" Background="{x:Null}" BorderBrush="{x:Null}" Padding="0" Height="20" Focusable="False" ClickMode="Press"> <StackPanel Orientation="Horizontal" Margin="0" VerticalAlignment="Stretch" > <Image Source="{Binding Path=MyIcon, Mode=OneTime}" Stretch="Fill" /> <TextBlock Margin="5,0,0,0" Text="{Binding FriendlyName,Mode=OneTime}"/> </StackPanel> </Button> </HierarchicalDataTemplate> </UserControl.Resources> <TreeView ItemsSource="{Binding Path=RootChildren}" ItemTemplate="{StaticResource NavTreeTempl}" > <TreeView.ItemContainerStyle> <Style TargetType="{x:Type TreeViewItem}"> <Setter Property="IsExpanded" Value="{Binding Path=IsExpanded, Mode=TwoWay}"/> </Style> </TreeView.ItemContainerStyle> </TreeView>
TabbedNavTreesView
对于标签式导航树,我有两个要求。首先,如果我们返回到一个带有展开树的选项卡,该树必须显示为我们离开时的状态。其次,选项卡必须是固定位置的。当选中的选项卡移动到底部行时,我总是感到困惑。
出于历史原因,我通过ListBox和NavTreeView来模拟TabControl。(我从TabControl开始,但由于没有IsExpanded的通知,所以没有奏效,因为TabControl不保留视觉树。所以我最初使用了没有通知的IsExpanded和2个关联的ListBox)。请参阅下面的TabbedNavTreesView代码,我们看到以下项:
- 定义了NavTreeVm的数据模板,它仅显示TreeName。
- 样式指定了第一个Listbox的ItemsPanel。通常,ItemsControl的每个派生类都有自己的默认ItemsPanel。大多数标准的WPF控件都是无外观的(外观示例请参阅此处),我们可以覆盖默认值,因此我们可以选择Canvas或水平StackPanel。在这里,我们选择了一个UniformGrid。为防止出现额外的行,我们使用绑定到MaxRowsNavTrees。
- 最后,指定了ListBox和NavTreeView。
..... <UserControl.Resources> <DataTemplate x:Key="templateNavTreeHeader" DataType="{x:Type vm:NavTreeVm}"> <!-- use stack panel and show in icon RootItem here -- --> <TextBlock Margin="3,2,3,2" FontSize="10" Text="{Binding TreeName}"/> </DataTemplate> <Style x:Key="mimicTabControlHeader" TargetType="ListBox"> <Setter Property="ItemsPanel"> <Setter.Value> <ItemsPanelTemplate> <UniformGrid Rows="{Binding MaxRowsNavTrees}"/> </ItemsPanelTemplate> </Setter.Value> </Setter> </Style> </UserControl.Resources> ... Grid definition omitted here <ListBox x:Name="NavTabHeaderLookAlike" Grid.Row="0" ItemsSource="{Binding NavTrees}" SelectedItem="{Binding SelectedNavTree}" Style="{StaticResource mimicTabControlHeader}" ItemContainerStyle="{StaticResource selectedItemUseBrusch}" ItemTemplate="{StaticResource templateNavTreeHeader}" /> <vw:NavTreeView Grid.Row="1" DataContext="{Binding SelectedNavTree}" />
关注点
- 通过我们对INavTreeItem接口的选择,我们强调了TreeView中表示的元素。这使得处理其他类型的树(如分层收藏夹)更加容易,但与基于ShellItems的接口相比,需要进行一些转换。使用派生类使代码保持组织性。从文件系统进行的转换在模型中,这在文件浏览器中是有意义的。
- 当在Windows 7文件浏览器中使用收藏夹并展开它们时,我们有时需要滚动才能在“计算机/驱动器”中找到一个打开的文件夹。为此,我想玩一下TabbedNavTrees(另一个解决方案是引入一个易于访问的折叠/展开命令)。通过选项卡,您可以选择并单击鼠标一次跳到3-12个标签式树。然而,Windows 7文件浏览器中的树具有更美观、更宽敞的外观。
- 一种改进可以是为每个RootItem设计一个图标。通过对XAML进行微调,我们可以将这些图标添加到标签中的名称旁边,或者以垂直方式仅使用带图标的标签。
- 我的计划是撰写另一篇,更详细的文章,关于添加标签式文件夹面板,使用相同的基本MVVM和WPF技术。因为交互性稍多,MVVM的附加值将更明显。
限制
- 这个练习的想法是第一次MVVM学习体验。RootItems的数量有限,我没有找到关于收藏夹的文档。
- 我只在1台Windows 7 PC上进行了有限的测试。我从一位远程Windows XP的朋友那里了解到程序会挂起,但我无法测试是什么原因造成的。欢迎提出一些意见。
历史
2012-05-20 首次版本文章提交。