0. 背景故事
现在的东西动不动就用G来算,一眨眼的功夫,我那100G的硬盘已拥挤不已了,但还有很多东西想放进来啊,怎么办?好吧,现在 DVD 刻录机的价格已经平民化了,我买了一个来舒缓紧张的硬盘。这下好了,硬盘上的可用空间总是足以让我下载想要的大块头了。没过多久,我刻录的 DVD 就堆积成山,成为我房间的一道景物。为了管理这座“山”,我决定写一个 DVD 管理软件,嗯,就叫它 Cupel 吧。不难想象,Cupel 将充分使用 TreeView 控件的各种功能,现在我把开发 Cupel 的过程中使用 TreeView 的心得写下来,希望能为那些寻找这方面内容的朋友提供一些参考。
1. 填充节点
1.1 说说要求
图 1-1 类别视图
如上图所示,根节点是光盘库,它可以包含0个或多个类别节点,每个类别节点又包含0个或多个光盘节点。Cupel 通过 Cupel.Data.DiscLibrary 类来读取和储存相关数据。
1.2 进行填充
类别视图的节点应该在 Cupel 的主窗体显示之前填充好,于是我选择在 Load 事件发生时进行填充:
//
Code #01
private
void
MainForm_Load(
object
sender, EventArgs e)
{
TreeNode libraryNode
=
new
TreeNode(
"
My Disc Library
"
);
foreach
(DiscCategory category
in
m_MyDiscLibrary.GetCategories())
{
TreeNode categoryNode
=
new
TreeNode(category.Name);
foreach
(
string
label
in
category.GetDiscLabels())
{
categoryNode.Nodes.Add(
new
TreeNode(label));
}
libraryNode.Nodes.Add(categoryNode);
}
m_CategoryView.Nodes.Add(libraryNode);
m_CategoryView.ExpandAll();
}
填充节点的方法是很简单的,上面的代码有两点需要说明:
- 1) 无论是 TreeNode 还是 TreeView,节点都是包含在 Nodes 属性中的,通过该属性的 Add() 方法可以添加新的节点。正如一个 TreeNode 可以包含多个子节点,一个 TreeView也可以包含多个根节点。
- 2) 节点填充完毕后,你应该使用 TreeView.ExpandAll() 方法展开所有节点。然而,当光盘节点过多时,展开全部节点可能不太合适,此时可以考虑只展开类别节点,即把 Code #01 的 m_CategoryView.ExpandAll(); 改为 libraryNode.Expand(); 就行了。
1.3 添加图标
图 1-2 文件夹视图
对于 Windows 的用户,上面这幅图应该是很熟悉了,上面的每个节点都带有一个图标,这使得目录试图更直观。Code #01 并没有为每个节点添加图标,运行结果是每个节点将只有文字。要为节点添加图标,最简单的方法就是在创建节点时通过构造函数来指定,但在此之前,你得先创建一个 System.Windows.Forms.ImageList 实例,并用它来储存图标。这里介绍在 Visual Studio 里使用 ImageList 组件为 TreeView 提供图像资源:
- 1) 在“工具箱”中拖动 ImageList 组件到主窗体;
- 2) 在“属性”窗口中点击 Images 属性右边的“...”按钮打开“图像集合编辑器”;
- 3) 按“添加”按钮添加所需的图标。
- 4) 选中 TreeView 控件,在“属性”窗口中找到 ImageList 属性,并把它的值设为刚才的 ImageList。
至此,相关的准备工作已经完毕,接下来要做的就是修改 Code #01 为节点指定图标,这可以通过使用 TreeNode 如下的构造函数做到:
//
Code #02
public
TreeNode(
string
text,
int
imageIndex,
int
seletedImageIndex)
由于在 Cupel 中无论节点是否被选中,其图标都是一样的,所以上面构造函数的后两个参数值是一样的。假设 category.ico 在 ImageList 中的索引是1,那么你可以这样指定类别节点的图标:
//
Code #03
TreeNode categoryNode
=
new
TreeNode(category.Name,
1
,
1
);
1.4 继续思考
前面说到,每个节点可以包含0个或多个字节点,于是在用户第一次运行 Cupel 时,类别视图将只有一个根节点。这显然是不太友好的,因为面对着“一无所有”的类别视图,用户很可能会不知所措,尤其在他有很多光盘并且还没决定如何对这些光盘分类时。此时我们不妨考虑为用户提供一个默认分类,这样他就可以在此基础上构想一个更合适自己的分类,这要比凭空想出一个分类容易的多。当然,有些用户早已想出了一套很好的分类,此时我们就没必要为他提供默认分类了,而是直接让他应用自己的分类。可以看出,如果 Cupel 在第一次运行时显示一个设置向导,询问用户使用默认分类还是应用自己的分类,则会使用户感到更加友好。
无论多么好吃的东西,每天都吃也会使人感到厌倦。现今是一个个性化的时代,图 1-1 无疑显得有点单调,如果用户可以为每个类别指定一个不同的图标,甚至隶属不同类别的光盘也具有不同的图标,这将会使得 Cupel 令人眼前一亮。进一步考虑,我们可以考虑把类别视图的图标设置储存在一个配置文件,让用户可以选择应用不同的图标套装。当然,有些用户根本不在乎这点儿花样,就像那些一直支持着“Windows 经典”主题的用户一样。可以看出,如果 Cupel 在第一次运行时显示一个设置向导,询问用户使用哪个图标套装,则会使用户感到更加友好。
2. 延迟填充
2.1 说说要求
图 2-1 光盘结构视图
图 2-1 分上下两部分,上面是一个 TreeView,显示了类别视图选中的光盘节点所包含的目录结构,下面是一个 ListView,显示了光盘结构视图选中的节点的细节信息,此图实质上是一个主-从视图。
当光盘所包含的目录或文件节点比较多时,一次过填充光盘结构视图的所有节点很可能导致界面没有响应,这显然是不允许的。其实,我们没有必要一开始就把所有节点都填充上去,而应该在用户访问到某节点时才填充它的子节点。
2.2 做好准备
TreeView 中的节点信息都包含在 TreeNode 中,为了使得光盘结构视图具备延迟填充特性,以及在节点信息视图上显示选中节点的细节信息,我们有必要自定义一个用于 TreeView 的节点类,该类将派生自 TreeNode,并且包含实现相关功能的信息。
节点可分为目录节点和文件节点两类,它们既有相同之处,也有不同之处,于是我们很容易联想到建立一个继承体系:
图 2-2 节点继承图
FileSystemTreeNodeBase 类的 Properties 属性是一个 List<FileSystemTreeNodeProperty> 集合,它包含了与节点的相关信息,这些信息将会显示在节点信息视图上,实现主-从视图。另外,FileSystemTreeNodeBase 类还包含了一个 FillSubNodes 抽象方法,用于协助光盘结构视图实现延迟填充特性。由于文件节点不会有子节点,所以 FileTreeNode.FillSubNodes() 的方法体是空的。现在我们来看一下 DirectoryTreeNode.FillSubNodes():
//
Code #04
public
override
void
FillSubNodes()
{
if
(Nodes.Count
==
0
)
{
this
.TreeView.BeginUpdate();
foreach
(DirectoryNode subDirectoryNode
in
m_DirectoryNode.SubDirectoryNodes)
{
DirectoryTreeNode subDirectoryTreeNode
=
new
DirectoryTreeNode(subDirectoryNode.Name, subDirectoryNode);
subDirectoryTreeNode.Properties.Add(
new
FileSystemTreeNodeProperty(
"
Path
"
, subDirectoryNode.FullName));
Nodes.Add(subDirectoryTreeNode);
}
foreach
(FileNode fileNode
in
m_DirectoryNode.FileNodes)
{
FileTreeNode fileTreeNode
=
new
FileTreeNode(fileNode.Name);
fileTreeNode.Properties.Add(
new
FileSystemTreeNodeProperty(
"
Directory
"
, fileNode.Directory));
fileTreeNode.Properties.Add(
new
FileSystemTreeNodeProperty(
"
File Name
"
, fileNode.Name));
Nodes.Add(fileTreeNode);
}
this
.TreeView.EndUpdate();
}
}
用户有可能在展开某个节点后把它折叠起来,此时该节点的 Nodes 属性就会包含它的子节点(一个例外情况就是原光盘的某个目录是空目录,即里面没有包括任何子目录和/或文件),所以我们应该首先检查 Nodes.Count 是否为0。当条件满足时,我们就对该节点进行填充,留意填充代码包含在 TreeView.BeginUpdate() 和 TreeView.EndUpdate() 之间,这样做是为了避免 TreeView 每填充一个节点就绘制一次,从而提高了效率。
2.3 按需填充
仅当某个节点包含了子节点时,我们才能展开该节点,所以在展开该节点时,就要对其子节点所包含的子节点进行填充。例如,在图 2-1 中,当我们展开根节点(即“G:\”)时,“浪客剑心”所包含的子节点就得填充好了,否则它就无法被展开,它里面的目录结构也就无法显示了。
回到 Cupel,当用户选中类别视图中的某个光盘节点,光盘结构视图就会显示该光盘的根节点及其所包含的子节点:
//
Code #05
DirectoryTreeNode rootDirectoryTreeNode
=
rootDirectoryTreeNode.FillSubNodes();
m_DiscInfoView.Nodes.Clear();
m_DiscInfoView.Nodes.Add(rootDirectoryTreeNode);
接着,当用户点击可展开节点左边的“+”时,将引发 TreeView 的 BeforeExpand 事件,此时是填充该节点的子节点的子节点的最佳时机:
//
Code #06
private
void
m_DiscInfoView_BeforeExpand(
object
sender, TreeViewCancelEventArgs e)
{
foreach
(FileSystemTreeNodeBase subNode
in
e.Node.Nodes)
{
subNode.FillSubNodes();
}
}
2.4 显示细节
当用户选中光盘结构视图中的某个节点时,节点信息视图将显示与该节点相关的信息,这两个视图共同组成一个主-从视图:
//
Code #07
private
void
m_DiscInfoView_NodeMouseClick(
object
sender, TreeNodeMouseClickEventArgs e)
{
if
(e.Button
==
MouseButtons.Left
&&
e.Clicks
==
1
)
{
m_NodeInfoView.Items.Clear();
FileSystemTreeNodeBase fileSystemTreeNode
=
(FileSystemTreeNodeBase)e.Node;
foreach
(FileSystemTreeNodeProperty property
in
fileSystemTreeNode.Properties)
{
m_NodeInfoView.Items.Add(
new
ListViewItem(
new
string
[]
{
property.Name,
property.Value
}
)
);
}
}
}
值得注意的是,Code #07 首先检测是否为鼠标左键点击以及点击次数是否为1,这些信息都包含在类型为 TreeNodeMouseClickEventArgs 的 e 参数中。另外,e.Node 是当前选中的节点,你必须把它强制转换成 FileSystem.TreeNodeBase 类型才能访问其所包含的 Properties 属性。
2.5 继续思考
虽然我们使用了“延迟填充”,但在展开某些节点时依然会感觉到“迟钝”,出现这种情况的主要原因是该节点的子节点包含着大量子节点。此时我们可以在展开之前把鼠标指针改为等待样式,待节点展开完毕后再改为默认样式:
//
Code #08
private
void
m_DiscInfoView_BeforeExpand(
object
sender, TreeViewCancelEventArgs e)
{
m_DiscInfoView.Cursor
=
Cursors.WaitCursor;
//
}
private
void
m_DiscInfoView_AfterExpand(
object
sender, TreeViewEventArgs e)
{
m_DiscInfoView.Cursor
=
Cursors.Default;
}
另外,这里所提出的延迟填充方案并不是最佳方案。试想一下,如果我只展开图 2-1 中的“Bleach OVA”节点,而“浪客剑心”节点里面包含着数量可观的子节点却无需展开,那么 Cupel 的运行效率将受到影响。再者,预先填充这么多不需要的节点也会造成内存空间的浪费。为了避免这些弊端,我们可以修改一下这个方案,用“伪子节点”代替真实子节点来进行填充。还是拿图 2-1 来举例,当用户展开根节点时,填充“Bleach”、“Bleach OVA”和“浪客剑心”等子节点,接着分别为这些子节点填充一个“伪子节点”。当用户继续展开“浪客剑心”节点时,它所包含的“伪子节点”将被删除,取而代之的是它原本包含的真实子节点。
3. 节点编辑
3.1 说说要求
这里所说的“节点编辑”是狭义的重命名现有节点的名字,广义上它还包括添加新节点以及移除现有节点。下图示范了 Cupel 把“Anime”节点重命名为“Cartoon”:
图 3-1 编辑类别名
对节点进行重命名时需要注意:
- 1) 新名字不能为空字符串;
- 2) 新名字不能和已存在的名字相冲突;
- 3) 新名字不允许包含某些特殊字符(可选)。
3.2 开始编辑
TreeView.LabelEdit 属性指示了节点是否允许编辑,默认情况下,它的值为 false。我们可以为类别节点提供一个上下文菜单,里面包含一个重命名菜单项,当用户点击该菜单项时,该类别节点进入编辑状态:
//
Code #09
m_CategoryView.LabelEdit
=
true
;
if
(
!
m_CategoryView.SelectedNode.IsEditing)
{
m_CategoryView.SelectedNode.BeginEdit();
}
注意,仅当 TreeView.LabelEdit 为 true 时,TreeNode.BeginEdit() 方法才可用,否则会抛出 InvalidOperationException 异常。
3.3 完成编辑
节点完成编辑后将引发 TreeView.AfterLabelEdit 事件,该事件通过 NodeLabelEditEventHandler 委托来作用,该委托所包含的类型为 NodeLabelEditEventArgs 的参数 e 包含了完成编辑所需的信息:
- 1) e.Node 是当前编辑的节点;
- 2) e.Label 是用户为节点输入的新名字。
根据 3.1 中提到的三点要求的前两点,我们可以写出如下代码:
//
Code #10
private
void
m_CategoryView_AfterLabelEdit(
object
sender, NodeLabelEditEventArgs e)
{
if
(e.Label
!=
null
)
{
if
(e.Label.Length
>
0
)
{
if
(
!
m_MyDiscLibrary.IsCategoryNameExisting(e.Label))
{
e.Node.EndEdit(
false
);
//
}
else
{
e.CancelEdit
=
true
;
MessageBox.Show(
"
类别名已存在。
"
);
e.Node.BeginEdit();
}
}
else
{
e.CancelEdit
=
true
;
MessageBox.Show(
"
类别名不能为空。
"
);
e.Node.BeginEdit();
}
}
m_CategoryView.LabelEdit
=
false
;
}
在某些情况下,第三点要求是必须的,例如 Cupel 把类别节点影射到磁盘的目录,而 Windows 规定某些字符不能用于命名目录或文件的,此时就有必要添加相关的代码来排错了。
另外,如果编辑期间抛出异常,就有可能导致数据处于未定义状态,此时你可以用一个 try 块包围代码:
//
Code #11
try
{
//
}
catch
()
{
e.Node.EndEdit(
true
);
}
finally
{
m_CategoryView.LabelEdit
=
false
;
}
3.4 继续思考
提供快捷键可以提高应用程序的易用性,我们在 Windows 中重命名目录或文件时通常按 F2 来进入编辑状态而不是使用右键菜单的重命名菜单项,于是我们也可以考虑在 Cupel 中提供类似的便捷:
//
Code #12
private
void
m_CategoryView_KeyUp(
object
sender, KeyEventArgs e)
{
if
(e.KeyCode
==
Keys.F2
&&
m_CategoryView.SelectedNode
!=
null
&&
m_CategoryView.SelectedNode.Level
==
1
)
{
RenameDiscCategory(
null
, EventArgs.Empty);
}
}
当你添加新的类别节点时,它会有一个默认的名字——New Category,并且处于编辑状态:
//
Code #13
TreeNode newTreeNode
=
new
TreeNode(
"
New Category
"
,
1
,
1
);
m_CategoryView.Nodes[
0
].Nodes.Add(newTreeNode);
m_CategoryView.LabelEdit
=
true
;
if
(
!
newTreeNode.IsEditing)
{
newTreeNode.BeginEdit();
}
可以看出,它和重命名类别节点名字的代码非常相似,实质上,它等效于先添加一个新的节点,然后对该节点进行重命名。至于移除现有类别节点则更简单:
//
Code #14
if
(m_CategoryView.SelectedNode
!=
null
)
{
m_CategoryView.SelectedNode.Remove();
}
当然,在实际的应用中,这是远远不够的,因为用户可能只想移除该类别,而不希望丢失其所包含的光盘节点。对于用户来说,正确的做法应该是把待移除的类别所包含的光盘节点移到别的类别节点下,然后再移除类别节点。但没有人能够保证用户一定会这样做,于是你就要有一些措施来避免不必要麻烦了,这里我介绍两个措施:
- 1) 显示一个对话框提示用户把待移除的类别节点所包含的光盘节点移动到别的类别节点,再执行删除操作,这个对话框通常是一个向导;
- 2) 类别节点移除后,原本隶属该类别的光盘节点将被移到一个“Uncategorized”类别节点下,等待用户做进一步的处理。
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 事件就触发了。
从名字上很容易联想到这三个事件的用途:
- 1) ItemDrag 用于判断对象是否允许拖动,如果允许则用 DoDragDrop() 方法初始化拖放操作。例如,如果被拖动的是“Music”而不是“MC0001”,我们应该中止拖放操作;
- 2) DragEnter 则用于判断拖过来的数据是否可以接受,用户极有可能把非预期的数据拖过来,于是你有责任确保控件只接受那些可解释的数据。例如,如果用户拖过来的是一段文本,那么拖放操作就不能继续了;
- 3) 在 DragDrop 中,我们要做的就是解释用户拖放过来的数据,并对这些数据做适当的处理,当然,数据无法正确解释也是有可能发生的,所以你有责任确保这些数据不会影响到现有的正常数据。
4.3 实现拖放
首先是处理 ItemDrag 事件:
//
Code #15
private
void
m_CategoryView_ItemDrag(
object
sender, ItemDragEventArgs e)
{
if
(e.Button
==
MouseButtons.Left)
{
TreeNode sourceTreeNode
=
(TreeNode)e.Item;
if
(sourceTreeNode.Level
==
2
)
{
DoDragDrop(sourceTreeNode, DragDropEffects.Move);
}
}
}
需要说明的是,我们只接受左键拖动,而且源节点必须是光盘节点。在类别视图上,节点只有三种类型:根节点、类别节点和光盘节点,它们分别是树的第一层、第二层和第三层。而 TreeNode.Level 属性恰好表达了节点的层次索引,只是它是从0开始的。于是,光盘节点的 Level 属性值就是2。一切顺利的话,我们就可以用 DoDragDrop 方法来初始化拖放操作了。
然后是处理 DragEnter 事件:
//
Code #16
private
void
m_CategoryView_DragEnter(
object
sender, DragEventArgs e)
{
Point targetPoint
=
m_CategoryView.PointToClient(
new
Point(e.X, e.Y));
TreeNode targetNode
=
m_CategoryView.GetNodeAt(targetPoint);
if
(targetNode
!=
null
&&
targetNode.Level
==
1
&&
e.Data.GetDataPresent(
typeof
(TreeNode)))
{
e.Effect
=
e.AllowedEffect;
m_CategoryView.SelectedNode
=
targetNode;
}
else
{
e.Effect
=
DragDropEffects.None;
}
}
在这里,我们必须确保目标节点是类别节点,并且用户拖过来的数据类型是 TreeNode,否则不受理。要判断目标节点是否为类别节点,首先得获得目标节点的实例,这可以通过向 TreeView.GetNodeAt(Point pt) 方法传递目标节点的工作区坐标做到。然而,DragEventArgs.X 和 DragEventArgs.Y 所给出的是屏幕坐标,于是我们就需要使用 TreeView.PointToClient(Point pt) 方法把目标节点的屏幕坐标转换成工作区坐标了。要判断拖过来的数据类型是否为 TreeNode,我们只需把 typeof(TreeNode) 传递给 e.Data.GetDataPresent 并判断其返回值就可以了。
最后是处理 DragDrop 事件:
//
Code #17
private
void
m_CategoryView_DragDrop(
object
sender, DragEventArgs e)
{
Point targetPoint
=
m_CategoryView.PointToClient(
new
Point(e.X, e.Y));
TreeNode targetNode
=
m_CategoryView.GetNodeAt(targetPoint);
TreeNode sourceNode
=
(TreeNode)e.Data.GetData(
typeof
(TreeNode));
if
(sourceNode.Parent.Text
!=
targetNode.Text)
{
sourceNode.Remove();
targetNode.Nodes.Add(sourceNode);
}
}
要处理拖过来的数据,当然就得先获取目标节点,这点和处理 DragEnter 事件的一样。由于通过了 DragEnter 事件的检测,我们就可以在这里直接解释拖过来的数据了,这可以通过 e.Data.GetData(Type t) 方法做到。把一个光盘节点拖放到一个类别节点相当于把该光盘节点从原来的类别节点上删除,并添加到目标类别节点,当然我们应该判断与拖放操作相关的两个类别节点是否为同一个节点。
4.4 继续思考
“得一想二”是用户的本性,我在这里介绍的拖放操作仅存在于同一个控件中的,难免日后用户会期望得到跨控件/程序的拖放支持,例如,我把某张 DVD 放进光驱,然后打开我的电脑,把光驱的图标拖到类别视图上的某个类别节点,期望着 Cupel 会自动为我处理后续事宜。当 DVD 上的目录/文件数量可观时,用户可能期望有一个进度条以便做到心中有数,甚至用户还期望在此过程中可以随时中止 DVD 信息获取操作......噢,你还能想到什么?
5. 后面的事
为了更好的管理我那座“山”,我引入了 Cupel,同时也引入了开发 Cupel 时的种种问题,这使我想起杰拉尔德·温伯格在《你的灯亮着吗?》中提到的一句话:每种解决方案都会带来新的问题。回顾上面所说的一切,我们很容易想到把类别视图封装成一个自定义控件,而光盘结构视图和节点信息视图则组合封装成一个用户控件,这样做的好处不言自明,然而这又会引入什么新的问题呢?说到这里,我又不禁想起另一句话:终点其实是另一个起点。