Creating a multi-paged container control with design time support
This article describes adding design time support for a
TabControl
-like component which renders the same way the
Project Properties in Visual Studio 2012.
This is the first time I've tried to make more advanced use of component designers so there are going to be areas that I'm not aware of or have not implemented correctly. The component seems to be working fine, but it's entirely possible that bugs exist, which could cause problems. Caveat emptor!
Overview of the control
For this article, I'm not going to delve into how the control itself was put together as I want to focus on the design time support, so I'm just going to provide a quick overview.
TabList
- the main controlTabListPage
- these are hosted by theTabList
to provided multi-paged supportTabListControlCollection
- a customControlCollection
that handlesTabListPages
, and prevents adding other controls directly onto theTabList
TabListPageCollection
- a strongly typed wrapper forTabListPage
objects
The basics of these four classes are all based on the
TabControl
. If you know how to use that, then you know how to
use the TabList
control, some property names have changed but
otherwise it's pretty similar.
For rendering support, we use these classes:
ITabListPageRenderer
- interface to be implemented by rendering classesTabListPageRenderer
- base class to inherit for render support, and also provides a default renderer propertyTabListPageState
- flags which describe the state of aTabListPage
DefaultTabListPageRenderer
- simple renderer which draws a header in a Visual Studio 2012-esque style.
And finally, we have the two designers which this article will concentrate on:
TabListDesigner
- designer class for theTabList
controlTabListPageDesigner
- designer class for theTabListPage
control
Implementing the TabListDesigner
As the TabList
control is a container control, we can't use
the base ControlDesigner
. Instead, we'll use
ParentControlDesigner
which has a bunch of extra functionality
we need.
Initializing a new control
Normally, I initialize a component via the constructor of the
control. This is fine when you're initializing properties to
default values, but what about adding child items? Consider for
example the TabControl
. When add one of these to a form, it
generates two hosted pages. If you remove these, they don't come
back. If you've ever looked at the designer generated code for a
control, you'll see it will add items to a collection, but
doesn't clear the collection first so creating items via the
initialization method of a component would be problematic.
Fortunately for us, the designer has two methods you can
override. InitializeNewComponent
is called when you create a
new instance of the designed type. InitializeExistingComponent
can be used to modify an existing component. There's also a
third override, InitializeNonDefault
although I'm not sure
when this is called.
For our purposes, overriding the InitializeNewComponent
method
is enough:
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;
}
Now, whenever you add a TabList
control onto a designer
surface such as a Form
, it'll get two shiny new
TabListPages
.
Hooking up events
For our designer, we need to know when certain actions occur so
we can act accordingly - for example, to disable the Remove verb
if there's nothing to remove. We'll set these up by overriding
the Initialize
method.
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
The first event we attached as
ISelectionService.SelectionChanged
. This event is raised when
the selected components change. We'll use this event to
automatically activate a given TabListPage
if a control hosted
upon it is selected.
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
The second event IComponentChangeService.ComponentChanged
is
raised when the RaiseComponentChanged
method is called. We'll
describe how this method works a bit further on, but for now, we
use the event to determine if there are any tab pages in the
control - if there are, the remove command is enabled, otherwise
it's disabled. (We'll also describe the verbs further down too!)
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
The final event, TabList.SelectedIndexChanged
is on the
TabList
control itself. We use this event to select the
TabList
component for designing due to how component selection
seems to work when you mix runtime and design time
functionality.
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
I mentioned verbs above, but just what are they? Well, they are
commands you attach to the context and tasks menu of controls.
To do this, override the Verbs
property of your designer and
create a verbs collection.
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;
}
}
Each verb binds to an event handler. For our purposes the events are simple and just pass through into other methods.
private void AddVerbHandler(object sender, EventArgs e)
{
this.AddTabListPage();
}
private void RemoveVerbHandler(object sender, EventArgs e)
{
this.RemoveSelectedTabListPage();
}
I suppose you could just use an anonymous delegate instead.
Modifying a component with undo support
If you are making multiple changes a control, and one of these goes wrong, the IDE won't automatically undo the changes for you and you will need to handle this yourself. Fortunately, the IDE does provide the facility via designer transactions. In additional to providing a single undo for a number of operations, using transactions can also be good for performance as UI updates are delayed until the transaction is complete.
The code below is called by the Add verb and adds a new
TabListPage
to the control.
These are the basic steps for making changes:
- Create a transaction via
IDesignerHost.CreateTransaction
- Notify the designer of impending changes via the
RaiseComponentChanging
method - Make the change
- Notify the designer that the change has been made via the
RaiseComponentChanged
method. This will raise theIComponentChangeService.ComponentChanged
event mentioned above. - Either
Commit
orCancel
the transaction
In this case, despite wrapping the transaction in a using
statement, I've got got an explicit try
catch
block to
cancel the transaction in the event of an error. I'm not sure if
this is strictly necessary however.
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;
}
}
}
}
The handler for the remove verb does pretty much the same thing,
except we use IDesignerHost.DestroyComponent
to remove the
selected TabListPage
control.
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 we're finished making changes
this.RaiseComponentChanged(controlsProperty, null, null);
// commit the transaction
transaction.Commit();
}
catch
{
transaction.Cancel();
throw;
}
}
}
}
}
Adding controls to the selected TabListPage
If the TabList
control is selected and you try to drag a
control on it, you'll get an error stating that only
TabListPage
controls can be hosted. By overriding the
CreateToolCore
method, we can intercept the control creation,
and forward it onto the current TabListPage
via the
InvokeCreateTool
method.
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;
}
Returning null
via CreateToolCore
prevents the control from
being created on the TabList
. The reminder of the logic
forwards the call onto the selected TabListPage
, if one is
available.
Allowing TabListPage selection at design-time
As you'll have noticed, most controls can't be used at design
time - when you click a control it just selects it. This default
behaviour is a serious problem for our component as if you can't
active other pages, how can you add controls to them?
Fortunately, this is extremely easy to implement as the designer
provides a GetHitTest
method which you can override. If you
return true
from this method, then mouse clicks will be
processed by the underlying control instead of the designer.
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;
}
In the above code, we translate the provided mouse co-ordinates
into the client co-ordinates, then test to see if they are on
the header of a TabListPage
. If they are, we return true
and
the call will then be forward onto the TabList
control which
will then selected that page.
There is one side effect of this behaviour. As we have
essentially intercepted the mouse call, that means the TabList
control isn't selected. This behaviour is inconsistent with
standard behaviour and this is why when the designer was
initialized we hooked into the SelectedIndexChanged
event of
the TabList
control. With this hooked, as soon as the
SelectedIndex
property is changed we can manually select the
TabList
control. Of course, if you'd rather, you could change
that code to select the active TabListPage
instead, but again
that's inconsistent with standard behaviour.
Unfortunately there's also another side effect I discovered -
the context menu no longer works if you right click on an area
where you allow mouse clicks to pass through. Again, this is
fairly straightforward to work around by overriding WndProc
and intercepting the WM_CONTEXTMENU
message.
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;
}
}
Note: Normally I wouldn't use "magic numbers" as I have here. But at the same time, I don't want to define WM_CONTEXTMENU in this class - for my internal projects, I link to an assembly I've created which contains all the Win32 API functionality that I use. Linking that to this not possible for this example and I don't want to create a
Native
class for a just a single member. So this time I'll cheat and leave an inline magic number.
The final side effect I've found is double clicking to open the default event handler doesn't work either.
Design time control paining
The final section of the TabListDesigner
class I want to
discuss is design time painting. Normally, in the OnPaint
overriding of my control, I would have a block similar to the
below.
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
if (this.DesignMode)
{
// Design time painting here
}
}
While there's nothing wrong with this approach, if you are using
a designer than you have another option, which saves you having
to do design time checks each time your contain is painted at
runtime. The designer has an OnPaintAdornments
method, just
override this to perform your design time drawing.
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);
}
As the TabList
doesn't have a border property, I draw a dotted
line around the control using ControlPaint.DrawFocusRectangle
.
Implementing the TabListPage designer
Although the TabListPage
control is basically a Panel
control with a bunch of properties and events hidden, it still
needs a designer to override some functionality. For the
TabListPageDesigner
class, we'll inherit from
ScrollableControlDesigner
.
Removing sizing and moving handles
As the TabList
control takes care of sizing its child
TabListPage
controls, we don't really want the user to be able
to resize or move them at design time. By overriding the
SelectionRules
property, you can define exactly which handles
are displayed. As I don't want the control to be moved or sized,
I get rid of everything via the Locked
flag.
public override SelectionRules SelectionRules
{ get { return SelectionRules.Locked; } }
Preventing the component from being re-parented
The CanBeParentedTo
method is used to determine if a component
can be hosted by another control. I'm overriding this to make
sure that they can only be parented on another TabList
control. Although, as I've disabled the dragging of
TabListPage
controls with selection rules above, you can't
drag them to reparent anyway.
public override bool CanBeParentedTo(IDesigner parentDesigner)
{
return parentDesigner != null && parentDesigner.Component is TabList;
}
Known Issues
- As described above, if you double click one of the
TabListPage
headers nothing happens. Normally, you'd expect a code window to be opened at the default event handler for the control. While it should be possible to trap theWM_LBUTTONDBLCLK
message, I don't know how to open a code window, or create a default event handler is one is missing. - Another issue I spotted is that I can't Cut (or Copy) a
TagListPage
from oneTabList
control to another. Not sure why yet, but I'll update the source on GitHub when I fix it.
The source
Get the source code from the link below. I've also uploaded it to GitHub, feel free to fork and make pull requests to make this component even better!
Update History
- 2012-08-19 - First published
- 2020-11-21 - Updated formatting
Downloads
Filename | Description | Version | Release Date | |
---|---|---|---|---|
Cyotek.Windows.Forms.TabList.zip
|
Cyotek.Windows.Forms.TabList control, a multi-paged container control with design time support and a Visual Studio 2012 look |
1.0.0.2 | 31/12/2012 | Download |
Leave a Comment
While we appreciate comments from our users, please follow our posting guidelines. Have you tried the Cyotek Forums for support from Cyotek and the community?
Comments
DotNetKicks.com
#
[b]Creating a multi-paged container control with design time support[/b] You've been kicked (a good thing) - Trackback from DotNetKicks.com
Mike Stephens
#
Fantastic piece of work here.
I'm experiencing a small issue where randomly the tab pages reorder themselves, in this specific case it is the last tab being repositioned to second place.
Richard Moss
#
Mike,
Thanks for your comment. Do you have a sample which replaces this? I'll take a look at the code and see if I can spot any bugs.
Regards; Richard Moss
Andrew Tupper
#
is there no way to change the colour of the selected tab and the colour of the non selected tabs? Or is it always Blue and Transparent