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

创建一个具有设计时支持的多页面容器控件

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2012年8月23日

CPOL

9分钟阅读

viewsIcon

16652

本文介绍了如何为一个类似于 Visual Studio 2012 项目属性页面渲染方式的 TabControl 组件添加设计时支持。

本文介绍了如何为一个类似于 TabControl 的组件添加设计时支持,该组件的渲染方式与 Visual Studio 2012 中的项目属性相同。

这是我第一次尝试更高级地使用组件设计器,因此可能存在我不知道或未正确实现的方面。该组件似乎运行良好,但完全可能存在导致问题的错误。买者自负!

控件概述

在本文中,我不会深入探讨控件本身的实现方式,因为我想专注于设计时支持,所以我只提供一个快速概述。

  • TabList - 主要控件
  • TabListPage - 由 TabList 托管,提供多页支持
  • TabListControlCollection - 一个自定义的 ControlCollection,用于处理 TabListPage,并防止其他控件直接添加到 TabList
  • TabListPageCollection - TabListPage 对象的强类型包装器

这四个类的基本功能都基于 TabControl。如果您知道如何使用 TabControl,那么您就知道如何使用 TabList 控件,一些属性名称已更改,但其他方面非常相似。

为了支持渲染,我们使用这些类:

  • ITabListPageRenderer - 由渲染类实现的接口
  • TabListPageRenderer - 用于渲染支持的基类,并提供一个默认渲染器属性
  • TabListPageState - 描述 TabListPage 状态的标志
  • DefaultTabListPageRenderer - 简单的渲染器,以 Visual Studio 2012 风格绘制标题。

最后,我们有两个设计器,本文将重点介绍它们:

  • TabListDesigner - TabList 控件的设计器类
  • TabListPageDesigner - TabListPage 控件的设计器类

实现 TabListDesigner

由于 TabList 控件是一个容器控件,我们不能使用基类 ControlDesigner。相反,我们将使用 ParentControlDesigner,它具有我们需要的许多额外功能。

初始化新控件

通常,我通过控件的构造函数初始化组件。当您将属性初始化为默认值时,这很好,但如何添加子项呢?例如,考虑 TabControl。当您将其中一个添加到窗体时,它会生成两个托管页面。如果您删除它们,它们不会回来。如果您查看过控件的设计器生成代码,您会发现它会向集合中“添加”项,但不会首先“清除”集合,因此通过组件的初始化方法创建项可能会出现问题。

幸运的是,设计器有两个可以重写的方法。当您创建设计类型的新实例时,会调用 InitializeNewComponentInitializeExistingComponent 可用于修改现有组件。还有第三个重写方法,InitializeNonDefault,但我不知道何时调用它。

为了我们的目的,重写 InitializeNewComponent 方法就足够了。

public override void InitializeNewComponent(IDictionary defaultValues)
{
  base.InitializeNewComponent(defaultValues);
      
  // add two default pages to each new control and reset the selected index
  this.AddTabListPage();
  this.AddTabListPage();
  this.TabListControl.SelectedIndex = 0;
}

现在,无论何时您将 TabList 控件添加到设计器表面(例如 Form),它都会获得两个全新的 TabListPage

连接事件

对于我们的设计器,我们需要知道何时发生某些操作,以便我们能采取相应的行动——例如,如果没有要删除的内容,则禁用 Remove 动词。我们将通过重写 Initialize 方法来设置这些。

public override void Initialize(IComponent component)
{
  TabList control;
  ISelectionService selectionService;
  IComponentChangeService changeService;

  base.Initialize(component);

  // attach an event so we can be notified when the selected components in the host change
  selectionService = (ISelectionService)this.GetService(typeof(ISelectionService));
  if (selectionService != null)
    selectionService.SelectionChanged += this.OnSelectionChanged;

  // attach an event to notify us of when a component has been modified
  changeService = (IComponentChangeService)this.GetService(typeof(IComponentChangeService));
  if (changeService != null)
    changeService.ComponentChanged += this.OnComponentChanged;

  // attach an event so we can tell when the SelectedIndex of the TabList control changes
  control = component as TabList;
  if (control != null)
    control.SelectedIndexChanged += this.OnSelectedIndexChanged;
}

OnSelectionChanged

我们附加的第一个事件是 ISelectionService.SelectionChanged。当选定的组件更改时,会触发此事件。我们将使用此事件在选择托管在其上的控件时自动激活给定的 TabListPage

