G
N
I
D
A
O
L

具有分组功能的 ComboBox 控件

发表于2010 年 6 月 24 日 

Windows 窗体ComboBox控件是一个出色的用户界面组件,适用于您希望向用户呈现一组离散的选项或邀请他们输入任意字符串值的情况。

它不会对可用性构成太大挑战;本质上,该控件直观且易于使用。但是,有时可以通过将用户的选项分组或分类来大大提高应用程序的可用性。

This is particularly useful when the number of options is large, or when there is a risk of ambiguity in the wording of the options

– often GUI designers are locked in a constant battle between brevity and elegance over the risk of ambiguity. 通过引入组/类别,

可以在添加另一层描述的同时保持简洁。

不幸的是,标准的ComboBox控件没有提供组或类别;它被设计为一个平面列表,列表项之间没有隐含的凝聚力,也没有层次结构。

尽管如此,许多应用程序(包括Mozilla Firefox)已经确定了对具有分组机制的下拉控件的需求,现在在应用程序领域中出现了许多示例。

我编写了 GroupedComboBox试图提供一种解决方案,该解决方案与整个 Windows 窗体中使用的可视化设计和数据绑定的现有原则保持一致。

它是ComboBox的直接替代品,从它继承以减少功能重复。与标准ComboBox中的许多扩展功能一样,控件必须绑定到数据源才能利用分组。

为了使用分组机制,只需对现有代码进行很少的更改:

  • 将新GroupMember属性的值设置为您希望对列表项进行分组的属性的名称。(此功能与DisplayMemberValueMember属性的功能相同,因此您可以使用属性、列名等)
  • 绑定到合适的数据源(ArrayIListIListSourceITypedListDataView等)。(注意:数组必须是强类型的;object[]数据源不公开可绑定的属性。)
  • 由于实现,列表中的项目将被固有地排序。词典排序被认为是最佳实践用户界面设计的一个方面。如果您有不同的排序要求,请考虑使用更合适的控件。

比较显示绑定到同一数据源的 GroupedComboBox 和 ComboBox。

注意:Windows Vista/7 上的视觉样式渲染器在DropDownStyle属性设置为ComboBoxStyle .DropDownList并且DrawMode属性设置为DrawMode .Normal时使用凸起的按钮效果,

但是当所有者绘图为启用。

在这种情况下没有简单的方法来强制系统样式,因此如果两种类型的控件出现在同一个Form上, GroupedComboBox可能会在视觉上与System.Windows.Forms.ComboBox发生冲突。

Windows 窗体数据绑定的一些背景知识

如果您使用 Windows 窗体控件中的数据绑定功能,您可能想知道如何使用DisplayMember / ValueMember / DataSource机制从数据源中提取值。

数据源中的项目不属于任何一种类型;根据来源,它们可以是原始数据类型、具有公共属性的对象、System.Data.DataRowView的实例,甚至是不同类型项目的异构集合。

事实上,对数据源的唯一限制是它们实现了IListIListSource

那么,给定一个不可预知类型的对象,我们如何获得它的某个属性的值呢?好吧,首先我们需要了解数据绑定上下文中“属性”的定义;

它不一定指的是 get/set 风格的属性,而是一种抽象。这使我们能够绑定到DataView中的列,或者可能存在或不存在于异构集合中的属性。

System.ComponentModel.PropertyDescriptor类是我们用来访问这些抽象属性值的机制

PropertyDescriptor是一个抽象类。属性描述符通常从数据源获取,而不是从列表项本身获取。不同的数据源有不同的实现;

例如,System.ComponentModel.ReflectPropertyDescriptor使用反射来抓取集合的元素类型以获取其公共属性,

DataView使用System.Data.DataColumnPropertyDescriptor来表示其列。

您可以通过多种方式获取属性描述符——实现ITypedList的源直接公开它们,而数组和列表需要ListBindingHelper来检索它们。

值得庆幸的是,BindingSource组件通过其GetItemProperties()方法将它们全部舍入。

