具有TreeView下拉控件的ComboBox

具有TreeView下拉控件的ComboBox



没错,如标题所说的那样,在下拉框中是一个TreeView,但是,为什么我们需要这样的控件?事实上这样的需求我已经遇到很多次了,比如适用于:

 当遇到层次结构的数据
 让用户选择树上的一个节点
 需要TreeView但是界面上缺少足够的空间
 用于不经常修改的选项
 当一个对话框看起来太笨重和突兀

在这些情况下,一个普通的ComboBox不符合要求,无论你喜欢不喜欢,它不会同时显示每一个数据条目以及它们的结构。

我会大篇幅探讨过在Windows窗体中写一个这样的控件面临的挑战以及分享围绕ToolStripDropDown控件的常见的陷阱以及最终的解决方案。

告诉你下拉框的真相

当考虑这个问题时,大多数人会立即告诉你下拉框是一个窗体。毕竟,它表现出了很多窗体具有的行为:

 它的位置可以超出父窗体客户区的范围
 能够独立处理鼠标和键盘输入
 出现在最顶层,其他窗口不能掩盖它

然而,这种观点是根本错误的。下拉框有一些非常不同于窗体的东西:

 它们打开时,父窗体的焦点不会被转移过来
 当它们捕捉鼠标/键盘输入,父窗体的焦点不会被转移过来
 同它们的子控件/项交互的时候,不切换焦点

上述的这几点代表它和窗体、控件的工作方式迥然不同,那么是如何做到能拥有这些特性呢?

错在哪里

考虑到这一点,在Windows窗体中用如下“伟大的”方式实现下拉框时会遭遇失败:

使用一个窗体(懵懂无知期)

于是,你决定用一个Form实现下拉框。你将这个窗体设置为无边框的并且在任务栏上不显示图标,也许它被设置了TopMost属性。你甚至可能会设置它的父窗体,不管其中细节的差异,都是这样:用户点击下拉按钮然后你的窗体被显示。当焦点切换到下拉框窗体时,会有一个轻微的闪烁。父窗体的标题栏会变色,窗体阴影变淡(在Aero下)。然后用户通过下拉列表控件所在的窗体做出选择,然后窗体被关闭。直到再次单击父窗体焦点改变,标题栏改变了颜色,并且窗体的阴影又变深。又会出现一次尴尬的闪烁。最后结果如何?需要额外的点击,非常差的用户体验。

还是使用一个窗体,尝试变得更聪明

那些已经尝试了以上办法(或对WinForms/Win32的问题有更深入的理解)的人会尽力解决最初的问题,那就是,下拉窗体会从父窗体抢到焦点。一个鲜有人知的秘密是,Form类有一个受保护的属性,称为ShowWithoutActivation,(在大多数情况下),它将在显示窗体的时候跳过窗体的激活:

C# code?
1
2
3
protected override bool ShowWithoutActivation {
    get { return true; }
}


如果设置ShowWithoutActivation没有效果,可以通过重写CreateParams属性强制设置控件样式的WS_EX_NOACTIVATE标志:

C# code?
1
2
3
4
5
6
7
8
protected override CreateParams CreateParams {
    get {
        const int WS_EX_NOACTIVATE = 0x08000000;
        CreateParams p = base.CreateParams;
        p.ExStyle |= WS_EX_NOACTIVATE;
        return p;
    }
}


请注意,这样做不会阻止当窗体可见时它获得焦点,也就是说只要单击了窗体,这个问题还是会出现。因此,要想成功预防在窗体显示的时候成功获得焦点,下一个挑战是,防止它任何时候获得焦点。这是所有Win32拦截窗口消息技术中最常用的。特别来讲是响应WM_MOUSEACTIVATE消息(当单击事件被触发但是没有获得焦点时发送)设置为MA_NOACTIVATE(不改变焦点,处理单击事件):

C# code?
1
2
3
4
5
6
7
8
protected override void DefWndProc(ref Message m) {
    const int WM_MOUSEACTIVATE = 0x21;
    const int MA_NOACTIVATE = 0x0003;
    if (m.Msg == WM_MOUSEACTIVATE)
        m.Result = (IntPtr)MA_NOACTIVATE;
    else
        base.DefWndProc(ref m);
}


实际上这样是可以工作的,当点击下拉窗体的时候也不会导致转移焦点。问题是,下拉不只是窗体本身,单击窗体上任意的控件你会发现之前做的工作算白做了。可以在控件上再如法炮制在窗体上做过的消息拦截技术,但一些控件(例如TreeView)不行。比方说选择一个树节点,始终会转移焦点。

所以,在这个问题之后,你留下了一堆子类化控件和底层的代码,又得到什么了呢?一个很好的用户体验,至少是在实际点击它之前,最后还是没用。

替换为使用控件实现