private void OnSelectionChanged(object sender, EventArgs e)
{
  ISelectionService service;

  service = (ISelectionService)this.GetService(typeof(ISelectionService));
  if (service != null)
  {
    TabList control;

    control = this.TabListControl;
    foreach (object component in service.GetSelectedComponents())
    {
      TabListPage ownedPage;

      // check to see if one of the selected controls is hosted on a TabListPage. If it is, 
      // activate the page. This means, if for example, you select a control via the
      // IDE's properties window, the relavent TabListPage will be activated

      ownedPage = this.GetComponentOwner(component);
      if (ownedPage != null && ownedPage.Parent == control)
      {
        control.SelectedPage = ownedPage;
        break;
      }
    }
  }
}

OnComponentChanged

当调用 RaiseComponentChanged 方法时,会触发第二个事件 IComponentChangeService.ComponentChanged。我们将在后面详细介绍此方法的工作原理,但现在,我们使用此事件来确定控件中是否有任何选项卡页——如果有,则启用 remove 命令,否则禁用。(我们稍后也会详细介绍动词!)

private void OnComponentChanged(object sender, ComponentChangedEventArgs e)
{
  // disable the Remove command if we dont' have anything we can actually remove
  if (_removeVerb != null)
    _removeVerb.Enabled = this.TabListControl.TabListPageCount > 0;
}

OnSelectedIndexChanged

最后一个事件 TabList.SelectedIndexChanged 位于 TabList 控件本身。由于运行时和设计时功能混合时组件选择的工作方式,我们使用此事件选择 TabList 组件进行设计。

private void OnSelectedIndexChanged(object sender, EventArgs e)
{
  ISelectionService service;

  service = (ISelectionService)this.GetService(typeof(ISelectionService));
  if (service != null)
  {
    // set the TabList control as the selected object. We need to do this as if the control 
    // is selected as a result
    // of GetHitTest returning true, normal designer actions don't seem to take place
    // Alternatively, we could select the selected TabListPage instead but might as well 
    // stick with the standard behaviour
    service.SetSelectedComponents(new object[] { this.Control });
  }
}

动词

上面我提到了动词,但它们到底是什么?它们是您附加到控件的上下文菜单和任务菜单的命令。为此,请重写设计器的 Verbs 属性并创建一个动词集合。

public override DesignerVerbCollection Verbs
{
  get
  {
    if (_verbs == null)
    {
      _verbs = new DesignerVerbCollection();

      _addVerb = new DesignerVerb("Add TabListPage", this.AddVerbHandler) 
                 { Description = "Add a new TabListPage to the parent control." };
      _removeVerb = new DesignerVerb("Remove TabListPage", this.RemoveVerbHandler) 
              { Description = "Remove the currently selected TabListPage from the parent control." };

      _verbs.Add(_addVerb);
      _verbs.Add(_removeVerb);
    }

    return _verbs;
  }
}

每个动词都绑定到一个事件处理程序。就我们的目的而言,这些事件很简单,只是传递到其他方法中。

private void AddVerbHandler(object sender, EventArgs e)
{
  this.AddTabListPage();
}

private void RemoveVerbHandler(object sender, EventArgs e)
{
  this.RemoveSelectedTabListPage();
}

我想你可以直接使用匿名委托来代替。

使用撤销支持修改组件

如果您对控件进行了多项更改,其中一项出错,IDE 不会自动为您撤消更改,您需要自行处理。幸运的是,IDE 通过设计器事务提供了此功能。除了为多项操作提供单个撤销外,使用事务对性能也很有益,因为 UI 更新会延迟到事务完成。

下面的代码由 Add 动词调用,并向控件添加一个新的 TabListPage

这些是进行更改的基本步骤:

  • 通过 IDesignerHost.CreateTransaction 创建一个事务。
  • 通过 RaiseComponentChanging 方法通知设计器即将进行的更改。
  • 进行更改。
  • 通过 RaiseComponentChanged 方法通知设计器已进行更改。这将触发上面提到的 IComponentChangeService.ComponentChanged 事件。
  • 提交 (Commit) 或取消 (Cancel) 事务。

在这种情况下,尽管将事务包装在 using 语句中,但我使用了一个显式的 try catch 块,以便在发生错误时取消事务。不过,我不确定这是否严格必要。

