Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Creating a Multi-Page Windows Forms Control with Design Time Support

0.00/5 (No votes)
1 Apr 2009 2  
A step-by-step guide to creating a control and adding design time support to it

Introduction

Have you ever been faced with the task of displaying multiple pages on the same Windows Form? Most of us would certainly respond in the affirmative, and most of us have addressed this by using the good-old Tab Control. While tabs are undoubtedly a proven way of handling such cases, there are situations calling for a "less generic" approach. What if we need to make our form a bit more visually appealing and use icons or graphical buttons to flip through pages? What if we don't want to display tabs at all? Many familiar applications feature such graphical interfaces (see illustration); however, the .NET Framework offers no built-in tools to accomplish this, at least at the time of this writing.

Examples of application windows that use multiple pages: Live Messenger, FireFox

For my purposes, I had to build a control from scratch, thanks to the framework's extensible nature. When the project was completed, I felt that the experience was worth sharing with the developer community, so here we go: a custom Multi-Page Control.

Please note that the implementation of a particular page-flipping mechanism (such as icons or list-box items) is beyond the scope of this article. Rather, it focuses on a custom Windows Forms control that can host multiple pages of child controls, as well as the programming model for using the control in a Windows Forms project. For simplicity, I am using standard Windows Forms controls - buttons and combo-box items - for page activation.

Before we plunge into the code, a few words on the level of training recommended for better understanding of the subject. Familiarity with C# and object oriented programming is expected, with some experience in Windows Forms programming. Knowledge of some of the .NET Framework's advanced features, such as reflection, is helpful, although not 100% required.

You will also notice that I do not use Hungarian notation in my code; however, reading the code should not be a problem, since the notation is, in fact, quite simple and easy to understand. The prefixes are assigned as such:

  • a: local variables
  • the: method's formal parameters, as well as parameters passed to exception-handling catch blocks
  • my: instance fields of a class
  • our: static fields of a class

Properties and methods are named using the Camel case notation (each word is capitalized):

DoActionOne();
PropertyTwo = 1;

Step 1. Creating the Control

Our MultiPaneControl is a .NET class that inherits from System.Windows.Forms.Control. Individual pages are instances of the MultiPanePage class. Since the MultiPanePage class is all about containing other controls, it makes perfect sense to derive it from System.Windows.Forms.Panel:

public class MultiPaneControl : System.Windows.Forms.Control
{
  // implementation goes here
}

public class MultiPanePage : System.Windows.Forms.Panel
{
  // implementation goes here
}

Adding a page to our control is a no brainer:

Controls.Add( new MultiPanePage() );

Now, to set the current page, we will add a dedicated property and toggle visibility within its set block:

protected MultiPanePage mySelectedPage;

public MultiPanePage SelectedPage
{
  get
    { return mySelectedPage; }

  set
  {
    if (mySelectedPage != null)
      mySelectedPage.Visible = false;

    mySelectedPage = value;

    if (mySelectedPage != null)
      mySelectedPage.Visible = true;
  }
}

Since all members of the MultiPaneControl's Controls collection (i.e., all pages) should share such basic characteristics as positioning and pixel dimensions, it's a good idea to set those from within the parent control. Our control must also support background transparency. And we want to make sure that no other class instance, except MultiPanePage, can be added to the MultiPaneControl, so we will secure the addition with a conditional statement:

public MultiPaneControl()
{
  ControlAdded += new ControlEventHandler(Handler_ControlAdded);

  SetStyle(ControlStyles.SupportsTransparentBackColor, true);
  BackColor = Color.Transparent;
}

private void Handler_ControlAdded(object theSender, ControlEventArgs theArgs)
{
  if (theArgs.Control is MultiPanePage)
  {
    MultiPanePage aPg = (MultiPanePage) theArgs.Control;

    // prevent the page from being moved and/or sized independently

    aPg.Location = new Point(0, 0);
    aPg.Dock = DockStyle.Fill;

    if (Controls.Count == 1)
      mySelectedPage = aPg;  // automatically set the current page

    else
      theArgs.Control.Visible = false;
  }
  else
  {
    // block anything other than MultiPanePage
    Controls.Remove(theArgs.Control);
  }
}