最后,你可能会认为使用一个控件并且直接添加到父窗体上可能是一个避免焦点问题的聪明的办法……,是的,它完全解决了这个问题。不幸的是,现在下拉框的范围不能超过父窗体的客户区。你可能会认为这也挺好,只要您的父窗体在下拉控件的位置下面有足够空间,但是这没有完全实现使用一个下拉框的首要的用途。这个“解决方案”同样不可取。

进阶,ToolStripDropDown

事实上,随着对下拉列表展现的行为观察的深入,会越来越发现,他们更类似菜单而不是窗体。菜单可以独立处理键盘和鼠标输入同时不会从它们的父窗体争夺焦点,所以,让我们看看在框架库中一个做明显的例子,ContextMenuStrip控件。这是一个仅仅被继承的控件,在所有其他方面,它的行为就像一个组件。不过更重要的是,这是不是一个窗体,从而避免了焦点的问题。事实上,菜单在焦点处理上相比其他元素时,代表了第三类:

 窗体:从父窗体获得焦点,而且焦点系统独立于父窗体的控件。
 控件:从它们的父控件获得焦点,但隶属于父窗体,和它的其它控件是同一个焦点体系。
 菜单:和它的父控件、子控件共享焦点体系。

这种行为精确地代表了我们在下拉框中的需求,然而ContextMenuStrip有一个非常明确的用途。深入观察它的继承链,可以找到ToolStrip,ToolStripDropDown和ToolStripDropDownMenu。第一个并不代表一个菜单,所以它对我们来说没用。第二个是下拉框的基类,是一个不错的开端。值得一提的是,ToolStripDropDown不支持滚动(而它未来的派生类可以),然而在我用来实现下拉框的方法中,自动滚动支持是不必要的。

任何ToolStripItem的派生类(标签,按钮等)可以被添加到下拉列表框中,包括了ToolStripControlHost(虽然我不是选择简单地将一个TreeView控件放入下拉框中,这样仍然会有一些诡异的焦点问题,没有提及的间接开销)。下拉列表框自身必须包含至少一个项,即使它不会占用空间。作为一个控件类的派生类,它可以通过重写OnPaint方法重绘,并且同时响应鼠标和键盘事件。结合了无焦点行为,它提供了符合我们创建下拉框所预期的所有行为所需的工具。

实现

全功能的TreeView在下拉控件中并不需要,所以我们基于一个类似(但是更简单)的数据模型来实现。



DropDownBase类包含可编辑部分的基本功能,绘制,事件处理和设计时支持,以及DroppedDown状态的管理。数据模型围绕类似于TreeNode的ComboTreeNode类。它和它的集合类型ComboTreeNodeCollection构成创建一棵树的循环关系。ComboTreeBox类是DropDownBase类主要的实现类。它拥有和控制数据模型并且可视化选定的节点。它还拥有ComboTreeDropDown——实际的下拉列表(ToolStripDropDown类的派生类)。

在此实现中,数据模型和视图完全分离,节点可以单独于控件/下拉框被定义和操纵。

模型——ComboTreeNode类和ComboTreeNodeCollection集合

ComboTreeNode类是一个简单用来保存节点的名称,文本,状态以及和模型树中其它节点之间关系的原子类。ComboTreeNodeCollection集合表示子树,并因此关联一个父节点。此规则的唯一例外是对于尚未被添加到控件的子树,以及根集合属于控件。无论这个类和ComboTreeBox类关系如何,当添加到ComboTreeBox控件中时都会带上附加的属性和行为。

ComboTreeNodeCollection集合实现了IList<ComboTreeNode>,因为节点的顺序是很重要的。一个内部对象List<ComboTreeNode>用来作为后备存储字段。集合负责分配每个节点的父节点,以确保树的CollectionChanged事件(来自INotifyCollectionChanged接口)被递归触发:

C# code?
1
2
3
4
5
6
7
8
9
public void Add(ComboTreeNode item) {
    innerList.Add(item);
    item.Parent = node;
    // changes in the subtree will fire the event on the parent
    item.Nodes.CollectionChanged += this.CollectionChanged;
    OnCollectionChanged(
        new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item)
    );
}


它还实现了非泛型的IList,以便提供对Windows窗体设计器使用的CollectionEditor类的兼容。

视图——下拉框自身

ComboTreeDropDown类负责显示以及提供和model.Visually的数据交互,节点可以被表示为一系列的行,其上界由它们在树中的深度决定,其可见性由它们的父节点是否被扩展决定。每个节点的可见性,通过递归检查节点的父节点的Expanded属性决定,只有树的根节点到它的路径上的所有节点都没有被折叠,这个节点才可见:

C# code?
1
2
3
4
5
6
7
8
9
10
11
internal bool IsNodeVisible(ComboTreeNode node) {
    bool displayed = true;
    ComboTreeNode parent = node;
    while ((parent = parent.Parent) != null) {
        if (!parent.Expanded) {
            displayed = false;
            break;
        }
   }
   return displayed;
}


