A slightly better WinForms wizard, and slightly more work.
See Also:
Article 1 in this series: Article 1 in a series about WinForms Wizards: The fastest Wizard in the West.
Article 2 in this series: You're looking at it.
Article 3 in this series: Adding Named Panel Navigation to the WizardController
Article 4 in this series: Providing design time support for the Wizard Framework
Article 5 in this series: Adding a design time dialog and creating a VS project sample
Abstract:
I commented that there were some optimizations we could make to the basic wizard described in my previous article Article 1 in a series about WinForms Wizards: The fastest Wizard in the West. The primary items of interest were getting rid of the custom message pump, which shouldn't be too hard, and wondering if we could add in some validation logic. I'm going to take an intermediate step before I get to the validation logic and clean up the look of the UI using an extra layer of abstraction from the original dialog only design.
TOC:
- Deriving from ApplicationContext and making it work.
- Extending the dialog interfaces and adding panel interfaces.
- Modifying the navigation logic to use panels instead of dialogs.
- Coding our dialogs, panels, and final test.
- Conclusion
1. Deriving from ApplicationContext and making it work
The first bit of clean-up is getting rid of the message pump in the old article. Since the WizardController is being run as the application instead of from an application, we need to make sure all of the message pump code is running. To do this previously, we made a bunch of DoEvents calls within a loop. This time, we'll let the Run method of the Application class control our pump, and we'll control our lifetime. The two required steps are deriving from ApplicationContext, and then finding out where in the hack to call ExitThread from. ExitThread will cause a thread exception that tells the message pump to shut-down.
public class WizardController : ApplicationContext {
public void Wizard_NavigateFinish(object sender, EventArgs e) {
ExitThread();
}
}
Our example works, because the ApplicationContext has two constructors. The first, an empty constructor, assumes that ExitThread will be called by our context in order to tell the pump when it needs to shut-down. The second associates a primary form with the context. Whenever the primary form exits, it causes a call to ExitThread, and the pump is again shut down. Since we want the wizard controller to start up and shut-down without killing our dialog or requiring our forms to be closed out, we'll need to handle the life-time ourselves. Whenever our wizard reaches a completed state and the user clicks the Finish button, we'll go ahead and shut the pump down so the rest of our post wizard logic will get run.
2. Extending the dialog interfaces and adding panel interfaces.
The application needs to support a single dialog (for consistency, global navigation, and branding), while supporting multiple panels. This is a big change, since we now have two types of wizard resources to work with. The dialog stays the same for the most part, since it is still responsible for hosting the navigation buttons. We keep the old properties NavigatePrevious, NavigateNext, and NavigateFinish that return pointers to the controls used for navigation. To extend the feature set, we also need a UIRoot, or a place where the panels are going to get stuffed. In our implementation, the WizardController will have full run on the control returned by this property, so make sure it is an empty container control of some sort that will actually allow the panels to display and render properly. We also need to add a Display property that enables us to show and hide the dialog. Our implementation will assume that we only see the dialog once as a Form, and each time thereafter we'll only hold an IWizardDialog interface version of it. We could always cast the values out, but I'm interested in finding ways to remove our dependency on using a Form at all.
public interface IWizardDialog {
Control NavigatePrevious { get; }
Control NavigateNext { get; }
Control NavigateFinish { get; }
Control UIRoot { get; }
bool Display { get; set; }
}
The interface for the new panels will be really simple. Each panel gets to decide which of the navigation controls are activated and that is pretty much it. I'll either extend this interface later to allow more features, or create a new interface to attach to the panels. Most likely article 3 will contain a brand new interface that covers the concepts of validation, so for now IWizardPanel only has bool properties for ShowNavigatePrevious, ShowNavigateNext, and ShowNavigateFinish.
public interface IWizardPanel {
bool ShowNavigatePrevious { get; }
bool ShowNavigateNext { get; }
bool ShowNavigateFinish { get; }
}
3. Modifying the navigation logic to use panels instead of dialogs
The interfaces give us enough to completely rewrite the WizardController now that we have a contract for the dialogs to follow. The new controller needs to understand only a single dialog, but also needs new logic to handle multiple panels. Handling a single dialog is the first change we'll make since we can't even display the wizard without the dialog. Once you create the WizardController, you'll make a call to SetDialog with the dialog you'll be using for your UI. Since the dialog is set seperately and can be replaced you can implement skinning by simply swapping out your wizard dialog before calling StartWizard. The method is similar to the code used previously for AddDialog, except now we centralize all of the exception cases, provide new exception cases, and hook a new event. The Closing event is important for us, since we don't want users clicking the big X button and killing our form. If they do the Application.Run will just sit there and hang. Since this is a wizard, we tell the user to stop clicking the button and finish before we beat them silly.
public void SetDialog(Form dialog) {
IWizardDialog wDialog = dialog as IWizardDialog;
if ( wDialog != null ) {
if ( wDialog.NavigatePrevious == null ) {
throw new Exception("Wizard dialogs must have a Previous Button");
}
if ( wDialog.NavigateNext == null ) {
throw new Exception("Wizard dialogs must have a Next Button");
}
if ( wDialog.NavigateFinish == null ) {
throw new Exception("Wizard dialogs must have a Finish Button");
}
if ( wDialog.UIRoot == null ) {
throw new Exception("Wizard dialogs must have a non null UI Root");
}
} else {
throw new Exception("Wizard dialogs must support IWizardDialog");
}
wizardDialog = wDialog;
wizardDialog.NavigatePrevious.Click += new EventHandler(Wizard_NavigatePrevious);
wizardDialog.NavigateNext.Click += new EventHandler(Wizard_NavigateNext);
wizardDialog.NavigateFinish.Click += new EventHandler(Wizard_NavigateFinish);
// Last time we get a chance on the Form object
dialog.Closing += new CancelEventHandler(Wizard_Closing);
}
Adding panels is even easier. Since we don't have any event hook-up and the panels can't really mess the wizard process up, we just make sure they sport the interface, and then add them to our collection.
public void AddPanel(Panel pnl) {
IWizardPanel vPanel = pnl as IWizardPanel;
if ( vPanel == null ) {
throw new Exception("Wizard panels must support IWizardPanel");
}
wizardPanels.Add(vPanel);
}
Using panels is pretty much the same as using dialogs. The only difference is rather than using Show/Hide, we simply add or remove the panel from the UIRoot.Controls collection. Below you'll find code for Wizard_Closing, with a message box, and some code to cancel out from closing down our form. Wizard_NavigateFinish clears the wizard display, completes the wizard, and throws a call to ExitThread for the message pump. Both Wizard_NavigatePrevious and Wizard_NavigateNext are the same. If a new panel is found moving either direction, good things simply happen.
public void Wizard_Closing(object sender, CancelEventArgs e) {
MessageBox.Show("You must complete the wizard in order to exit.");
e.Cancel = true;
}
public void Wizard_NavigateFinish(object sender, EventArgs e) {
// this could be fired anywhere in case you have an opt out
// early button on any of your forms.
wizardDialog.UIRoot.Controls.Clear();
wizardDialog.Display = false;
complete = true;
ExitThread();
}
public void Wizard_NavigateNext(object sender, EventArgs e) {
wizardIndex++;
if ( wizardIndex == wizardPanels.Count ) {
Wizard_NavigateFinish(sender, e); // This shouldn't happen if your dialogs are correct
return;
}
Panel newPanel = wizardPanels[wizardIndex] as Panel;
if ( newPanel != null ) {
InitPanel(newPanel);
}
}
public void Wizard_NavigatePrevious(object sender, EventArgs e) {
if ( wizardIndex > 0 ) {
Panel newPanel = wizardPanels[wizardIndex-1] as Panel;
if ( newPanel != null ) {
InitPanel(newPanel);
wizardIndex--;
}
}
}
All of the real code logic now goes into the InitPanel method. This method needs to ensure proper display of the panel, so we set the Dock property to DockStyle.Fill. This expands our panel as needed to fill the space of our wizard. Each of the IWizardDialog controls gets enabled or disabled based on the IWizardPanel's preferences. Finally the UIRoot.Controls collection is cleared and our new panel is added.
private void InitPanel(Panel wizardPanel) {
wizardPanel.Dock = DockStyle.Fill;
wizardDialog.NavigatePrevious.Enabled = ((IWizardPanel) wizardPanel).ShowNavigatePrevious;
wizardDialog.NavigateNext.Enabled = ((IWizardPanel) wizardPanel).ShowNavigateNext;
wizardDialog.NavigateFinish.Enabled = ((IWizardPanel) wizardPanel).ShowNavigateFinish;
wizardDialog.UIRoot.Controls.Clear();
wizardDialog.UIRoot.Controls.Add(wizardPanel);
}
4. Coding our dialogs, panels, and final test
Big section and lots of stuff right? Wrong. Almost everything is the same. Just like we did for the first Wizard article, we are going to implement a series of generic panels that allow us to quickly and easily investigate how the wizard will work. The WizardDialog is pretty much the same and shows you the minimum requirement of getting the sample running.
public class WizardPanel : Panel, IWizardPanel {
private bool prev;
private bool next;
private bool finish;
private Label description = new Label();
public WizardPanel(string description, bool prev, bool next, bool finish) {
this.prev = prev;
this.next = next;
this.finish = finish;
this.description.Text = description;
InitializeComponent();
}
private void InitializeComponent() {
this.description.Dock = DockStyle.Fill;
this.Controls.Add(this.description);
}
public bool ShowNavigatePrevious { get { return this.prev; } }
public bool ShowNavigateNext { get { return this.next; } }
public bool ShowNavigateFinish { get { return this.finish; } }
}
We'll talk a bit about the code for WizardTestDialog, because there is some weird layout that I'm doing. I'm adding a series of panels that each holds different portions of the UI. The bottom panel is where the navigation logic is going. We dock that to the bottom of the form, and add each of the navigation buttons using custom layout logic to make them look decent. They also get anchored to the panel so that when you resize, good things happen. If you size too small bad things happen, but this is a sample, not a production level application. The top panel becomes our UI root, and so we fill the rest of the form with it. For truly professional wizards you'll want to add graphics panels, title panels, and all sorts of other gunk that 90% of the population finds amazing and pleasing and the other 10% couldn't care less (can you guess I'm in the other 10% that just wants the damn dialog over with ;-)
public class WizardTestDialog : Form, IWizardDialog {
private Panel topPanel = new Panel();
private Panel bottomPanel = new Panel();
private Button previousButton;
private Button nextButton;
private Button finishButton;
private string title;
public WizardTestDialog(string title) {
this.title = title;
InitializeComponent();
}
public Control NavigatePrevious {
get {
return previousButton;
}
}
public Control NavigateNext {
get {
return nextButton;
}
}
public Control NavigateFinish {
get {
return finishButton;
}
}
public Control UIRoot {
get {
return topPanel;
}
}
public bool Display {
get {
return this.Visible;
}
set {
this.Visible = value;
}
}
private void InitializeComponent() {
// Create our large containers
// Top for wizard
// Bottom for navigation
this.bottomPanel.Height = 30;
this.bottomPanel.Dock = DockStyle.Bottom;
this.topPanel.Dock = DockStyle.Fill;
this.Text = title;
this.Controls.AddRange(new Control[] { topPanel, bottomPanel });
this.finishButton = new Button();
this.finishButton.Text = "Finish";
this.finishButton.Left = this.bottomPanel.Width - (this.finishButton.Width + 10);
this.finishButton.Anchor = AnchorStyles.Right;
this.nextButton = new Button();
this.nextButton.Text = "Next";
this.nextButton.Left = this.finishButton.Left - (this.nextButton.Width + 5);
this.nextButton.Anchor = AnchorStyles.Right;
this.previousButton = new Button();
this.previousButton.Text = "Previous";
this.previousButton.Left = this.nextButton.Left - (this.previousButton.Width + 5);
this.previousButton.Anchor = AnchorStyles.Right;
this.bottomPanel.Controls.AddRange(new Control[] { previousButton, nextButton, finishButton });
}
}
All we need now is a test. Notice we are using Application.Run now instead of a custom loop, and that we are setting our wizard dialog using SetDialog, and the panels using AddPanel. This is the only change. Since our dialogs and panels are still generic they support all of their logic in the constructors making for compact looking code.
public class WizardTester {
[STAThread()]
private static void Main(string[] args) {
WizardController controller = new WizardController();
controller.SetDialog(new WizardTestDialog("What do you want?"));
controller.AddPanel(new WizardPanel("foo 1", false, true, false));
controller.AddPanel(new WizardPanel("foo 2", true, true, false));
controller.AddPanel(new WizardPanel("foo 3", true, true, false));
controller.AddPanel(new WizardPanel("foo 4", true, true, true));
controller.AddPanel(new WizardPanel("foo 5", true, false, true));
controller.StartWizard();
Application.Run(controller);
}
}
5. Conclusion
I am really looking forward to adding some validation logic to this sample. I feel that the panel concept really makes this more viable for use as a Wizard framework for sure, so hopefully more people use this guy and give me some feedback. I'm still debating on whether or not to implement a cancel button, and maybe changing the bool Complete property into an enumeration specifying the current state of the wizard. I also think that some features to allow panels to skip around and maybe specify their index withing the navigation process might be nice. Well, let me know what you think.
Full Source (C#): Code-Only: Winforms Wizard Series Article 2 (C#)
Full Source (VB .NET): Code-Only: Winforms Wizard Series Article 2 (VB .NET)