One final thing for us to do is override the DefaultSize property:

protected static readonly Size ourDefaultSize = new Size(200, 100);

protected override Size DefaultSize
{
  get { return ourDefaultSize; }
}

At this point, our control is ready to be compiled and tested in the code.

Compiling the Step1 Sample Project yields a control that works fine at runtime; however, dealing with it in the design environment reveals an extensive list of shortcomings:

  1. There is no way to create an instance of MultiPaneControl on a form except by changing its source code.
  2. Adding and removing pages is also not possible without altering the source code.
  3. Pages can still be moved and sized individually: even though we've initially set the Dock property to prevent independent dragging/sizing, it can be easily altered through the Properties window.
  4. The only way to edit a page graphically is by setting the control's SelectedPage property so that the page becomes visible.
  5. When editing the SelectedPage property through the Properties window, Visual Studio displays all objects of type MultiPanePage, including those belonging to other instances of MultiPaneControl. In reality, only children of the control in question should be accessible.

Inadequacies of this sort are quite common in custom controls that lack Design-Time support, so our next step will be precisely that: enabling our control to work smoothly with Visual Studio's RAD tools.

Step 2. Adding Design-Time Support: the Basics

Toolbox Item

In this step, we will develop a Toolbox item for our MultiPaneControl. Since the Toolbox item is not an independent entity but rather an extension to the MultiPaneControl, they will be compiled together into a standalone assembly. This will allow us to utilize our control in any Windows Forms project we may lay our hands on in the future. To uniquely identify our control on the Toolbox, we will also design a custom icon for it.

When in action, the Toolbox item will create an instance of our control in response to a drag-and-drop - or "drawing" event. By default, the new control will contain one page. To associate a Toolbox item with a control, we need to apply the ToolboxItem attribute to the control's class:

[ToolboxItem(typeof(MultiPaneControlToolboxItem))]
public class MultiPaneControl : Control
{
  ...
}

The code above instructs Visual Studio to create an instance of MultiPaneControlToolboxItem which is then used to instantiate our MultiPaneControl. The former class must inherit from System.Drawing.Design.ToolboxItem. It must also be declared as Serializable since Visual Studio will serialize it into its internal data structures. Because System.Drawing.Design.ToolboxItem implements the ISerializable interface, we also need to define a special serialization constructor as is stated in the Microsoft documentation.

[PermissionSet(SecurityAction.Demand, Name = "FullTrust")]
[Serializable]
public class MultiPaneControlToolboxItem : ToolboxItem
{
  public MultiPaneControlToolboxItem() : base(typeof(MultiPaneControl))
  {
  }

  // Serialization constructor, required for deserialization
  public MultiPaneControlToolboxItem(SerializationInfo theInfo,
                                     StreamingContext theContext)
  {
    Deserialize(theInfo, theContext);
  }

  protected override IComponent[] CreateComponentsCore(IDesignerHost theHost)
  {
    // Control
    MultiPaneControl aCtl =
      (MultiPaneControl)theHost.CreateComponent(typeof(MultiPaneControl));

    // Control's page
    MultiPanePage aNewPage1 =
      (MultiPanePage)theHost.CreateComponent(typeof(MultiPanePage));
    aCtl.Controls.Add(aNewPage1);

    return new IComponent[] { aCtl };
  }
}

As we can see, it is the CreateComponentsCore method that is responsible for creating our control and supplying it with a page. Visual Studio does the rest for us automatically by adding the appropriate code to the form.

