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





5.00/5 (6投票s)
本文介绍了如何为一个类似于 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
。当您将其中一个添加到窗体时,它会生成两个托管页面。如果您删除它们,它们不会回来。如果您查看过控件的设计器生成代码,您会发现它会向集合中“添加”项,但不会首先“清除”集合,因此通过组件的初始化方法创建项可能会出现问题。
幸运的是,设计器有两个可以重写的方法。当您创建设计类型的新实例时,会调用 InitializeNewComponent
。InitializeExistingComponent
可用于修改现有组件。还有第三个重写方法,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,欢迎分叉并提交拉取请求,使此组件变得更好!