在将所需属性的名称作为字符串的情况下(例如此示例),您只需简单地枚举属性描述符列表,直到找到具有匹配名称值的描述符。

 1 // assume 'dataSource' is a data source that implements IList or IListSource and
 2 // 'displayMember' is a string representing the property we want
 3 BindingSource bindingSource = new BindingSource(dataSource, String.Empty);
 4 PropertyDescriptor displayProperty = null;
 5 foreach (PropertyDescriptor descriptor in bindingSource.GetItemProperties(null)) {
 6     if (descriptor.Name.Equals(displayMember)) {
 7         displayProperty = descriptor;
 8         break;
 9     }
10 }

一旦有了PropertyDescriptor的实例,就可以为数据源中的任何项目调用其GetValue()方法:

// assume 'listItem' is an object obtained from the data source
object displayValue = (displayProperty != null) ? displayProperty.GetValue(listItem) : null;

GroupedComboBox 如何实现 GroupMember

考虑到上述情况,扩展此功能以获取我们数据源的分组值并不难。

我的实现隐藏了ComboBox控件的DataSource属性,以透明地确保使用BindingSource

确定BindingSource组件可用后,我们可以通过将GroupMember的值与绑定源的PropertyDescriptor对象之一Name属性匹配来查找每个列表项的分组值。

当然,数据源总是有可能没有匹配的属性——如果发生这种情况,我们将假设给定的列表项未分组。

public new object DataSource {
    get {
        return (mBindingSource != null) ? mBindingSource.DataSource : null;
    }
    set {
        mBindingSource = (value != null) ? new BindingSource(value, String.Empty) : null;
    }
}

将列表项分组

为了分组显示,列表项必须进行特殊排序——首先按组(如果列表项已分组),然后按显示值。

我们不能假设数据源是以这种方式预先排序的,或者它甚至是可排序的,因此我们将使用内部排序集合来强制列表项的顺序。

对于包含引用类型的数据源,这样做只会产生很小的开销(尽管对于包含值类型的数据源,我们实际上是在复制列表)。

底层ComboBox绑定到的正是这个排序的集合。

由于我们正在创建原始数据源的排序视图,因此一次位于 GroupedComboBox 上以与其保持同步。

这是使用BindingSource的另一个令人信服的理由,因为它为兼容的数据源(例如DataView)提供了ListChanged事件。

每当更改数据源或GroupMember属性时,都会重新构建已排序的集合。

int IComparer.Compare(object x, object y) {
    int secondLevelSort = Comparer.Default.Compare(GetItemText(x), GetItemText(y));
    if (mGroupProperty == null) return secondLevelSort;

    int firstLevelSort = Comparer.Default.Compare(
        Convert.ToString(mGroupProperty.GetValue(x)),
        Convert.ToString(mGroupProperty.GetValue(y))
    );

    return (firstLevelSort == 0) ? secondLevelSort : firstLevelSort;
}

 

两层排序的执行方式如下(其中' mGroupProperty '是前面获得的PropertyDescriptor对象):

int IComparer.Compare(object x, object y) {
    int secondLevelSort = Comparer.Default.Compare(GetItemText(x), GetItemText(y));
    if (mGroupProperty == null) return secondLevelSort;

    int firstLevelSort = Comparer.Default.Compare(
        Convert.ToString(mGroupProperty.GetValue(x)),
        Convert.ToString(mGroupProperty.GetValue(y))
    );

    return (firstLevelSort == 0) ? secondLevelSort : firstLevelSort;
}

 

在这种情况下,内部使用的最明智的集合是ArrayList,因为我们必须选择一个IList,我们对元素知之甚少,我们想对它们进行排序。

绘制列表项

要以分组形式显示列表项,我们必须手动绘制它们。这不是一项非常艰巨的任务,

但必须小心以避免容易出现异常的代码并保持 Windows 窗体控件的可视化原则。

我们将使用OwnerDrawVariable模式来绘制项目,因为它允许我们将一些项目绘制得比其他项目大;

实际上,每个组中的第一个项目将是两倍高,以容纳组标题。

鉴于它在Items集合中的索引,列表项在以下情况下需要标题:

  • 它位于位置 0 并且具有非空分组值,或者:
  • 它与 [index – 1] 处的项目具有不同的分组依据值。

ComboBox下拉部分中项目的高度是行数乘以Font的Height属性

我们通过处理MeasureItem事件来计算每个列表项的大小。

然后在DrawItem事件的处理程序中绘制该项目。

TextRenderer类在渲染每个项目非常有用。