渲染

一组简单合理的规则规定了节点之间的连接线如何绘制,事实上分离不同的排列组合并且分别缓存结果位图以节省时间和内存是可行的(这些独立的排列可以用BITMAPINFO结构体表示)。展开或折叠节点会使得可见节点的超集改变。从树中添加/删除节点。更改父控件的大小和字体的将导致缓存集被重新构建。

滚动

滚动是手工实现的,基于一个非常简单的原理:可见节点的邻接子集,取决于下拉框的最大高度的大小,被用来作为一个偏移量。

 滚动条的范围等于可见项的总数-在下拉列表框中可以显示的最大的子集项数。
 滚动条的位置可以表示为相对范围内的偏移量的百分比。
 滚动条滑块的大小等于滚动区域内条目所占可见项的总数的百分比。

要实际渲染下拉框,接下来,要确定可见滚动范围内的节点相对于顶部的位置。使用NodeInfo对象表示每个节点的可视属性。对于每个节点有两个绘制操作,缩进,节点图标,手柄、连接线和节点文本被用(缓存的)位图表示。如果滚动的范围内的节点数目小于可见节点的总数,滚动条同时被绘制。

响应鼠标事件时也需要绘制每个节点的边界以及滚动条(保存在ScrollBarInfo中)部分。如果鼠标指针落在某个项的范围之内,相关联的ComboTreeNode对象可以被确定。单击节点旁的加/减手柄可以使其折叠/展开,而在节点上的任意位置单击将改变选择项并关闭下拉框。(值得注意的是,一些鼠标特性,比如拖动滚动条,并按住按钮的自动重复行为会有些复杂,并不属于本文讨论范围。然而要提一下一个最值得一提的问题——下拉框的MouseWheel事件之能由父控件来处理。)

高亮显示项目 vs 选中节点

每次下拉列表显示,它滚动到(并高亮显示)父控件的SelectedNode属性对应的节点。高亮显示项目和选定的节点是两个概念,如同普通的ComboBox的下拉框。这些规则适用于:

 移动鼠标到某个项目上,使其高亮,但除非单击该项,否则选中的节点不会改变。
 使用键盘的上/下键导航会同时改变高亮项目和选择的节点。
 滚动(包括鼠标滚轮、键盘(上页/下页)或使用滚动条)既不会改变高亮项目也不会改变选中的节点。

控制器——父控件

ComboTreeBox是这个实现中的控制器。另外它还暴露了用来管理数据模型并提供对视图的访问的操作——将所有的概念封装成成一个可重用的Windows窗体控件。因此,它结合了ComboBox和TreeView控件两方面的行为:

 为每个节点分配一个名称,这个名称将被用来通过集合访问节点。
 使用ImageIndex/ImageKey为每个节点指定一个图标。
 保存每个节点的展开状态。
 SelectedNode属性用来获取/设置用户在树中的选择。
 PathSeparator和Path属性来表示选定节点的路径字符串。
 BeginUpdate()/EndUpdate()方法实现批量添加。
 ExpandAll()/CollapseAll()方法管理树的节点全部收起/全部展开。
 对树的排序(执行递归排序,使用默认或自定义的比较器)。

提供以下附加功能:

 递归的枚举器(AllNodes)遍历整棵树。
 根据节点名称或文本判断路径是否构成。
 使用Path属性设置选定的节点。
 ExpandedImageIndex/ExpandedImageKey:以允许展开的节点显示一个不同的图标。

TreeView控件的以下功能没有在ComboTreeBox中实现:

 水平滚动(在下拉框中不引人注目)。
 节点旁的复选框(多选)。
 自定义元素的外观或使用事件重绘控件。
 SelectedImageIndex/SelectedImageKey属性(其实,个人而言,我觉得没什么用)。
 节点上的工具提示,拖放功能等超过下拉列表框之外的其他功能。
 一个Sorted属性,用来创建自动排序的树,取而代之的是,它可以按需排序。

写在最后

ComboTreeBox既是一个自定义下拉列表框的例子,同时也是创建一个Windows窗体的解决方案。ToolStripDropDown组件使这一切成为可能,即使(在本例中)需要手工实现下拉框中的内容。对于编写自定义控件,保持原有的外观,行为和处理输入,这也是一个非常有益的练习。我特别满意的是优雅的滚动机制和使用位图缓存,这些使得我能够成功填充几十万个节点到下拉框中而不会导致性能损失(即使这样的规模被认为是不适合下拉框的)。

通过演示如何实现一个自定义下拉框,我希望能起到抛砖引玉的作用,发掘它更多的用处。

下载

源代码
(基于.NET 3.5, 引用System.Design和WindowsBase)

posted on 2012-11-21 15:32  Sandy Lee  阅读(2474)  评论(0编辑  收藏  举报

导航