|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Announcements
Want a new Job?
Chapters
Services
Feature Zones
|
IntroductionHave 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.
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 of 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:
Properties and methods are named using the Camel case notation (each word is capitalized): DoActionOne();
PropertyTwo = 1;
Step 1. Creating the ControlOur 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 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 public MultiPaneControl()
{
ControlAdded += new ControlEventHandler(Handler_ControlAdded);
}
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 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:
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 BasicsToolbox ItemIn this step, we will develop a Toolbox item for our 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(typeof(MultiPaneControlToolboxItem))]
public class MultiPaneControl : Control
{
...
}
The code above instructs Visual Studio to create an instance of [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 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 be displayed in the Toolbox (all versions of Visual Studio) and in the Document Outline window (VS 2005 or higher).
To preclude independent creation of [ToolboxItem(false)] // do not place in the Toolbox
public class MultiPanePage : Panel
{
...
}
UI Type EditorNext, we are going to implement an editor for the The illustration below shows a form with two
To accomplish this, we need to create a class that derives, either directly or indirectly, from 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(
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 [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 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:
Addressing the remaining problems involves creation of control designers for both the The steps to provide design-time support to the
We can derive our Designer class from several classes featured by the .NET Framework that implement the
Being that the sole purpose of the MultiPaneControlDesignerThe 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 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 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 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:
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 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 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 The handler for Finally, the handler for Our designer will override a few more methods. For example, we want to prevent anything other than 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 protected override void OnDragDrop(DragEventArgs theDragEvents)
{
MultiPanePageDesigner aDsgn_Sel = GetSelectedPageDesigner();
if (aDsgn_Sel != null)
aDsgn_Sel.InternalOnDragDrop(theDragEvents);
}
The In the To provide a means of synchronizing both properties, we will introduce two more events in // 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 // ...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;
}
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 PagesThis 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
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 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 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 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 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 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:
MultiPanePageDesignerOur 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 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 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 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 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
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
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||