然而,绘制一个列表项不仅仅是绘制文本。DrawItemState枚举的一些标志(可通过DrawItemEventArgs.State访问)必须影响绘图代码:

  • Selected – 鼠标悬停在项目上。一般情况下,背景会用SystemBrushes 绘制。突出显示,文本将以SystemColors 绘制。高亮文本
  • 焦点- 项目具有焦点,通常与被选中一起发生。除非设置了NoAccelerator,否则需要在其周围绘制焦点矩形对于 Windows 的新安装,默认情况下禁用加速器。
  • ComboBoxEdit – 当ComboBoxDropDownStyle设置为DropDownList时,此标志的存在意味着该项目正在控件的主要部分中绘制。此处适用不同的规则:
    • 当控件处于焦点且未显示下拉列表时,该项目应显示为好像已突出显示。
    • 当控件不在焦点上,或者如果在焦点上但下拉时,该项目应该看起来好像它是正常的(未装饰)。
    • 在分组组合框的情况下,我们不会缩进项目或在此状态下为组绘制标题。
  • Disabled – 这与ComboBoxEdit一起发现,表示控件的Enabled属性设置为 false,因此应使用SystemColors 呈现文本。灰色文本

在这种情况下,我们有一些额外的风格要求,例如缩进所有具有非空 group-by 值的项目,

并给出组标题不可选择的印象(通过抑制它们的突出显示和焦点矩形)。

我们还希望以粗体呈现标题,以进一步区分它们与项目文本。

示例用法

以下示例演示了如何使用根本不同类型的数据源来产生相同的输出:

匿名对象的 ArrayList  (ArrayList of Anonymous Objects)

GroupedComboBox groupedCombo = new GroupedComboBox();

groupedCombo.ValueMember = "Value";
groupedCombo.DisplayMember = "Display";
groupedCombo.GroupMember = "Group";

groupedCombo.DataSource = new ArrayList(new object[] {
    new { Value=100, Display="Apples", Group="Fruit" },
    new { Value=101, Display="Pears", Group="Fruit" },
    new { Value=102, Display="Carrots", Group="Vegetables" },
    new { Value=103, Display="Beef", Group="Meat" },
    new { Value=104, Display="Cucumbers", Group="Vegetables" },
    new { Value=0, Display="(other)", Group=String.Empty },
    new { Value=105, Display="Chillies", Group="Vegetables" },
    new { Value=106, Display="Strawberries", Group="Fruit" }
});

数据视图(DataView)

DataTable dt = new DataTable();
dt.Columns.Add("Value", typeof(int));
dt.Columns.Add("Display");
dt.Columns.Add("Group");

dt.Rows.Add(100, "Apples", "Fruit");
dt.Rows.Add(101, "Pears", "Fruit");
dt.Rows.Add(102, "Carrots", "Vegetables");
dt.Rows.Add(103, "Beef", "Meat");
dt.Rows.Add(104, "Cucumbers", "Vegetables");
dt.Rows.Add(DBNull.Value, "(other)", DBNull.Value);
dt.Rows.Add(105, "Chillies", "Vegetables");
dt.Rows.Add(106, "Strawberries", "Fruit");

groupedCombo.DataSource = dt.DefaultView;

 

字符串数组 String Array (按字长分组 grouped by word length)

groupedCombo.ValueMember = null;
groupedCombo.DisplayMember = null;
groupedCombo.GroupMember = "Length";

string[] strings = new string[] { "Word", "Ace", "Book", "Dice", "Taste", "Two" };

groupedCombo.DataSource = strings;

 最后的话

此自定义控件是增强 GUI 可用性和正确使用 Windows 窗体数据绑定基础结构和视觉样式指南的示例。

通过正确了解这些功能如何在后台运行,我能够创建一个组件,该组件尽可能多地回收现有功能,

在代码中简单易用,并避免违反通常导致复杂的约定,笨拙的解决方案。

当然,它并没有给ComboBox控件提供一个新的、多层次的层次结构,而只是一种分类机制——但是,

在这种能力下,我希望你觉得它有用!

 ------------------------------------------------------------------------------

https://www.brad-smith.info/blog/archives/104

 

posted @ 2022-09-19 20:39  firespeed  阅读(120)  评论(0编辑  收藏  举报