65.9K
CodeProject 正在变化。 阅读更多。
Home

创建具有丰富设计时支持的基于集合的控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (44投票s)

2003年11月23日

15分钟阅读

viewsIcon

107039

downloadIcon

2412

一篇关于编写高级基于集合的 Windows 窗体控件以及如何为其提供最佳设计时支持的文章。包含一个示例工具栏控件的完整 C# 源代码。

什么是集合控件?

实际上,这是我刚创造的一个术语。当我这么说的时候,我指的是那些以列表形式呈现自身的用户界面控件。更明显的例子是列表框、列表视图和树形视图。不那么明显的是工具栏等。它们都维护一个用于显示对象的集合。

在某些情况下,集合中的这些对象本身也包含子项的集合。列表视图就是一个例子,当控件处于报表视图模式时,每个项都可以有子项。

当你将集合引入你的控件时,你的工作突然变得更加困难。与开发简单的属性控件(如按钮)相比,你最终需要编写至少另外三个类。

集合控件的要求

在编写这些控件之一时,当你仍在编写对象模型时,通常会花一些额外的时间在代码上。你必须在主控件上定义用于访问集合的属性。你必须编写将代表每个单独项的类(例如,ListViewItem 类)。你必须编写将作为子项集合的类。这仅仅是为了让它能正常工作。

要添加设计时支持,你必须编写一个类来充当子项的类型转换器。当用户在设计时填充了你的控件后,代码序列化器会遍历你集合中的每个对象,并使用此转换器类来检查它,并以最佳方式重新创建它(即使用哪个构造函数)。

尽管这种类型的控件具有子项,但它们通常不负责绘制自身。它们实际上没有自己的窗口,而是由父控件负责计算它们的位置并绘制它们。

丰富的 d 设计时支持

这是我发明的另一个术语。我用它来指代那些额外付出一点点工作,以使你的控件在设计时非常易于使用。我发布了一些我自己的控件,它们都不使用集合编辑器(这是设计时修改集合的标准方法)。相反,它们使用一套设计器动词和选择来直观地进行更改。

要添加丰富的 d 设计时支持,你很可能需要为主控件编写一个设计器,并为子项编写一个设计器。你将在这些设计器以及你控件中代码的扩展中添加必要的代码。

其中一个要求是,你的子项必须是可选择的,并且可以使用普通的属性网格控件进行修改。要实现这一点,每个子项都必须存在于设计图面上。这意味着它必须实现 IComponent 接口,最简单的方法是派生自 Component。

丰富 d 设计时支持的优点是用户可以通过单击来选择每个子项。这不是一项微不足道的工作,就设计器而言(默认情况下),你只是在单击主控件的一部分。由我们设计时代码利用宿主环境提供的接口来选择用户单击的子项并将其绘制出来。

我们还需要监听宿主环境的选择更改事件,以便在用户选择不同控件时,我们能收到通知并进行重绘。

设计对象模型

在本文中,我们将创建一个布局类似于工具栏的控件。所有“按钮”都将有一个 Colour 属性,这是控制其外观的唯一方式。按钮在设计时是可选择和可修改的。

主控件将只有一个自定义属性,我们将其命名为“Buttons”。我们将使用 BrowsableAttribute 类在设计时隐藏此属性,因为我们希望使用自己的逻辑来添加和删除它们,而不是使用集合编辑器。

我们的子项,我们称之为 ColourButtons,将只有一个属性——Colour。当选择一个按钮时,它周围会绘制一个粗边框。我知道我们正在开发一个相当无用的控件,但是你可以使用完全相同的方法来开发任何高级控件,例如工具栏或某种列表。

对于像这样的控件,最重要的事情之一就是将布局逻辑与绘制逻辑分开。在内部,控件需要维护一个矩形列表,每个按钮一个。我们将实现一个 CalculateLayout 函数,该函数循环遍历集合并生成矩形。每当向集合添加或删除按钮,或者主控件调整大小时,都会调用此函数。

如果所有矩形都像这样预先计算好,那么绘制代码就会容易得多。你永远不应该在绘制代码中计算位置,因为它根本没有必要。绘制比计算位置的需求要大得多。

开始

我不会把所有的代码都放进这篇文章,因为那样会显得很杂乱。相反,我会粘贴重要的部分,并尝试描述其余部分。我将在编写过程中同时开发 VB 和 C# 控件,并将最终的解决方案提供下载。