protected virtual void AddTabListPage()
{
  TabList control;
  IDesignerHost host;

  control = this.TabListControl;
  host = (IDesignerHost)this.GetService(typeof(IDesignerHost));

  if (host != null)
  {
    using (DesignerTransaction transaction = host.CreateTransaction(string.Format
                                ("Add TabListPage to '{0}'", control.Name)))
    {
      try
      {
        TabListPage page;
        MemberDescriptor controlsProperty;

        page = (TabListPage)host.CreateComponent(typeof(TabListPage));
        controlsProperty = TypeDescriptor.GetProperties(control)["Controls"];

        // tell the designer we're about to start making changes
        this.RaiseComponentChanging(controlsProperty);

        // set the text to match the name
        page.Text = page.Name;

        // add the new control to the parent, and set it to be the active page
        control.Controls.Add(page);
        control.SelectedIndex = control.TabListPageCount - 1;

        // inform the designer we're finished making changes
        this.RaiseComponentChanged(controlsProperty, null, null);

        // commit the transaction
        transaction.Commit();
      }
      catch 
      {
        transaction.Cancel();
        throw;
      }
    }
  }
}

remove 动词的处理程序执行几乎相同的操作,只是我们使用 IDesignerHost.DestroyComponent 来删除选定的 TabListPage 控件。

protected virtual void RemoveSelectedTabListPage()
{
  TabList control;

  control = this.TabListControl;

  if (control != null && control.TabListPageCount != 0)
  {
    IDesignerHost host;

    host = (IDesignerHost)this.GetService(typeof(IDesignerHost));

    if (host != null)
    {
      using (DesignerTransaction transaction = host.CreateTransaction(string.Format
                                               ("Remove TabListPage from '{0}'", control.Name)))
      {
        try
        {
          MemberDescriptor controlsProperty;

          controlsProperty = TypeDescriptor.GetProperties(control)["Controls"];

          // inform the designer we're about to make changes
          this.RaiseComponentChanging(controlsProperty);

          // remove the tab page
          host.DestroyComponent(control.SelectedPage);

          // tell the designer w're finished making changes
          this.RaiseComponentChanged(controlsProperty, null, null);

          // commit the transaction
          transaction.Commit();
        }
        catch
        {
          transaction.Cancel();
          throw;
        }
      }
    }
  }
}

向选定的 TabListPage 添加控件

如果选中了 TabList 控件并尝试将控件拖动到其上,您将收到一条错误消息,指出只能托管 TabListPage 控件。通过重写 CreateToolCore 方法,我们可以拦截控件创建,并通过 InvokeCreateTool 方法将其转发到当前 TabListPage

