日期:2014-05-17  浏览次数:21109 次

[翻译]具有TreeView下拉控件的ComboBox
具有TreeView下拉控件的ComboBox



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

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

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

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

告诉你下拉框的真相

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

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

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

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

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

错在哪里

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

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

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

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

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

C# code
protected override bool ShowWithoutActivation {
    get { return true; }
}


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

C# code
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
 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类包含可编辑部分的