首先,我们将新的用户控件添加到我们的项目中。由于我们不希望绘图闪烁,我们在构造函数中使用受保护的 SetStyle 函数来启用 DoubleBufferAllPaintingInWmPaint 样式。这两者是相辅相成的。我们还定义了 CalculateLayout 函数,我们将在集合中调用它,并在控件调整大小时调用它。

接下来是定义子项类,以及我们将用于包含按钮的强类型集合类。此时,我们将 Buttons 属性添加到主控件,该属性公开了此集合的私有实例,该实例在主控件的构造函数中实例化。ColourButton 有一个内部的 Bounds 成员,类型为 Rectangle,它将保存按钮在控件中的位置。

为了简单起见,我们的集合将只实现 AddRemove 函数以及索引器。通常你会向其中添加一些更强的类型化帮助函数,例如 IndexOf。集合的构造函数是内部的,并接受主控件的一个实例作为参数。这样,当添加按钮时,就可以将此实例传递给按钮,因为当用户更改按钮的颜色时,它需要发出信号表明需要重绘。以下是 ColourButtonColourButtonCollection 类的代码。

public class ColourButton : Component
{
    private Color _Colour = Color.White;

    internal CollectionControl Control = null;
    internal Rectangle Bounds;

    public Color Colour
    {
        get
        {
            return _Colour;
        }
        set
        {
            _Colour = value;
            if (Control != null)
                Control.Invalidate();
        }
    }
}

public class ColourButtonCollection : CollectionBase
{
    private CollectionControl Control;

    internal ColourButtonCollection(CollectionControl Control)
    {
        this.Control = Control;
    }

    public ColourButton this[int Index]
    {
        get
        {
            return (ColourButton) List[Index];
        }
    }

    public bool Contains(ColourButton Button)
    {
        return List.Contains(Button);
    }

    public int Add(ColourButton Button)
    {
        int i;

        i = List.Add(Button);
        Button.Control = Control;
        Control.CalculateLayout();

        return i;
    }

    public void Remove(ColourButton Button)
    {
        List.Remove(Button);
        Button.Control = null;
        Control.CalculateLayout();
    }
}

绘制和布局逻辑

我们已经创建了 CalculateLayout 函数(尽管此时它是空的),并在添加或删除按钮时调用它。我们还需要重写 OnResize 并在那里调用它。对于这个示例控件,我们将从左到右将按钮显示在一行水平线上。我们将在两侧留一些填充,然后按钮将占用垂直方向上的剩余空间,并将自身的高度和宽度设为相等。

CalculateLayout 函数还会使控件无效。虽然你经常在不计算位置的情况下重绘,但你永远不会在不重绘的情况下计算位置。

internal void CalculateLayout()
{
    const int PADDING = 3;

    int buttonSize, x, i; // x is the current horizontal position
    ColourButton button;
    Rectangle wrct;

    x = PADDING;
    buttonSize = ClientRectangle.Height - (2 * PADDING);
    for (i = 0; i < _buttons.Count; i++)
    {
        button = _buttons[i];

        // Create bounds rectangle for button and increment x
        wrct = new Rectangle(x, PADDING, buttonSize, buttonSize);
        button.Bounds = wrct;
        x += buttonSize + PADDING;
    }

    // Mark the control as invalid so it gets redrawn
    Invalidate();
}

接下来是绘制代码,对于这个例子来说,它非常简单。我们重写 OnPaint 方法来绘制按钮,简单地用从其定义的颜色创建的画笔填充它们的矩形。

请注意,还有另一个方法 OnPaintBackground,我们不触碰它。如果我们对控件的背景做了什么特殊的事情,比如不同的颜色,我们就会这样做。现在,如果我们不处理它,我们就不必担心绘制背景。事实上,由于我们继承自 UserControl,我们的控件已经具有 BackColor 属性,甚至可以拥有背景图像。

protected override void OnPaint(System.Windows.Forms.PaintEventArgs e)
{
    Brush b = null;
    Rectangle wrct;

    foreach(ColourButton button in _buttons)
    {
        // Create brush from button colour
        if (b != null)
            b.Dispose();
        b = new SolidBrush(button.Colour);

        // Fill rectangle with this colour
        wrct = button.Bounds;
        if (highlightedButton == button)
        {
            e.Graphics.FillRectangle(SystemBrushes.Highlight, wrct);
            wrct.Inflate(-3, -3);
        }
        e.Graphics.FillRectangle(b, wrct);
    }
}

