ContainerListView 和 TreeListView:编写与 VS.NET 设计界面兼容的控件
了解如何使用 TypeConverters 和 UITypeEditors 将自定义 .NET 控件正确集成到 Visual Studio .NET 设计环境中。本文包含两个有用的控件:一个 ContainerListView 和一个功能齐全的 TreeListView。
引言
在当今世界,我发现以有意义且紧凑的方式呈现数据越来越复杂。我还发现,各种控件,尤其是市面上销售的控件,似乎都是仓促制作的,原因可能是时间限制,或者仅仅是因为想尽可能便宜地赚钱。
正是由于现有组件缺乏质量,我才决定花时间创建我迫切需要的两个控件。第一个是一个 listview,它为每列的控件或图像提供容器,而不是只允许文本。第二个是一个高质量的混合树状列表,它还提供了上述列表的相同功能。
本文将概述这两个控件:ContainerListView
和 TreeListView
。这两个控件都是纯粹使用 C# 中的 .NET 类编写的。我尽量避免调用外部 API。这两个控件都使用了 Pierre Arnaud(OPaC Bright Ideas)创建的 .NET 库。Pierre 的库通过包装 uxtheme.dll 函数,允许使用 Windows XP 视觉样式。这个优秀的库可以在 CodeProject 上下载,扩展版本(添加了这些控件所需的 DrawThemeEdge
函数)可在 zip 文件中找到。非常感谢 Pierre 创建了这个库,它多次帮了我大忙。
这是我第一次尝试编写自定义控件。有些逻辑没有优化,有点臃肿。有几个函数消耗大量 CPU 周期,主要是 OnMouseDown
代码,它试图将鼠标单击位置与可见项匹配。我愿意接受建议和错误修复,并非常感谢它们。我将继续改进这两个控件,尽可能添加功能和优化。
主题焦点
本文并未过多关注这两个控件的创建,尽管它触及了一些要点。这篇文章的真正灵感来自于我花费了大量时间来学习如何成功地将控件集成到 Visual Studio .NET 设计环境中。这花费了我太多时间。这两个控件都与 VS.NET 中的设计界面完全兼容,利用了 UITypeEditor
并正确地序列化源代码。
完成此类任务的步骤非常简单,但文档很少且不为人所知。大多数控件开发人员都会跳过将控件集成到设计界面的过程。我写这篇文章的目的是让您熟悉 .NET 的设计功能,以及为您的控件添加正确代码序列化的几个简单步骤。
ContainerListView 控件
标准的 .NET ListView
控件支持多列,每列都可以包含一个唯一的文本值。在许多情况下,这种简单性就足够了,或者就是所需的一切。但很多时候,确实需要将文本以外的内容嵌入到 ListView
的列中。
ContainerListView
提供了将文本、图像或控件嵌入到 ListView
项的每个子项中的能力。该控件还添加了一些高级功能,例如列和行跟踪、3 个独特的上下文菜单(列标题、行、通用)、Windows XP 视觉样式集成,以及在设计模式下编辑项(包括子项及其图像或控件)的能力。该控件还将子控件发出的 MouseDown
事件连接到 ContainerListView
本身,确保即使单击子控件也能正确选择行。
创建和填充 ContainerListView
控件相对简单。如前所述,完全可以在 VS.NET 的设计界面中拖放控件,并完全控制几乎所有的设计设置。如果您想手动编写 GUI 代码,这里有一个示例:
ContainerListView clv = new ContainerListView();
clv.Text = "Sample ContainerListView";
clv.Name = "clv";
clv.Dock = DockStyle.Fill;
clv.VisualStyles = true; // enable integration with
// WindowsXP visual styles
ContainerListViewItem clvi = new ContainerListViewItem();
clvi.Text = "Test";
// Add column headers
ToggleColumnHeader tch = new ToggleColumnHeader();
tch.Text = "Column 1";
clvi.Columns.Add(tch);
tch = new ToggleColumnHeader();
tch.Text = "Column 2";
clvi.Columns.Add(tch);
tch = new ToggleColumnHeader();
tch.Text = "Column 3";
clvi.Columns.Add(tch);
// Add a row item with a child progressbar
ContainerSubListViewItem cslvi = new ContainerSubListViewItem("Test");
clvi.SubItems.Add(cslvi);
ProgressBar pb = new ProgressBar();
pb.Value = 25;
cslvi = new ContainerSubListViewItem(pb);
clvi.SubItems.Add(cslvi);
clv.Items.Add(clvi);
这个 ContainerListView
控件继承自 System.Windows.Forms.Control
类,而不是扩展现有的 ListView
控件。部分目标是看看我是否能从头开始创建一个控件,部分是为了使其尽可能低调,而不挂钩 Windows 通用控件(标准 ListView
会这样做)或使用 Windows API 调用。我想要一个纯 .NET 实现。如果您想查看控件的详细信息,请查看 zip 文件中的源代码。
TreeListView 控件
TreeListView
是一个混合控件。它将 TreeView
与上面的 ContainerListView
控件结合起来,允许第一列表现得像一个树。TreeListView
控件最近变得很受欢迎,在 CodeProject 和其他网站上有很多可用的。没有一个完全符合我想要的功能,所以我扩展了 ContainerListView
并添加了树。这个 TreeListView
支持 ContainerListView
的所有功能,除了行跟踪,目前正在进行中。
这是一个例子。
TreeListView tlv = new TreeListView();
tlv.SmallImageList = smallImageList;
tlv.VisualStyles = true;
// Add column headers
ToggleColumnHeader tch = new ToggleColumnHeader();
tch.Text = "Tree Column";
tlv.Columns.Add(tch);
tch = new ToggleColumnHeader();
tch.Text = "Column 2";
tlv.Columns.Add(tch);
tch = new ToggleColumnHeader();
tch.Text = "Column 3";
tlv.Columns.Add(tch);
// Add tree nodes
TreeListNode tln = new TreeListNode();
tln.Text = "Test";
tln.ImageIndex = 1;
tln.SubItems.Add("Sub Item 1");
tln.SubItems.Add("Sub Item 2");
TreeListNode tln2 = new TreeListNode();
tln2.Text = "Child 1";
tln2.ImageIndex = -1; // Setting to -1 will suppress icon
tln2.SubItems.Add("Sub Item 1.1");
tln2.SubItems.Add("Sub Item 2.1");
tln2.Nodes.Add(tln2);
tlv.Nodes.Add(tln);
tln = new TreeListNode();
tln.Text = "Second Test";
tln.ImageIndex = 1;
tln.SubItems.Add("Test Item");
tln.SubItems.Add("Test Item");
tlv.Nodes.Add(tln);
自定义控件渲染
这两个控件的初始版本使用了几个私有成员函数来绘制按钮、焦点框等元素。绘制列标题按钮、边框等的每个状态都需要大量代码。
.NET 框架提供了一个非常方便的类,即 System.Windows.Forms
命名空间中的 ControlPaint
类。这个方便的小类包含大量静态函数,为按钮、边框、焦点框等各种对象提供渲染功能。使用 ControlPaint
类可以大大减少控件中的绘图代码量,并帮助保持正确的 Windows 外观和感觉。
这两个控件的一个重要特性是它们集成了 Windows XP 视觉样式。这是通过 Pierre Arnaud 编写的一个库实现的。它封装了 uxtheme.dll 中找到的视觉样式渲染函数,并提供了一种非常简单的方法在 .NET 应用程序中使用它们。这个优秀的库可以在 CodeProject 上找到:https://codeproject.org.cn/cs/miscctrl/themedtabpage.asp。我强烈推荐这个库给任何希望轻松地将他们的控件与 Windows XP 集成的人。Pierre 的文章还涵盖了如何让标签页在 XP 下正确渲染。非常感谢 Pierre 提供的优秀库。
集成到设计界面
现在进入本文的重点,将控件集成到 VS.NET 设计界面。这两个控件编写总共花了大约 4 天时间。在这 4 天中,有 3 天以上花在了研究如何使用 .NET 的设计功能来使这些控件尽可能专业和有用。
集成一个控件,特别是使用集合的控件,需要使用 .NET 中提供的几种设计服务:UITypeEditor
、TypeConverter
和设计时属性。 UITypeEditor
提供了一种显示任何对象 GUI 编辑对话框的方法。 TypeConverter
提供了一种将自定义类转换为正确源代码的方法。设计时属性提供了在控件中启用这些设计时编辑功能的方法。
选择 UITypeEditor
根据您的项目,实现 UITypeEditor
可以非常简单,也可以非常复杂。使用框架中提供的编辑器之一可以使生活非常简单,在很多情况下,提供的编辑器之一可以很好地完成工作。其他时候,您可能需要编写自己的 UITypeEditor
,这超出了本文的范围。
.NET 框架中提供的一些 UITypeEditor
如下。 System.Drawing.Design.FontEditor
在应用于属性时显示标准的字体选择对话框。默认的 Control.Font
属性使用此编辑器。 System.Drawing.Design.ImageEditor
显示一个筛选为图像类型的打开文件对话框,并在 .NET 框架的许多地方使用。 System.Windows.Forms.Design.FileNameEditor
显示用于需要文件名属性的标准文件打开对话框。 System.Windows.Forms.Design.AnchorEditor
显示用于设置每个 Windows Form
控件的 Control.Anchor
设置的独特下拉菜单。
最独特也是可能最有用的编辑器是 System.ComponentModel.Design.CollectionEditor
。它显示一个两窗格对话框,用于编辑几乎任何类型的集合。 CollectionEditor
只期望在您的集合中有一个索引器和一个添加方法才能正常工作。正确实现 CollectionEditor
,确保正确进行代码序列化,虽然不复杂,但文档不完善,并且通过试错来弄清楚很繁琐。
将 CollectionEditor 集成到控件中
要为您的控件集成 CollectionEditor
,您需要执行以下几项(如果不是全部)任务。根据您实现的将包含在集合类中的项类,可能需要实现的事情比这里描述的要少。
集成 CollectionEditor
的第一步是开发您的集合类和要包含在该集合中的项类。下面是一个简单的例子:
public class MyCollectionItem
{
// required for type converter
public MyCollectionItem() { }
#region Properties
// properties here
#endregion
#region Methods
// public methods here
#endregion
}
public class MyCollection: CollectionBase
{
// a basic indexer of the type of your collection
// item is required
public MyCollectionItem this[int index]
{
get { return List[index] as MyCollectionItem; }
set { List[index] = value; }
}
// an Add method with a parameter of the type of
// your collection item is required
public int Add(MyCollectionItem item)
{
return List.Add(item);
}
}
为了使 CollectionEditor
工作,您必须提供一个采用整数参数并返回您的集合项类型(在此例中为 MyCollectionItem
)的索引器,以及一个接受相同类型参数的 Add
方法。
下一步需要在您的控件类中添加一个带有几个属性的属性。
public class MyControl: Control
{
protected MyCollection theCollection;
[
Category(“Data”),
Description(“The collection of items for this control.”),
DesignerSerializationVisibility
(DesignerSerializationVisibility.Content),
Editor(typeof(CollectionEditor), typeof(UITypeEditor))
]
public MyCollection TheCollection
{
get { return theCollection; } // only getter, no setter
}
}
前两个属性是可选的,仅指定属性在属性编辑器中的位置及其作用。接下来的两个更重要。 DesignerSerializationVisibility
属性指示设计编辑器将集合的内容序列化到源代码中。这将放置所有代码,以将项添加到您的集合项类型(在此例中为 MyCollectionItem
)的集合变量中。
最后一个属性 Editor()
指定设计编辑器应显示哪种类型的编辑器。该属性接受两个参数:System.Type
,指定编辑器类型,及其父类型。在示例中,我们指定了 CollectionEditor
和 UITypeEditor
(所有类型编辑器都应继承自它)。
此时,集成 CollectionEditor
可能会变得稍微复杂一些,但绝不困难。通过上面的示例,您会注意到没有向源代码添加任何代码。这是因为我们的集合项类 MyCollectionItem
不继承自任何东西。 CollectionEditor
只能在内部知道如何序列化继承自 Component
的类。通常,仅仅继承 Component
就可以让 CollectionEditor
正确地序列化您的代码。
有时,您可能需要为您的类实现一个 TypeConverter
。虽然对某些人来说可能令人生畏,但实现 TypeConverter
在大多数情况下都是一件轻而易举的事情。这个简单的类将为我们的 MyCollectionItem
类实现一个 TypeConverter
:
public class MyCollectionItemConverter: TypeConverter
{
public override bool CanConvertTo
(ITypeDescriptorContext context, Type destinationType)
{
if (destinationType == typeof(InstanceDescriptor))
{
return true;
}
return base.CanConvertTo(context, destinationType);
}
public override object ConvertTo(ITypeDescriptorContext context,
CultureInfo culture, object value, Type destinationType)
{
if (destinationType == typeof(InstanceDescriptor)
&& value is MyCollectionItem)
{
MyCollectionItem item = (MyCollectionItem)value;
ConstructorInfo ci = typeof(MyCollectionItem).
GetConstructor(new Type[] {});
if (ci != null)
{
return new InstanceDescriptor(ci, null, false);
}
}
return base.ConvertTo(context, culture, value, destinationType);
}
}
所有类型转换器都必须继承 TypeConverter
类,并且都必须重写 CanConvertTo
和 ConvertTo
方法。您还应该调用基方法,以确保转换器可以转换为除您的集合项之外的其他类型。
第二个函数完成了大部分工作。它返回一个 InstanceDescriptor
,这是一个 .NET 类,提供了创建对象实例所需的所有信息。在我们的类型转换器中,我们提供了关于我们的项类的构造函数的信息,并指定构造函数不描述整个对象。指定此 InstanceDescriptor
仅描述构造函数,将确保设置项类属性的代码被序列化到源中。
为什么我们需要提供 TypeConverter
? CollectionEditor
会尝试以属性的形式序列化有关您的类的尽可能多的信息。如果您的集合是控件的一部分,CollectionEditor
将需要知道您的集合项的构造函数是什么。一旦有了这些信息,CollectionEditor
就可以在您的源代码中添加 MyControl.TheCollection.Add()
行,将每个项添加到控件中包含的集合中。如果不知道构造函数,CollectionEditor
只能创建一个集合项实例并添加代码来设置其属性。
添加 CollectionEditor
的最后一步是向您的集合项类添加两个属性。第一个属性将防止您的类的实例充斥设计界面。第二个属性将您的新 TypeConverter
与您的项类关联起来。
[DesignTimeVisible(false), TypeConverter(“MyCollectionItemConverter”)]
public class MyCollectionItem
{
// required for type converter
public MyCollectionItem() { }
#region Properties
// properties here
#endregion
#region Methods
// public methods here
#endregion
}
一旦将 TypeConverter
应用于您的集合项类,您应该就可以开始了。与设计编辑器进行简单测试将让您知道代码是否被正确序列化。在大多数情况下,这应该足够了。在极端情况下,您需要实现 ISerializable
并为您的类编写自己的序列化代码。这超出了本文的范围。
最后的寄语
虽然上述信息在某些情况下可能无法帮助您成功地将 CollectionEditor
集成到您的控件中,但我希望它能帮助大多数人。Visual Studio .NET 是一个非常强大的开发环境,它提供了非常强大的方法来实现您自己的与设计界面兼容的控件。许多硬核程序员(包括我自己)更喜欢手动编写 UI。另一方面,拥有一个设计编辑器可以在紧急情况下为您省时,并且拥有能够正常工作的控件很重要。我希望本文对您有所帮助,并希望它能鼓励开发更多具有设计意识的控件。
本文开头介绍的两个控件都实现了 CollectionEditor
。这允许您在设计编辑器中编辑项和节点(如果需要)。CollectionEditor
甚至可以嵌套使用,因此在编辑一个项时,您可以打开另一个编辑器来编辑子项。在将控件添加到 ListView
子项时,您必须首先将控件添加到设计界面。然后,您将能够从子控件属性列表中的列表中选择它。一旦控件被添加到列表中,您仍然可以通过单击它来编辑其属性。这是一个意外的副作用,但它非常受欢迎。我建议在将控件添加到 ListView
子项时要小心。并非所有控件都能正确渲染,有些控件在添加时可能会出现意外问题。到目前为止测试过的控件包括 ProgressBar
、TextBox
、PictureBox
和 ComboBox
。
最后,我想请您在我读完本文并使用这些控件后做两件事。首先,请使用评论上方的条形图对本文进行评分。我想把它从“未编辑”部分移到一个更合适的部分,因为它现在更稳定了,而人们评分可以促成这一点。
其次,如果您在商业应用程序中使用这些控件,一些经济支持将非常有帮助。我目前没有计划出售这些控件,但科技行业很难找到工作,赚钱对我来说并不容易。我不需要您付费购买这些控件,但如果您能做到,您将极大地帮助我。如果您愿意提供帮助,可以通过电子邮件 jrista@hotmail.com 与我联系。
感谢您的阅读,希望这些控件对您有用。 :)
控件特性
ContainerListView
- Windows XP 视觉样式集成
CollectionEditor
用于列标题和行项- 选定的列突出显示
- 行和列跟踪(背景突出显示)
- 多选
- 行子项可以包含控件
- 行子项可以包含图像
ImageList
支持(小型和状态图像)- 列标题可以有图标
- 每个项都可以有自定义的前景色和背景色
- 鼠标滚轮滚动
- 快速插入,每秒可插入数十万个项
TreeListView
- 继承了
ContainerListView
的所有功能 - 第一列表现得像一个
TreeView
- 可切换的根和子线条
- 可以选择双击激活或展开/折叠项
- 快速插入,每秒可插入数万个项
更改日志
- 2002 年 12 月 1 日
- 子控件剪裁的已知 bug 尚未修复
- 2002 年 12 月 2 日
TreeListView
中滚动 bug 已修复。折叠或展开节点时,滚动条会正确调整。- 发现子控件渲染 bug。折叠节点时,子控件仍会渲染。
- 修复了导致列表内容部分滚动但没有滚动条的滚动 bug。
- 2002 年 12 月 6 日
- 添加了键盘导航。 .NET 的一个 bug(或特性)是,当按下箭头键且容器中有其他控件时,焦点会从我的控件移开。
- 修复了
TreeListView
的SelectedNodes
集合。 - 修复了滚动条放置 bug。
- 子控件剪裁仍未修复。
- 2002 年 12 月 9 日
- 修复了突出显示的行文本颜色
- 修复了
KeyDown
事件 bug - 添加了几个 1 毫秒的
Thread.Sleep()
以防止过多的 CPU 使用
- 2003 年 1 月 8 日
- 为
ContainerListView
添加了EnsureVisible
- 修复了导致焦点矩形遗留的箭头导航 bug
- 修复了
TreeListView
中当树深入超过两个级别时阻止正确导航的箭头导航 bug - 修复了
TreeListView
中的滚动条更新 bug - 更新了渲染代码以提高性能。渲染前会进行检查,以确定哪些项实际上落在视口内,并且只渲染那些项,其他所有项都会被跳过。
- 添加了检查以防止渲染不在视口内的列。提供了可观的性能提升。
- 修复了文本渲染 bug。文本现在会在其列的末尾正确截断,并在截断的字符串中添加省略号。适用于项和列标题。
- 修复了两个控件以及自定义项颜色的颜色。属性编辑器中选择的颜色现在用于渲染控件。
- 为
如果您发现任何其他 bug,请随时与我联系。这些控件最终将在商业应用程序中使用,我希望它们是高质量的控件。
箭头键导航 bug
应要求,我为这些控件添加了箭头键导航。当控件单独位于容器中时(例如,Form
、标签页、Panel
等),箭头键会按预期工作。当控件与一个或多个其他控件共享容器时,.NET 似乎会抢占我的控件的键盘控制,从它们那里移除焦点,并将其转移到容器中的下一个控件。一个简单的测试将表明箭头键会在控件之间移动焦点,直到找到一个可以“保留”焦点的控件(其中一些是 TextBox
、ComboBox
、ListBox
)。一旦具有保留焦点的控件获得焦点,使用箭头键将导航该控件。
我一直无法弄清楚为什么 .NET 在按下箭头键时会从我的控件中移除焦点。我的观察表明,当按下上、下、左或右键时,我的控件中的 OnKeyDown
事件从未被触发。所有其他按键都正确传递。出于某种原因,.NET 有一个低级过滤器,用于检查箭头键,并首先尝试导航控件,然后才允许自定义控件处理事件。如果有人知道如何通知 .NET 框架控件希望使用这些按键事件,以及如何防止 .NET 在按下箭头键时从自定义控件中移除焦点,我将非常感谢了解。感谢任何帮助。
Bug 已修复!!
非常感谢 Lion Shi,他回复了我对 msnews.microsoft.com 新闻组的帖子。箭头键的 KeyDown
事件由系统处理,以便能够在窗体上的控件之间移动。特别是单选按钮和复选框。如果控件需要访问这些事件,它需要重写 PreProcessMessage(ref Message)
方法并捕获适当的消息。为了解决这两个控件的问题,我添加了以下内容:
protected const int WM_KEYDOWN = 0x0100;
// windows key codes, not used any more
protected const int VK_LEFT = 0x0025;
protected const int VK_UP = 0x0026;
protected const int VK_RIGHT = 0x0027;
protected const int VK_DOWN = 0x0028;
public override bool PreProcessMessage(ref Message msg)
{
if (msg.Msg == WM_KEYDOWN)
{
if (focusedItem != null && items.Count > 0)
{
// Convert key code to a .NET Keys structure
Keys keyData = ((Keys) (int) msg.WParam) | ModifierKeys;
Keys keyCode = ((Keys) (int) msg.WParam);
// handle message
if (keyData == Keys.Down)
{
Debug.WriteLine("Down");
if (focusedIndex < items.Count-1)
{
focusedItem.Focused = false;
focusedItem.Selected = false;
focusedIndex++;
items[focusedIndex].Focused = true;
items[focusedIndex].Selected = true;
focusedItem = items[focusedIndex];
Invalidate(this.ClientRectangle);
}
return true;
}
else if (keyData == Keys.Up)
{
Debug.WriteLine("Up");
if (focusedIndex > 0)
{
focusedItem.Focused = false;
focusedItem.Selected = false;
focusedIndex--;
items[focusedIndex].Focused = true;
items[focusedIndex].Selected = true;
focusedItem = items[focusedIndex];
Invalidate(this.ClientRectangle);
}
return true;
}
else if (keyData == Keys.Left)
{
Debug.WriteLine("Left");
//return false;
}
else if (keyData == Keys.Right)
{
Debug.WriteLine("Right");
//return false;
}
}
}
return base.PreProcessMessage(ref msg);
}
PreProcessMessage
返回一个 bool
值,如果消息已处理,则为 true
,否则为 false
。如果函数为 KeyDown
消息返回 true
,那么上述问题(焦点从控件移除并移至容器中的下一个控件)将得到解决。可以在 PreProcessMessage
函数中处理任何消息,因此对于似乎在捕获事件时遇到问题的任何自定义控件,都可以尝试重写 PreProcessMessage
。
一种替代方法是按如下方式捕获 WM_GETDLGCODE
消息。这允许您在控件的正常 OnKeyDown
/OnKeyUp
方法中使用箭头键。
protected override void WndProc(ref System.Windows.Forms.Message m)
{
base.WndProc( ref m );
if( m.Msg == (int)Msg.WM_GETDLGCODE )
{
m.Result = new IntPtr( (int)DialogCodes.DLGC_WANTCHARS |
(int)DialogCodes.DLGC_WANTARROWS |
(int)DialogCodes.DLGC_WANTTAB |
m.Result.ToInt32() );
}
}
关于 Jon Rista
Jon Rista 是一名计算机科学专业的学生,目前正在 ACCIS 在线完成学业。从 8 岁起,他就对计算机和计算机编程着迷。现年 23 岁,他有一个雄心勃勃的目标:创办自己的软件公司,致力于开发优质工具,帮助人们构建互联网社区并连接人们。两个项目包括一个功能丰富且可扩展的公告板系统,以及一个功能丰富、优质、模块化、无广告的点对点网络系统的创建。