Let's take some time away from coding and put our artistic hat on, since it's time to design an icon for our control! This icon will be displayed in the Toolbox (all versions of Visual Studio) and in the Document Outline window (VS 2005 or higher).

  1. In your preferred graphics editor, draw a 16x16-pixel image.
  2. Save it as a bitmap image, naming the file [Your control's class name].bmp. In our case, the images for MultiPaneControl and MultiPanePage controls will be named MultiPaneControl.bmp and MultiPanePage.bmp, respectively.
  3. Using the Project -> Add Existing Item... menu, place the images in the root of your project, i.e., the folder where your *.cs files reside.
  4. In the Properties window for each bitmap, set the Build Action property to Embedded Resource.
  5. Make sure that your control classes reside in the project default namespace as shown below:
  6. Setting the Default Namespace for the project

To preclude independent creation of MultiPanePage instances, we apply the following attribute to the class:

[ToolboxItem(false)]    // do not place in the Toolbox
public class MultiPanePage : Panel
{
  ...
}

UI Type Editor

Next, we are going to implement an editor for the SelectedPage property. As I've mentioned before, when we edit this property via the Properties Window, Visual Studio filters the list of items based solely on the item type. Suppose our form contains two instances of MultiPaneControl: Control A and Control B, each with its own collection of pages. Setting a page that belongs to Control A as the selected page of Control B is undesirable and error-prone, aside from the fact that it plain doesn't make any sense. The list of available objects should be restricted to pages that are part of the control being edited.

The illustration below shows a form with two MultiPaneControls, the upper control containing three pages (myCtlPanePage1, myCtlPanePage2, myCtlPanePage3), and the lower, two pages (myCtlPanePage4 and myCtlPanePage5). The lower control is currently selected. The UI Type Editor reflects the fact that the pages belong to two different controls and only displays the appropriate objects.

Choosing from a list of pages that belong to the selected control

To accomplish this, we need to create a class that derives, either directly or indirectly, from System.ComponentModel.Design.UITypeEditor. Our editor will inherit from System.ComponentModel.Design.ObjectSelectorEditor, which is ideal for the problem we are trying to address. This class displays a drop-down list of items in response to an arrow click. Our derived class, in turn, will only be responsible for populating the list.

internal class MultiPaneControlSelectedPageEditor : ObjectSelectorEditor
{
  protected override void FillTreeWithData(Selector theSel,
    ITypeDescriptorContext theCtx, IServiceProvider theProvider)
  {
    base.FillTreeWithData(theSel, theCtx, theProvider);  //clear the selection

    MultiPaneControl aCtl = (MultiPaneControl) theCtx.Instance;

    foreach (MultiPanePage aIt in aCtl.Controls)
    {
      SelectorNode aNd = new SelectorNode(aIt.Name, aIt);

      theSel.Nodes.Add(aNd);

      if (aIt == aCtl.SelectedPage)
        theSel.SelectedNode = aNd;
    }
  }
}

With this class in place, all we need to do is apply the Editor attribute to the property:

[
  Editor(
    typeof(MultiPaneControlSelectedPageEditor), //the class we've just created!
    typeof(System.Drawing.Design.UITypeEditor) )
]
public MultiPanePage SelectedPage
{
  ...
}

We are almost ready to compile our Step2 sample project. To wrap things up, we'll hide the two MultiPanePage's properties that are not meant for direct editing, namely Dock and Location:

[EditorBrowsable(EditorBrowsableState.Never)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public override DockStyle Dock
{
  get { return base.Dock; }
  set { base.Dock = value; }
}

[EditorBrowsable(EditorBrowsableState.Never)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public new Point Location
{
    get { return base.Location; }
    set { base.Location = value; }
}

Please note that we must not apply the same technique to the Size property, as Visual Studio uses it for laying out child controls.

Step 3. Adding Design-Time Support. Control Designers.

So far, we have covered only three of the five design-time issues outlined earlier. As a result:

  • MultiPaneControl instances can now be created graphically from Visual Studio's Toolbox.
  • Pages can no longer be dragged independently of their container control.
  • The SelectedPage property has an error-proof UI Type Editor.

Addressing the remaining problems involves creation of control designers for both the MultiPaneControl and MultiPanePage controls. In this section, we will be dealing with two components, the Control and the Designer. It's important not to confuse methods and properties of the Designer with those of the Control.

The steps to provide design-time support to the MultiPaneControl are:

  • Create a MultiPaneControlDesigner that implements the IDesigner interface, as outlined in the Microsoft documentation.
  • Associate our Control with the Designer and make Visual Studio aware of this association. This is achieved by applying the Designer attribute to the MultiPaneControl class.

We can derive our Designer class from several classes featured by the .NET Framework that implement the IDesigner interface. These are:

  • ControlDesigner - default designer class used with all controls that don't have a custom designer
  • ParentControlDesigner - inherits full functionality of ControlDesigner and supports dragging controls onto the edited control's surface
  • ScrollableControlDesigner - inherits full functionality of ParentControlDesigner and supports processing of scrolling events at design time

Being that the sole purpose of the MultiPaneControl class is hosting other controls, ParentControlDesigner seems to be the best choice for our designer.

MultiPaneControlDesigner

The key in developing a control's designer is its interaction with the development environment. Such interaction relies on the so called designer services, i.e., objects obtained as a result of calling the GetService method. Each object implements an interface that serves its own specific purpose. For instance, the ISelectionService is used to programmatically select/deselect components on a form.

A reference to the interface must be obtained in order to access its functionality. In other words, we are not guaranteed that our development environment (or, rather, its version) will support a certain interface. For example, a control designer developed for Visual Studio 2008 may crash an older version of VS if the latter lacks support for the necessary interface. To determine whether such support is present, we need to explicitly check the value returned by the GetService method:

void MyFunc(ComponentDesigner theDesigner)
{
  Type aType = typeof(ISelectionService);
  ISelectionService aSrv = (ISelectionService) theDesigner.GetService(aType);

  if (aSrv != null)      //REQUIRED!!!
  {
    // do actions with the service
  }
}

If you have experience in COM programming, you will notice a strong resemblance here in the way references are obtained, as well as how we check to see if the interface is actually supported. In contrast to COM, though, our reference does not need to be released in the .NET environment.

So, we will set off by establishing contact between the IDE and our control designer. To do so, we will override the Initialize method and subscribe to several events provided by designer services. We'll also initialize the Designer Verbs from within this method.

A Designer Verb may be viewed as a single action or command. Each designer has verbs unique to the task for which it was built. Visual Studio displays verbs in the control's context menu. Versions 2005 and higher also show the verbs in a popup window activated by clicking on the small triangle in the top left corner of the control's bounding area:

Designer verbs provided by MultiPaneControlDesigner

When the verb is called by the user, an event is raised and the system automatically invokes its handler. In the handler, the designer performs verb-specific actions, perhaps modifying the properties of the control. Now, let's look at the Initialize method:

public override void Initialize(IComponent theComponent)
{
  base.Initialize(theComponent);  // IMPORTANT! This must be the very first line

  // ISelectionService events
  ISelectionService aSrv_Sel =
    (ISelectionService)GetService(typeof(ISelectionService));
  if (aSrv_Sel != null)
    aSrv_Sel.SelectionChanged += new EventHandler(Handler_SelectionChanged);

  // IComponentChangeService events
  IComponentChangeService aSrv_CH =
    (IComponentChangeService)GetService(typeof(IComponentChangeService));
  if (aSrv_CH != null)
  {
    aSrv_CH.ComponentRemoving += new ComponentEventHandler(Handler_ComponentRemoving);
    aSrv_CH.ComponentChanged += 
	new ComponentChangedEventHandler(Handler_ComponentChanged);
  }

  // Prepare the verbs
  myAddVerb = new DesignerVerb("Add page", new EventHandler(Handler_AddPage));
  myRemoveVerb = new DesignerVerb("Remove page", new EventHandler(Handler_RemovePage));
  mySwitchVerb = 
	new DesignerVerb("Switch pages...", new EventHandler(Handler_SwitchPage));

  myVerbs = new DesignerVerbCollection();
  myVerbs.AddRange(new DesignerVerb[] { myAddVerb, myRemoveVerb, mySwitchVerb });
}

We have subscribed to three events here, so we'll have to do exactly the opposite in the Dispose method override:

protected override void Dispose(bool theDisposing)
{
  if (theDisposing)
  {
    // ISelectionService events
    ISelectionService aSrv_Sel = 
	(ISelectionService)GetService(typeof(ISelectionService));
    if (aSrv_Sel != null)
      aSrv_Sel.SelectionChanged -= new EventHandler(Handler_SelectionChanged);

    // IComponentChangeService events
    IComponentChangeService aSrv_CH =
      (IComponentChangeService)GetService(typeof(IComponentChangeService));
    if (aSrv_CH != null)
    {
      aSrv_CH.ComponentRemoving -=
        new ComponentEventHandler(Handler_ComponentRemoving);
      aSrv_CH.ComponentChanged -=
        new ComponentChangedEventHandler(Handler_ComponentChanged);
    }
  }
  base.Dispose(theDisposing);
}

Let's take a look at the event handlers. Since only one page can be visible at a time, selecting a child control that is located on a hidden page must set this page to be visible. The handler for the SelectionChanged event will be used to monitor control selection and toggle visibility.

The handler for ComponentRemoving is required to update the SelectedPage property, if necessary. This way the property will always be in sync with the Controls collection.

Finally, the handler for ComponentChanged is useful for enabling/disabling particular verbs. For example, we won't allow page deletion for a control that has only one page.

Our designer will override a few more methods. For example, we want to prevent anything other than MultiPanePage instances from being added as child controls (the same concept we had to implement for the control itself):

public override bool CanParent(Control theControl)
{
  if (theControl is MultiPanePage)
    return !Control.Contains(theControl);
  else
    return false;
}

Also, several methods related to drag-and-drop will be overridden. Because our control can only host pages, and nothing else, a drag-and-drop operation must be processed by the control's pages. For example, dragging and dropping a CheckBox control must result in its placement on one of the pages, and not the container. This is achieved by redirecting drag-and-drop operations to the page's designer, which we will cover a bit later. Let's look at the OnDragDrop override:

protected override void OnDragDrop(DragEventArgs theDragEvents)
{
  MultiPanePageDesigner aDsgn_Sel = GetSelectedPageDesigner();
  if (aDsgn_Sel != null)
    aDsgn_Sel.InternalOnDragDrop(theDragEvents);
}

The MultiPanePageDesigner's internal method – InternalOnDragDrop – will do the work for us. The same is true for the OnDragEnter, OnDragLeave, OnDragOver, and OnGiveFeedback methods.

In the GetSelectedPageDesigner method, we will use a mechanism of setting Page visibility that is similar to the one we've defined in the SelectedPage property of the control. However, in the MultiPaneControlDesigner, this mechanism will only be applicable to design-time, allowing us to switch from one page to another, yet leaving the SelectedPage property of the control unchanged.

To provide a means of synchronizing both properties, we will introduce two more events in MultiPaneControl and slightly modify the body of its SelectedPage set block:

// MultiPaneControl class
public event EventHandler SelectedPageChanging;
public event EventHandler SelectedPageChanged;

public MultiPanePage SelectedPage
{
  get { return mySelectedPage; }
  set
  {
    if (mySelectedPage == value)
      return;

    // fire the event before switching
    if (SelectedPageChanging != null)
      SelectedPageChanging(this, EventArgs.Empty);

    if (mySelectedPage != null)
      mySelectedPage.Visible = false;

    mySelectedPage = value;

    if (mySelectedPage != null)
      mySelectedPage.Visible = true;

    // fire the event after switching
    if (SelectedPageChanged != null)
      SelectedPageChanged(this, EventArgs.Empty);
  }
}

Just as with designer services, we can specify handlers for our new MultiPaneControl events in the Initialize method. Again, corresponding clean-up code needs to be added into the Dispose method:

// ...Initialize
DesignedControl.SelectedPageChanged += new EventHandler(Handler_SelectedPageChanged);

// ...Dispose
DesignedControl.SelectedPageChanged -= new EventHandler(Handler_SelectedPageChanged);

Processing of the event is trivial:

private void Handler_SelectedPageChanged(object theSender, EventArgs theArgs)
{
  mySelectedPage = DesignedControl.SelectedPage;
}

mySelectedPage is a MultiPaneControl's field holding the page visible at design time. Finally, the implementation of the GetSelectedPageDesigner method goes like this:

private MultiPanePageDesigner GetSelectedPageDesigner()
{
  MultiPanePage aSelPage = mySelectedPage;  // not DesignedControl.SelectedPage

  if (aSelPage == null)
    return null;

  MultiPanePageDesigner aDesigner = null;

  IDesignerHost aSrv = (IDesignerHost)GetService(typeof(IDesignerHost));
  if (aSrv != null)
    aDesigner = (MultiPanePageDesigner)aSrv.GetDesigner(aSelPage);

  return aDesigner;
}

Working with Pages

This is perhaps the most interesting and rewarding part of the whole project, as we finally get to see our control and designer in action.

Our Handler_AddPage needs to perform the following:

  1. Create a page.
  2. Add it to the collection of controls.
  3. Make the page visible for further editing.
  4. Reflect the changes into the form's source code.
private void Handler_AddPage(object theSender, EventArgs theArgs)
{
  IDesignerHost aHost = (IDesignerHost)GetService(typeof(IDesignerHost));

  if (aHost == null)
    return;

  MultiPaneControl aCtl = DesignedControl;

  MultiPanePage aNewPage = (MultiPanePage)aHost.CreateComponent(typeof(MultiPanePage));

  MemberDescriptor aMem_Controls = TypeDescriptor.GetProperties(aCtl)["Controls"];

  RaiseComponentChanging(aMem_Controls);

  aCtl.Controls.Add(aNewPage);
  DesignerSelectedPage = aNewPage;

  RaiseComponentChanged(aMem_Controls, null, null);
}

In order for the source code to reflect the changes, we use aHost.CreateComponent(typeof(MultiPanePage)) instead of new MultiPanePage(). The two notification methods, RaiseComponentChanging and RaiseComponentChanged, are called for code serialization.

In theory, the above listing should work fine. But... what if something does go wrong, causing an exception somewhere in the middle of the execution? Consider this:

RaiseComponentChanging(aMem_Controls);

aCtl.Controls.Add(aNewPage);

throw new Exception("Test exception");  // throw a test exception so that
                                        // further code is not executed
DesignerSelectedPage = aNewPage;

RaiseComponentChanged(aMem_Controls, null, null);

The first solution that comes to mind is wrapping the exception-prone code into a try block. However, in our example, the code that executed prior to throwing an exception has already altered the state of our controls and reversing this in a catch block would be extremely difficult.

To address this issue, we will initiate a transaction which will control the execution of our code. The transaction will only commit upon successful execution, and roll back otherwise. Microsoft documentation states that transactions are used for undo/redo support, but, frankly, I was unable to reproduce the lack of such support in a transaction-less environment.

I highly recommend adopting a uniform design strategy for methods that are to be executed inside transactions. That way, a single wrapper method could be used to initiate and commit all transactions, as well as to call a delegate to perform actual processing. Our Step3 example features a small class named DesignerTransactionUtility which serves precisely that purpose:

public abstract class DesignerTransactionUtility
{
  public static object DoInTransaction(
    IDesignerHost theHost,
    string theTransactionName,
    TransactionAwareParammedMethod theMethod,
    object theParam)
  {
    DesignerTransaction aTran = null;
    object aRetVal = null;

    try
    {
      aTran = theHost.CreateTransaction(theTransactionName);

      aRetVal = theMethod(theHost, theParam);   // perform actual execution
    }
    catch (CheckoutException theEx)             //  transaction initiation failed
    {
      if (theEx != CheckoutException.Canceled)
        throw theEx;
    }
    catch
    {
      if (aTran != null)
      {
        aTran.Cancel();
        aTran = null;  // the transaction won't commit in the 'finally' block
      }

      throw;
    }
    finally
    {
      if (aTran != null)
        aTran.Commit();
    }

    return aRetVal;
  }
}

The TransactionAwareParammedMethod is a delegate that encapsulates an operation whose execution occurs inside a transaction:

public delegate object TransactionAwareParammedMethod(IDesignerHost theHost,
                                                      object theParam);

Then, we slightly modify the event handler:

private void Handler_AddPage(object theSender, EventArgs theArgs)
{
  IDesignerHost aHost = (IDesignerHost)GetService(typeof(IDesignerHost));

  if (aHost == null)
    return;

  DesignerTransactionUtility.DoInTransaction
  (
    aHost,
    "MultiPaneControlAddPage",
    new TransactionAwareParammedMethod(Transaction_AddPage),
    null
  );
}

private object Transaction_AddPage(IDesignerHost theHost, object theParam)
{
  MultiPaneControl aCtl = DesignedControl;

  MultiPanePage aNewPage = (MultiPanePage)theHost.CreateComponent(typeof(MultiPanePage));

  MemberDescriptor aMem_Controls = TypeDescriptor.GetProperties(aCtl)["Controls"];

  RaiseComponentChanging(aMem_Controls);

  aCtl.Controls.Add(aNewPage);
  DesignerSelectedPage = aNewPage;

  RaiseComponentChanged(aMem_Controls, null, null);

  return null;
}

We will apply the same strategy to the Toolbox item, modifying its code accordingly.

Page removal is also performed inside a transaction:

private void Handler_RemovePage(object theSender, EventArgs theEvent)
{
  // validation goes here, skipped

  IDesignerHost aHost = (IDesignerHost)GetService(typeof(IDesignerHost));

  if (aHost == null)
    return;

  DesignerTransactionUtility.DoInTransaction
  (
    aHost,
    "MultiPaneControlRemovePage",
    new TransactionAwareParammedMethod(Transaction_RemovePage),
    null
  );
}

In Transaction_RemovePage, we destroy the page that is currently being designed. Before the page is destroyed, the ComponentRemoving event is fired, and its handler, Handler_ComponentRemoving, is called:

private void Handler_ComponentRemoving(object theSender, ComponentEventArgs theArgs)
{
  // validation goes here, skipped

  IDesignerHost aHost = (IDesignerHost)GetService(typeof(IDesignerHost));

  DesignerTransactionUtility.DoInTransaction
  (
    aHost,
    "MultiPaneControlRemoveComponent",
    new TransactionAwareParammedMethod(Transaction_UpdateSelectedPage)
  );
}

private object Transaction_UpdateSelectedPage(IDesignerHost theHost, object theParam)
{
  MultiPaneControl aCtl = DesignedControl;
  MultiPanePage aPgTemp = mySelectedPage;

  int aCurIndex = aCtl.Controls.IndexOf(mySelectedPage);

  if (mySelectedPage == aCtl.SelectedPage)
  //we also need to update the SelectedPage property
  {
    MemberDescriptor aMember_SelectedPage =
      TypeDescriptor.GetProperties(aCtl)["SelectedPage"];

    RaiseComponentChanging(aMember_SelectedPage);

    if (aCtl.Panes.Count > 1)
    {
      // begin update current page
      if (aCurIndex == aCtl.Panes.Count - 1)            // NOTE: after SelectedPage has
        aCtl.SelectedPage = aCtl.Panes[aCurIndex - 1];  // been updated, mySelectedPage
      else                                              // has also changed
        aCtl.SelectedPage = aCtl.Panes[aCurIndex + 1];
      // end update current page
    }
    else
      aCtl.SelectedPage = null;

    RaiseComponentChanged(aMember_SelectedPage, null, null);
  }
  else
  {
    if (aCtl.Panes.Count > 1)
    {
      if (aCurIndex == aCtl.Panes.Count - 1)
        DesignerSelectedPage = aCtl.Panes[aCurIndex - 1];
      else
        DesignerSelectedPage = aCtl.Panes[aCurIndex + 1];
    }
    else
      DesignerSelectedPage = null;
  }

  return null;
}

For page switching, we will create a handler that displays a choice dialog box. The implementation is trivial, and the dialog looks like this:

Switching pages in the designer

MultiPanePageDesigner

Our MultiPanePageDesigner derives from ScrollableControlDesigner. It handles drag-and-drop and drawing operations while redirecting mouse selection and designer verbs to MultiPaneControlDesigner.

The class has three fields for redirecting mouse selection:

private int  myOrigX = -1;               // store the original position of the
private int  myOrigY = -1;               // mouse cursor

private bool myMouseMovement = false;    // true if mouse movement occurred

The OnMouseDragMove method determines if mouse movement has taken place. OnMouseDragEnd does the rest by either selecting the parent MultiPaneControl or calling its base class method. The latter occurs if the user is, in fact, not intending to make a selection:

protected override void OnMouseDragBegin(int theX, int theY)
{
  myOrigX = theX;
  myOrigY = theY;

  // no call to base.OnMouseDragBegin
}

protected override void OnMouseDragMove(int theX, int theY)
{
  if ( theX > myOrigX + 3 || theX < myOrigX - 3 ||
       theY > myOrigY + 3 || theY < myOrigY - 3 )
  {
    myMouseMovement = true;

    base.OnMouseDragBegin(myOrigX, myOrigY);
    base.OnMouseDragMove(theX, theY);
  }
}

protected override void OnMouseDragEnd(bool theCancel)
{
  bool aProcess = !myMouseMovement && Control.Parent != null;
  if (aProcess)
  {
    ISelectionService aSrv = (ISelectionService)GetService(typeof(ISelectionService));

    if (aSrv != null)
      aSrv.SetSelectedComponents(new Control[] { Control.Parent });
    else
      aProcess = false;
  }

  if (!aProcess)
    base.OnMouseDragEnd(theCancel);

  myMouseMovement = false;
}

The Verbs property is also overridden. The DesignerVerbCollection that it returns is populated from the base class designer and from the designer of the parent MultiPaneControl:

public override DesignerVerbCollection Verbs
{
  get
  {
    // 1. Obtain verbs from the base class
    DesignerVerbCollection aRet = new DesignerVerbCollection();

    foreach ( DesignerVerb aIt in base.Verbs)
      aRet.Add(aIt);

    // 2. Obtain verbs from the parent control's designer
    MultiPaneControlDesigner aDs = GetParentControlDesigner();

    if (aDs != null)
      foreach (DesignerVerb aIt in aDs.Verbs)
        aRet.Add(aIt);

    return aRet;
  }
}

Drag-and-drop operations that are carried over from MultiPaneControlDesigner are also processed in the MultiPanePaneDesigner class. Note that we don't have to do anything special as ScrollableControlDesigner does all the processing for us automatically:

internal void InternalOnDragDrop(DragEventArgs theArgs)
  { OnDragDrop(theArgs); }

internal void InternalOnDragEnter(DragEventArgs theArgs)
  { OnDragEnter(theArgs); }

internal void InternalOnDragLeave(EventArgs theArgs)
  { OnDragLeave(theArgs); }

internal void InternalOnGiveFeedback(GiveFeedbackEventArgs theArgs)
  { OnGiveFeedback(theArgs); }

internal void InternalOnDragOver(DragEventArgs theArgs)
  { OnDragOver(theArgs); }

To render page borders on the form, we will override the OnPaintAdornments method. Our implementation will draw a dashed rectangle, making our pages look similar to those of a Tab Control:

protected override void OnPaintAdornments(PaintEventArgs theArgs)
{
  DrawBorder(theArgs.Graphics);

  base.OnPaintAdornments(theArgs);
}

protected void DrawBorder(Graphics theG)
{
  MultiPanePage aCtl = DesignedControl;

  if (aCtl == null)
    return;
  else if (!aCtl.Visible)
    return;

  Rectangle aRct = aCtl.ClientRectangle;
  aRct.Width--;     // decrement width and height so that the bottom
  aRct.Height--;    // and right lines become visible

  theG.DrawRectangle(BorderPen, aRct);
}

- and we're done! The final version is in the Step3 solution.

A Few Final Remarks

  • All sample code has been tested under Visual Studio .NET 2003, Visual Studio 2005, and Visual Studio 2008 Beta 2.
  • Project files provided are of version 2003. Use the Project Import Wizard to open the project in higher versions of Visual Studio.
  • Run Rebuild Solution before opening sample forms in Design mode.

The supplied source code can be freely distributed and included, in whole or in part, with any third-party software product on the condition that my authorship is properly acknowledged and explicitly stated in the Copyright section.

Comments, suggestions, and bug reports are greatly appreciated. Any ideas for improvement are also welcome.

History

  • 2007-11-25. Initial release
  • 2007-11-30. A few typo corrections have been made
  • 2007-12-08. Due to CodeProject's bugs with page serving, all article files (graphics and ZIP archives) were moved to a mirror site
  • 2009-03-31. Added support for background transparency

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here