请注意,我引入了一个作用域为控件的变量,用于存储对应该绘制高亮显示(如果有)的按钮的引用。当我们在设计时处理用户选择按钮时,这将非常重要。此时,控件实际上可以工作。由于我还没有隐藏 Buttons 属性,所以在将控件添加到窗体后,我可以进入集合编辑器并添加按钮。按钮都显示为白色方块,但我们已经走得很远了。

控制序列化

在我们将对 Buttons 集合所做的任何更改序列化为代码之前,我们需要添加一个 TypeConverter 类并将其与 ColourButton 关联。TypeConverter 帮助序列化器知道如何重新创建已实例化的对象。我将在本例中使用一个非常简单的 TypeConverter,它只是告诉序列化器使用默认的无参数构造函数。

internal class ColourButtonConverter : TypeConverter
{
    public override bool CanConvertTo(ITypeDescriptorContext context, 
        Type destType)
    {
        if (destType == typeof(InstanceDescriptor))
            return true;

        return base.CanConvertTo(context, destType);
    }

    public override object ConvertTo(ITypeDescriptorContext context,
        System.Globalization.CultureInfo culture, object value, 
        Type destType)
    {
        if (destType == typeof(InstanceDescriptor))
        {
            System.Reflection.ConstructorInfo ci =
                typeof(ColourButton).GetConstructor(
                System.Type.EmptyTypes);

            return new InstanceDescriptor(ci, null, false);
        }

        return base.ConvertTo(context, culture, value, destType);
    }
}

我们还需要告诉序列化器,在它们能够到达那里之前,它们必须进入我们的 Buttons 属性,我们通过 DesignerSerializationVisibilityAttribute 类来做到这一点。除了它的名称如此长之外,这个属性所做的就是通知序列化器如何处理我们的属性。我们希望它们深入集合,所以我们指定 Content。

当我们在设计时向控件添加按钮,保存,关闭设计器并重新打开它时,按钮又会出现。关于序列化,我们只需要做这些,就迈出了很大一步。

添加设计器

在添加设计器之前,我们将清理一些东西。首先,我们将一个 BrowsableAttribute 应用于 Buttons 属性,指定 False,因此该属性不会出现在属性网格中。其次,你可能已经注意到,在测试控件时,向集合添加按钮会导致按钮出现在窗体的组件托盘区域。这很正常,因为我们正在使用组件,但在这种情况下,我们希望隐藏它们。我们使用 DesignTimeVisibleAttribute 类来实现这一点,同样指定 False。最后,这是一个非常小的细节,我们使用 ToolboxItemAttribute 类来阻止我们的 ColourButton 类本身出现在工具箱中。

现在,我们可以开始创建我们的设计器了。它将继承自 ControlDesigner。我们需要它来处理对主控件的单击,以便选择单个按钮,并监听设计图面上的事件,以便我们知道用户何时选择了其他内容。我们还需要监听用户删除某个按钮组件时触发的事件。最后,我们需要重写 AssociatedComponents 属性,并简单地将其传递 Buttons 集合,以便它知道它们与控件一起使用。当用户将控件复制到剪贴板并粘贴到其他地方时,它会利用此信息。

这是一个告诉你 GetService 函数的好时机。VS.NET IDE 托管着大量的服务,这些服务都绑定到一系列层次化的资源。它们一直向上到项目级别,向下到特定源文件的视图(设计视图和代码视图)。我们感兴趣的服务是 ISelectionServiceIComponentChangeService。每个源文件的设计视图都有这些服务,我们可以通过 ComponentDesigner 类的受保护的 GetService 方法访问它们,而 ControlDesigner 继承自它。

设计器有一个 Initialize 函数,它在创建后几乎立即被调用。这个函数接受一个参数,该参数包含设计器要为其提供支持的对象。正是在这个函数中,我们将获取 ISelectionServiceIComponentChangeService,并连接我们需要的事件,即 SelectionChangedComponentRemoving。重要的是要记住取消连接事件,我们通过重写 Dispose 方法来实现这一点。

请注意,在编写设计器时,事情可能会出错。对我来说,肯定发生过。由于设计器与宿主环境集成得相当紧密,如果你编写错误的代码或忘记清理,事情可能会变得非常糟糕。这些事情需要重启 IDE 才能修复。这被称为“与其他设计器和谐相处”。切勿在设计器代码中引发异常——调试它们非常困难。

总之,这是我们开始设计器的代码。我还通过使用 DesignerAttribute 类将设计器与主控件关联起来。这个设计器除了连接我们需要的事件并调用主控件中的一个内部函数(此时为空)之外,什么都不做。

