TreeView 四技
Written by Allen Lee
0. 背景故事
现在的东西动不动就用G来算,一眨眼的功夫,我那100G的硬盘已拥挤不已了,但还有很多东西想放进来啊,怎么办?好吧,现在 DVD 刻录机的价格已经平民化了,我买了一个来舒缓紧张的硬盘。这下好了,硬盘上的可用空间总是足以让我下载想要的大块头了。没过多久,我刻录的 DVD 就堆积成山,成为我房间的一道景物。为了管理这座“山”,我决定写一个 DVD 管理软件,嗯,就叫它 Cupel 吧。不难想象,Cupel 将充分使用 TreeView 控件的各种功能,现在我把开发 Cupel 的过程中使用 TreeView 的心得写下来,希望能为那些寻找这方面内容的朋友提供一些参考。
1. 填充节点
1.1 说说要求
如上图所示,根节点是光盘库,它可以包含0个或多个类别节点,每个类别节点又包含0个或多个光盘节点。Cupel 通过 Cupel.Data.DiscLibrary 类来读取和储存相关数据。
1.2 进行填充
类别视图的节点应该在 Cupel 的主窗体显示之前填充好,于是我选择在 Load 事件发生时进行填充:
填充节点的方法是很简单的,上面的代码有两点需要说明:
1.3 添加图标
对于 Windows 的用户,上面这幅图应该是很熟悉了,上面的每个节点都带有一个图标,这使得目录试图更直观。Code #01 并没有为每个节点添加图标,运行结果是每个节点将只有文字。要为节点添加图标,最简单的方法就是在创建节点时通过构造函数来指定,但在此之前,你得先创建一个 System.Windows.Forms.ImageList 实例,并用它来储存图标。这里介绍在 Visual Studio 里使用 ImageList 组件为 TreeView 提供图像资源:
至此,相关的准备工作已经完毕,接下来要做的就是修改 Code #01 为节点指定图标,这可以通过使用 TreeNode 如下的构造函数做到:
由于在 Cupel 中无论节点是否被选中,其图标都是一样的,所以上面构造函数的后两个参数值是一样的。假设 category.ico 在 ImageList 中的索引是1,那么你可以这样指定类别节点的图标:
1.4 继续思考
前面说到,每个节点可以包含0个或多个字节点,于是在用户第一次运行 Cupel 时,类别视图将只有一个根节点。这显然是不太友好的,因为面对着“一无所有”的类别视图,用户很可能会不知所措,尤其在他有很多光盘并且还没决定如何对这些光盘分类时。此时我们不妨考虑为用户提供一个默认分类,这样他就可以在此基础上构想一个更合适自己的分类,这要比凭空想出一个分类容易的多。当然,有些用户早已想出了一套很好的分类,此时我们就没必要为他提供默认分类了,而是直接让他应用自己的分类。可以看出,如果 Cupel 在第一次运行时显示一个设置向导,询问用户使用默认分类还是应用自己的分类,则会使用户感到更加友好。
无论多么好吃的东西,每天都吃也会使人感到厌倦。现今是一个个性化的时代,图 1-1 无疑显得有点单调,如果用户可以为每个类别指定一个不同的图标,甚至隶属不同类别的光盘也具有不同的图标,这将会使得 Cupel 令人眼前一亮。进一步考虑,我们可以考虑把类别视图的图标设置储存在一个配置文件,让用户可以选择应用不同的图标套装。当然,有些用户根本不在乎这点儿花样,就像那些一直支持着“Windows 经典”主题的用户一样。可以看出,如果 Cupel 在第一次运行时显示一个设置向导,询问用户使用哪个图标套装,则会使用户感到更加友好。
2. 延迟填充
2.1 说说要求
图 2-1 分上下两部分,上面是一个 TreeView,显示了类别视图选中的光盘节点所包含的目录结构,下面是一个 ListView,显示了光盘结构视图选中的节点的细节信息,此图实质上是一个主-从视图。
当光盘所包含的目录或文件节点比较多时,一次过填充光盘结构视图的所有节点很可能导致界面没有响应,这显然是不允许的。其实,我们没有必要一开始就把所有节点都填充上去,而应该在用户访问到某节点时才填充它的子节点。
2.2 做好准备
TreeView 中的节点信息都包含在 TreeNode 中,为了使得光盘结构视图具备延迟填充特性,以及在节点信息视图上显示选中节点的细节信息,我们有必要自定义一个用于 TreeView 的节点类,该类将派生自 TreeNode,并且包含实现相关功能的信息。
节点可分为目录节点和文件节点两类,它们既有相同之处,也有不同之处,于是我们很容易联想到建立一个继承体系:
FileSystemTreeNodeBase 类的 Properties 属性是一个 List<FileSystemTreeNodeProperty> 集合,它包含了与节点的相关信息,这些信息将会显示在节点信息视图上,实现主-从视图。另外,FileSystemTreeNodeBase 类还包含了一个 FillSubNodes 抽象方法,用于协助光盘结构视图实现延迟填充特性。由于文件节点不会有子节点,所以 FileTreeNode.FillSubNodes() 的方法体是空的。现在我们来看一下 DirectoryTreeNode.FillSubNodes():
用户有可能在展开某个节点后把它折叠起来,此时该节点的 Nodes 属性就会包含它的子节点(一个例外情况就是原光盘的某个目录是空目录,即里面没有包括任何子目录和/或文件),所以我们应该首先检查 Nodes.Count 是否为0。当条件满足时,我们就对该节点进行填充,留意填充代码包含在 TreeView.BeginUpdate() 和 TreeView.EndUpdate() 之间,这样做是为了避免 TreeView 每填充一个节点就绘制一次,从而提高了效率。
2.3 按需填充
仅当某个节点包含了子节点时,我们才能展开该节点,所以在展开该节点时,就要对其子节点所包含的子节点进行填充。例如,在图 2-1 中,当我们展开根节点(即“G:\”)时,“浪客剑心”所包含的子节点就得填充好了,否则它就无法被展开,它里面的目录结构也就无法显示了。
回到 Cupel,当用户选中类别视图中的某个光盘节点,光盘结构视图就会显示该光盘的根节点及其所包含的子节点:
接着,当用户点击可展开节点左边的“+”时,将引发 TreeView 的 BeforeExpand 事件,此时是填充该节点的子节点的子节点的最佳时机:
2.4 显示细节
当用户选中光盘结构视图中的某个节点时,节点信息视图将显示与该节点相关的信息,这两个视图共同组成一个主-从视图:
值得注意的是,Code #07 首先检测是否为鼠标左键点击以及点击次数是否为1,这些信息都包含在类型为 TreeNodeMouseClickEventArgs 的 e 参数中。另外,e.Node 是当前选中的节点,你必须把它强制转换成 FileSystem.TreeNodeBase 类型才能访问其所包含的 Properties 属性。
2.5 继续思考
虽然我们使用了“延迟填充”,但在展开某些节点时依然会感觉到“迟钝”,出现这种情况的主要原因是该节点的子节点包含着大量子节点。此时我们可以在展开之前把鼠标指针改为等待样式,待节点展开完毕后再改为默认样式:
另外,这里所提出的延迟填充方案并不是最佳方案。试想一下,如果我只展开图 2-1 中的“Bleach OVA”节点,而“浪客剑心”节点里面包含着数量可观的子节点却无需展开,那么 Cupel 的运行效率将受到影响。再者,预先填充这么多不需要的节点也会造成内存空间的浪费。为了避免这些弊端,我们可以修改一下这个方案,用“伪子节点”代替真实子节点来进行填充。还是拿图 2-1 来举例,当用户展开根节点时,填充“Bleach”、“Bleach OVA”和“浪客剑心”等子节点,接着分别为这些子节点填充一个“伪子节点”。当用户继续展开“浪客剑心”节点时,它所包含的“伪子节点”将被删除,取而代之的是它原本包含的真实子节点。
3. 节点编辑
3.1 说说要求
这里所说的“节点编辑”是狭义的重命名现有节点的名字,广义上它还包括添加新节点以及移除现有节点。下图示范了 Cupel 把“Anime”节点重命名为“Cartoon”:
对节点进行重命名时需要注意:
3.2 开始编辑
TreeView.LabelEdit 属性指示了节点是否允许编辑,默认情况下,它的值为 false。我们可以为类别节点提供一个上下文菜单,里面包含一个重命名菜单项,当用户点击该菜单项时,该类别节点进入编辑状态:
注意,仅当 TreeView.LabelEdit 为 true 时,TreeNode.BeginEdit() 方法才可用,否则会抛出 InvalidOperationException 异常。
3.3 完成编辑
节点完成编辑后将引发 TreeView.AfterLabelEdit 事件,该事件通过 NodeLabelEditEventHandler 委托来作用,该委托所包含的类型为 NodeLabelEditEventArgs 的参数 e 包含了完成编辑所需的信息:
根据 3.1 中提到的三点要求的前两点,我们可以写出如下代码:
在某些情况下,第三点要求是必须的,例如 Cupel 把类别节点影射到磁盘的目录,而 Windows 规定某些字符不能用于命名目录或文件的,此时就有必要添加相关的代码来排错了。
另外,如果编辑期间抛出异常,就有可能导致数据处于未定义状态,此时你可以用一个 try 块包围代码:
3.4 继续思考
提供快捷键可以提高应用程序的易用性,我们在 Windows 中重命名目录或文件时通常按 F2 来进入编辑状态而不是使用右键菜单的重命名菜单项,于是我们也可以考虑在 Cupel 中提供类似的便捷:
当你添加新的类别节点时,它会有一个默认的名字——New Category,并且处于编辑状态:
可以看出,它和重命名类别节点名字的代码非常相似,实质上,它等效于先添加一个新的节点,然后对该节点进行重命名。至于移除现有类别节点则更简单:
当然,在实际的应用中,这是远远不够的,因为用户可能只想移除该类别,而不希望丢失其所包含的光盘节点。对于用户来说,正确的做法应该是把待移除的类别所包含的光盘节点移到别的类别节点下,然后再移除类别节点。但没有人能够保证用户一定会这样做,于是你就要有一些措施来避免不必要麻烦了,这里我介绍两个措施:
4. 节点拖放
4.1 说说要求
节点拖放可以用来实现更改某一光盘节点的所属类别,例如,我把图 1-1 中“Music”下的“MC0001”移到“Mix”下,就改变“MC0001”的类别了。由于每个光盘节点都必须隶属某一个分类,于是你不能把它拖放到“My Disc Library”下和类别节点并列。你更不能把一个光盘节点拖放到另一个光盘节点下。换言之,只有光盘节点是可拖动的,并且只能置于类别节点下。
4.2 基础知识
要使得控件接受用户拖放到它上面的数据,你必须把 AllowDrop 属性设为 true,这是第一步。
接下来,你要了解 TreeView 拖放操作所涉及的三个事件:ItemDrag、DragEnter 和 DragDrop。举个例子,我要把图 1-1 中“Music”下的“MC0001”移到“Mix”下,那么当我们在“MC0001”上按下鼠标左键并开始拖动时,ItemDrag 事件就触发了,然后,当“MC0001”被拖到“Music”的“地盘”上时,DragEnter 事件就触发了,最后,当我们在“Music”上松开鼠标左键时,DragDrop 事件就触发了。
从名字上很容易联想到这三个事件的用途:
4.3 实现拖