protected override IComponent[] CreateToolCore(ToolboxItem tool, int x, int y, 
int width, int height, bool hasLocation, bool hasSize)
{
  TabList control;
  IDesignerHost host;

  control = this.TabListControl;

  // prevent controls from being created directly on the TabList
  if (control.SelectedPage == null)
    throw new ArgumentException(string.Format("Cannot add control '{0}', 
    no page is selected.", tool.DisplayName));

  host = (IDesignerHost)this.GetService(typeof(IDesignerHost));
  if (host != null)
  {
    ParentControlDesigner childDesigner;

    childDesigner = (ParentControlDesigner)host.GetDesigner(control.SelectedPage);

    // add controls onto the TabListPage control instead of the TabList
    ParentControlDesigner.InvokeCreateTool(childDesigner, tool);
  }

  return null;
}

通过 CreateToolCore 返回 null 可防止在 TabList 上创建控件。其余逻辑会将调用转发到选定的 TabListPage(如果可用)。

允许在设计时选择 TabListPage

您会注意到,大多数控件在设计时无法使用——当您单击一个控件时,它只会选中它。对于我们的组件来说,这种默认行为是一个严重的问题,因为如果您无法激活其他页面,如何向它们添加控件呢?幸运的是,这非常容易实现,因为设计器提供了一个可以重写的 GetHitTest 方法。如果此方法返回 true,则鼠标单击将由底层控件而不是设计器处理。

protected override bool GetHitTest(Point point)
{
  TabList control;
  bool result;
  Point location;

  // return true if the mouse is located over a TabListPage header
  // this allows you to switch pages at design time with the mouse
  // rather than just selecting the control as it would otherwise

  control = this.TabListControl;
  location = control.PointToClient(point);
  result = control.HitTest(location) != null;

  return result;
}

在上面的代码中,我们将提供的鼠标坐标转换为客户端坐标,然后测试它们是否位于 TabListPage 的标题上。如果是,我们返回 true,然后调用将被转发到 TabList 控件,该控件将选择该页面。

这种行为有一个副作用。由于我们基本上拦截了鼠标调用,这意味着 TabList 控件未被选中。这种行为与标准行为不一致,这就是为什么在初始化设计器时我们挂接到 TabList 控件的 SelectedIndexChanged 事件。挂接后,一旦 SelectedIndex 属性更改,我们就可以手动选择 TabList 控件。当然,如果您愿意,可以将该代码更改为选择活动的 TabListPage,但这再次与标准行为不一致。

不幸的是,我还发现了另一个副作用——如果您右键单击允许鼠标单击通过的区域,上下文菜单将不再起作用。同样,通过重写 WndProc 并拦截 WM_CONTEXTMENU 消息,这相当容易解决。

protected override void WndProc(ref Message m)
{
  switch (m.Msg)
  {
    case 0x7b: // WM_CONTEXTMENU
      Point position;

      // For some reason the context menu is no longer displayed when right clicking the control
      // By hooking into the WM_CONTEXTMENU context message we can display the menu ourselves

      position = Cursor.Position;

      this.OnContextMenu(position.X, position.Y);
      break;
    default:
      base.WndProc(ref m);
      break;
  }
}

注意:通常,我不会像这里这样使用“魔术数字”。但同时,我也不想在这个类中定义 WM_CONTEXTMENU——对于我的内部项目,我链接到一个我创建的包含我使用的所有 Win32 API 功能的程序集。对于这个例子来说,链接到它是不可能的,而且我不想仅仅为了一个成员就创建一个 Native 类。所以这次,我将偷懒并保留一个内联的魔术数字。

我发现的最后一个副作用是双击以打开默认事件处理程序也不起作用。

设计时控件绘制

我想讨论的 TabListDesigner 类的最后一部分是设计时绘制。通常,在我的控件的 OnPaint 重写中,我会有一个类似于下面的块。

protected override void OnPaint(PaintEventArgs e)
{
  base.OnPaint(e);

  if (this.DesignMode)
  {
    // Design time painting here
  }
}

虽然这种方法没有错,但如果您使用的是设计器,那么您还有另一个选择,这样您就不必在每次运行时绘制容器时都进行设计时检查。设计器有一个 OnPaintAdornments 方法,只需重写它即可执行您的设计时绘制。

protected override void OnPaintAdornments(PaintEventArgs pe)
{
  base.OnPaintAdornments(pe);

  // outline the control at design time as we don't have any borders
  ControlPaint.DrawFocusRectangle(pe.Graphics, this.Control.ClientRectangle);
}

由于 TabList 没有边框属性,我使用 ControlPaint.DrawFocusRectangle 在控件周围绘制一条虚线。

实现 TabListPage 设计器

尽管 TabListPage 控件基本上是一个隐藏了一堆属性和事件的 Panel 控件,但它仍然需要一个设计器来重写某些功能。对于 TabListPageDesigner 类,我们将继承自 ScrollableControlDesigner

移除调整大小和移动句柄

由于 TabList 控件负责调整其子 TabListPage 控件的大小,我们不希望用户在设计时能够调整它们的大小或移动它们。通过重写 SelectionRules 属性,您可以精确定义显示哪些句柄。由于我不希望控件被移动或调整大小,我通过 Locked 标志去掉了所有内容。

public override SelectionRules SelectionRules
{ get { return SelectionRules.Locked; } }

防止组件重新父化

CanBeParentedTo 方法用于确定一个组件是否可以被另一个控件托管。我正在重写它以确保它们只能在另一个 TabList 控件上作为父级。尽管如此,由于我上面已经通过选择规则禁用了 TabListPage 控件的拖动,所以无论如何您也无法拖动它们来重新父化。

public override bool CanBeParentedTo(IDesigner parentDesigner)
{
  return parentDesigner != null && parentDesigner.Component is TabList;
}

已知问题

  • 如上所述,如果您双击一个 TabListPage 标题,什么也不会发生。通常,您会期望打开一个代码窗口到控件的默认事件处理程序。虽然应该可以捕获 WM_LBUTTONDBLCLK 消息,但我不知道如何打开代码窗口,或者在缺少默认事件处理程序时创建它。
  • 我发现的另一个问题是,我无法将 TabListPage 从一个 TabList 控件剪切(或复制)到另一个。还不确定为什么,但修复后我会在 GitHub 上更新源代码。

源代码

从文章顶部的链接获取源代码。我也将其上传到了 GitHub,欢迎分叉并提交拉取请求,使此组件变得更好!

© . All rights reserved.