internal class CollectionControlDesigner : ControlDesigner
{
    private CollectionControl MyControl;

    public override void Initialize(IComponent component)
    {
        base.Initialize(component);

        // Record instance of control we're designing
        MyControl = (CollectionControl) component;

        // Hook up events
        ISelectionService s = (ISelectionService) GetService(
        typeof(ISelectionService));
        IComponentChangeService c = (IComponentChangeService)
            GetService(typeof(IComponentChangeService));
        s.SelectionChanged += new EventHandler(OnSelectionChanged);
        c.ComponentRemoving += new ComponentEventHandler(
        OnComponentRemoving);
    }

    private void OnSelectionChanged(object sender, System.EventArgs e)
    {
        MyControl.OnSelectionChanged();
    }

    private void OnComponentRemoving(object sender, ComponentEventArgs e)
    {
    }

    protected override void Dispose(bool disposing)
    {
        ISelectionService s = (ISelectionService) GetService(
        typeof(ISelectionService));
        IComponentChangeService c = (IComponentChangeService)
            GetService(typeof(IComponentChangeService));

        // Unhook events
        s.SelectionChanged -= new EventHandler(OnSelectionChanged);
        c.ComponentRemoving -= new ComponentEventHandler(
        OnComponentRemoving);

        base.Dispose(disposing);
    }

    public override System.Collections.ICollection AssociatedComponents
    {
        get
        {
            return MyControl.Buttons;
        }
    }
}

添加按钮

我们希望用户能够做的第一件事就是添加按钮。我们将为此使用一个设计器动词。有关设计器动词的解释,请参阅我的文章“设计器入门”。我们只需要一个动词,并且将其简单地命名为“Add Button”。

在用户激活此动词时执行的代码中,我们必须创建一个按钮并将其添加到集合中。这听起来可能很简单,但这正是我们需要与其他设计器和谐相处的时候。如果我们只是创建了一个按钮并将其添加到集合中,IDE 怎么会知道发生了什么变化?它怎么会知道发生了什么,以便用户可以撤销/重做?

这时就轮到 DesignerTransaction 类了。当你对设计图面上的某个对象执行一个重要操作(或一组操作)时,你应该将其包装在一个事务中。每个事务都有一个友好的名称,它会出现在宿主环境中撤销/重做按钮旁边的下拉菜单中。此外,对对象(在本例中是 Buttons 集合)的每个独立更改都需要用对 IComponentChangeService 上的 OnComponentChangingOnComponentChanged 的调用来包装。

最后,你不应该直接尝试实例化 ColourButton——让设计器宿主(我们将使用的另一个服务)为你完成创建。这确保了对象在设计图面上,并且让每个人都满意。如果 ColourButton 类本身有设计器,它也会被创建。我知道这一切听起来像很多工作,而且确实如此,但你会习惯的,而且大部分都是样板代码,可以轻松地复制/粘贴。

public override System.ComponentModel.Design.DesignerVerbCollection Verbs
{
    get
    {
        DesignerVerbCollection v = new DesignerVerbCollection();

        // Verb to add buttons
        v.Add(new DesignerVerb("&Add Button", new EventHandler(OnAddButton)));

        return v;
    }
}

private void OnAddButton(object sender, System.EventArgs e)
{
    ColourButton button;
    IDesignerHost h = (IDesignerHost) GetService(typeof(IDesignerHost));
    DesignerTransaction dt;
    IComponentChangeService c = (IComponentChangeService)
    GetService(typeof(IComponentChangeService));

    // Add a new button to the collection
    dt = h.CreateTransaction("Add Button");
    button = (ColourButton) h.CreateComponent(typeof(ColourButton));
    c.OnComponentChanging(MyControl, null);
    MyControl.Buttons.Add(button);
    c.OnComponentChanged(MyControl, null, null, null);
    dt.Commit();
}

请注意,即使写了所有这些,我们的实现还不够完整——你可以添加按钮,撤销和重做按钮可以正常地从设计图面删除按钮,但它们不会从 Buttons 集合中删除按钮——我们稍后会回来处理。

选择按钮

设计器提供了一个有用的方法可以覆盖,称为 GetHitTest。它会收到一些坐标,由你的逻辑来决定是否将事件(通常是单击)传递给下面的控件。我们将覆盖此方法,并查看鼠标光标是否在控件上任何按钮的边界内。如果是,我们将返回 true。

protected override bool GetHitTest(System.Drawing.Point point)
{
    Rectangle wrct;

    point = MyControl.PointToClient(point);

    foreach (ColourButton button in MyControl.Buttons)
    {
        wrct = button.Bounds;
        if (wrct.Contains(point))
            return true;
    }

    return false;
}

这样,如果用户单击一个按钮,我们的控件中的 MouseDown 事件就会被触发。在此事件中,我们检查是否处于设计模式(使用 DesignMode 属性),如果是,则找到光标所在的按钮。然后我们获取 ISelectionService 的引用,并将选择设置为该按钮。

protected override void OnMouseDown(System.Windows.Forms.MouseEventArgs e)
{
    Rectangle wrct;
    ISelectionService s;
    ArrayList a;

    if (DesignMode)
    {
        foreach (ColourButton button in Buttons)
        {
            wrct = button.Bounds;
            if (wrct.Contains(e.X, e.Y))
            {
                s = (ISelectionService) GetService(
                typeof(ISelectionService));
                a = new ArrayList();
                a.Add(button);
                s.SetSelectedComponents(a);
                break;
            }
        }
    }

    base.OnMouseDown(e);
}

此时,在设计时单击控件中的单个按钮会选中它,你甚至可以在属性网格中修改其属性。在我们完成选择相关代码之前,还有最后一段代码要写,那就是填充我们之前创建的在选择更改时调用的函数。我们在这里设置我们创建的 highlightedButton 变量,以便通过视觉方式指示选择。

internal void OnSelectionChanged()
{
    ColourButton newHighlightedButton = null;
    ISelectionService s = (ISelectionService) GetService(
        typeof(ISelectionService));

    // See if the primary selection is one of our buttons
    foreach (ColourButton button in Buttons)
    {
        if (s.PrimarySelection == button)
        {
            newHighlightedButton = button;
            break;
        }
    }

    // Apply if necessary
    if (newHighlightedButton != highlightedButton)
    {
        highlightedButton = newHighlightedButton;
        Invalidate();
    }
}

我们快完成了。现在我们可以将控件添加到窗体,使用设计器动词添加按钮,并直观地选择这些按钮,在属性网格中更改它们的属性。

删除按钮

这是另一个关于与其他设计器和谐相处的问题。我们将编写代码放入我们设计器中的 OnComponentRemoving 函数。我们需要在这里处理两件事。首先,用户删除主控件。当发生这种情况时,我们需要销毁设计图面上所有的按钮。其次,当用户通过选择一个按钮并按删除键来删除一个按钮时。当发生这种情况时,我们需要将其从 Button 集合中删除。同样,我们对任何更改都需要包装在 OnComponentChangingOnComponentChanged 调用中。

private void OnComponentRemoving(object sender, ComponentEventArgs e)
{
    IComponentChangeService c = (IComponentChangeService)
    GetService(typeof(IComponentChangeService));
    ColourButton button;
    IDesignerHost h = (IDesignerHost) GetService(typeof(IDesignerHost));
    int i;

    // If the user is removing a button
    if (e.Component is ColourButton)
    {
        button = (ColourButton) e.Component;
        if (MyControl.Buttons.Contains(button))
        {
            c.OnComponentChanging(MyControl, null);
            MyControl.Buttons.Remove(button);
            c.OnComponentChanged(MyControl, null, null, null);
            return;
        }
    }

    // If the user is removing the control itself
    if (e.Component == MyControl)
    {
        for (i = MyControl.Buttons.Count - 1; i >= 0; i--)
        {
            button = MyControl.Buttons[i];
            c.OnComponentChanging(MyControl, null);
            MyControl.Buttons.Remove(button);
            h.DestroyComponent(button);
            c.OnComponentChanged(MyControl, null, null, null);
        }
    }
}

现在我们添加了这段代码,用户可以像删除设计图面上的任何其他控件或组件一样直观地删除按钮。此外,撤销和重做现在在添加按钮时也能正常工作。

结论

我们已经创建了一个具有丰富 d 设计时支持的工具栏控件的基础。与我们编写代码以实现此支持相比,为按钮添加更多属性很容易。我希望你觉得这篇文章有用,它确实展示了你在编写设计时和运行时代码时将使用的许多技术。该工具栏在运行时实际上除了在那里看起来漂亮之外什么都不做,但我们已经具备了添加鼠标悬停支持和 ButtonClick 事件的元素。

在我的网站上,有本文的一个副本,包含 VB.NET 源代码,以及其他文章和免费的 Windows 窗体控件。

历史

  • First version.
© . All rights reserved.