Introduction
I need to add some wizard capability to an application that I'm developing. One of the things I noted about the requirements is that there are going to be potentially many wizards and that there will be pages in common between each wizard. Another requirement is to be able to chain each wizard "page" to create a complete wizard and then process all the user selections at the end.
I decided to put together a prototype wizard that supported plug-ins for each page. I also wanted to achieve maximum flexibility with the least amount of work, so that I could take the prototype and then tailor it to my specific application requirements (heavily declarative). The current prototype:
- Allows you to create any wizard form in the Visual Studio form designer, the only requirement is that it has a container for each page of the wizard. The container can be any container control, such as a
Panel
.
- Allows you to create the wizard pages in the Visual Studio form designer. There are no restrictions--you can use third party controls, etc.
- The whole framework should be very easy to modify to support a WPF-based wizard.
- The wizard framework manages:
- Loading the assemblies that contain the wizard pages.
- Instantiating the specified classes.
- Managing back, next, cancel, and finish states.
- Provides a callback mechanism that the plug-in can use to notify the framework that a state change has happened.
- Allows the plug-in to:
- implement functionality before the page is exited,
- inform the framework that the data on the page is validated,
- implement its own help.
- The state of each page is preserved, so if the user clicks on "back", his/her previous selections are there (same with next).
What the framework does not do is:
- It does not provide data management for the data in each page.
- It does not provide a mechanism for modifying the wizard workflow.
- It does not adjust for different page sizes (for example, it might want to set itself to the maximum extents of the plug-in).
This is functionality that is potentially application specific. I'll update this article if I come up with some good general solutions for these two omissions.
I had also hoped to put the assemblies into a separate AppDomain. Unfortunately, this is not possible because the controls that a plug-in define are copied over to the application's wizard form. The cross-boundary management of objects (the controls) that are not set up for marshalling ended up beyond the scope of what I wanted to work on. See the section "What Else" for some alternatives.
Setup
To use the wizard, create your own wizard wrapping form. For example, I created this form:
Note: Underneath the "Finish" button is the "Next" button.
So, the setup for a wizard requires:
- Specify "
using Clifton.Wizard
" (rename the namespace if you wish).
- Instantiating the container form.
- Creating a
ContainerInfo
class in which you tell the wizard framework about the button instances and page container instance.
- Adding the plug-in assemblies.
- Starting the wizard framework, specifying the container form.
The following code illustrates this process:
static class Program
{
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
InitializeWizard();
}
static void InitializeWizard()
{
WizardContainerForm form=new WizardContainerForm();
ContainerInfo info = new ContainerInfo(form.WizardPluginArea, form.WizardBackButton,
form.WizardNextButton, form.WizardFinishButton, form.WizardCancelButton,
form.WizardHelpButton);
WizardForm wf = new WizardForm(info);
wf.AddAssembly(Path.GetFullPath("..\\..\\Welcome\\bin\\debug\\Welcome.dll"),
"Welcome.WizardPlugin");
wf.AddAssembly(Path.GetFullPath("..\\..\\Ingredients\\bin\\debug\\Ingredients.dll"),
"Ingredients.WizardPlugin");
wf.AddAssembly(Path.GetFullPath("..\\..\\PrepareDough\\bin\\debug\\PrepareDough.dll"),
"PrepareDough.WizardPlugin");
wf.AddAssembly(Path.GetFullPath("..\\..\\RollDough\\bin\\debug\\RollDough.dll"),
"RollDough.WizardPlugin");
wf.AddAssembly(Path.GetFullPath("..\\..\\Bake\\bin\\debug\\Bake.dll"),
"Bake.WizardPlugin");
wf.Initialize();
wf.Start(form);
}
}
The Plug-in Interface
Each plug-in must implement the IPlugin
interface. For convenience, a base class (described next) is provided that defines the default behavior and helps manage the control state of the plug-in.
using System;
using System.Collections.Generic;
using System.Windows.Forms;
namespace Clifton.Wizard.Interfaces
{
public interface IPlugin
{
bool IsValid { get; }
bool HasHelp { get; }
void OnNext();
void OnHelp();
List<Control> GetControls();
event EventHandler UpdateStateEvent;
}
}
The WizardBase Class
As mentioned, the WizardBase
class provides some default implementations for the IPlugin
interface, as should be self-evident by reading the comments.
using System;
using System.Collections.Generic;
using System.Windows.Forms;
namespace Clifton.Wizard.Interfaces
{
public abstract class WizardBase : IPlugin
{
public event EventHandler UpdateStateEvent;
protected List<Control> ctrlList;
protected Form form;
public virtual bool IsValid
{
get { return true; }
}
public virtual bool HasHelp
{
get { return false; }
}
public WizardBase()
{
ctrlList = new List<Control>();
}
public virtual void OnNext()
{
}
public virtual void OnHelp()
{
}
public virtual List<Control> GetControls()
{
if (ctrlList.Count == 0)
{
GetFormControls();
}
return ctrlList;
}
protected virtual void GetFormControls()
{
foreach (Control c in form.Controls)
{
ctrlList.Add(c);
}
}
protected void RaiseUpdateState()
{
if (UpdateStateEvent != null)
{
UpdateStateEvent(this, EventArgs.Empty);
}
}
}
}
What Does a Plug-in Look Like?
Let's take a look at two plug-ins: the welcome page and the ingredients page. Remember that each plug-in is a separate assembly.
The Welcome Page
The welcome page can be found in the Welcome project. It defines a form that looks like this:
It also defines a single WizardPlugin
class that relies heavily on the base class default behavior:
public class WizardPlugin : WizardBase
{
protected WelcomeForm wf;
public WizardPlugin()
{
wf = new WelcomeForm();
base.form = wf;
}
}
Note how the base class' form
field is being initialized. This is done to take advantage of the default functionality that the base class implements for managing the plug-in controls.
As mentioned above, this plug-in is added to the wizard framework by calling:
wf.AddAssembly(Path.GetFullPath("..\\..\\Welcome\\bin\\debug\\Welcome.dll"),
"Welcome.WizardPlugin");
Note how the plug-in type name Welcome.WizardPlugin
correlates to the namespace and the class name in the code above. Obviously, the assembly name corresponds to the project name. The wizard framework, therefore, displays:
The Ingredients Page
This is a little more interesting because this page requires validation before the "Next" button is enabled.
Sour cream? I've never heard of putting sour cream in gingerbread cookies!
First, the partial IngredientsForm
class handles the events for each checkbox and the Select All button, and provides an event which our WizardPlugin
class can hook.
using System;
using System.Windows.Forms;
namespace Ingredients
{
public partial class IngredientsForm : Form
{
public event EventHandler HaveIngredientsEvent;
protected int haveIngredients = 0;
public int HaveIngredients
{
get { return haveIngredients; }
set
{
if (value != haveIngredients)
{
haveIngredients = value;
RaiseHaveIngredientsChanged();
}
}
}
public IngredientsForm()
{
InitializeComponent();
}
private void OnCheckedChanged(object sender, EventArgs e)
{
CheckBox cb = (CheckBox)sender;
if (cb.Checked)
{
++HaveIngredients;
}
else
{
--HaveIngredients;
}
}
protected void RaiseHaveIngredientsChanged()
{
if (HaveIngredientsEvent != null)
{
HaveIngredientsEvent(this, EventArgs.Empty);
}
}
private void OnSelectAll(object sender, EventArgs e)
{
Button btn = (Button)sender;
foreach (Control ctrl in btn.Parent.Controls)
{
if (ctrl is CheckBox)
{
((CheckBox)ctrl).Checked = true;
}
}
}
}
}
There are a few things to note here:
- The logic that increments or decrements the ingredient count.
- The event that fires whenever the ingredient count is changed.
- The way the
OnSelectAll
event handler sets the check state.
The last point is the most critical. The form on which the controls were defined no longer has those controls! They now belong to the container panel defined in the container wizard form. So, I am taking advantage of the fact that the button is a child of this container, getting the button's parent, and then setting the check state of all CheckBox
controls on the container. Yes, I could also have done something like:
ckIngr1.Checked=true;
ckIngr2.Checked=true;
Whatever.
The WizardPlugin
class (you can name the class anything you want, I just used this class name for all the wizard pages in the demo) for this page now raises the state change event that the wizard framework uses to be notified of a state change. It also implements the IsValid
property.
using System;
using System.Collections.Generic;
using System.Windows.Forms;
using Clifton.Wizard.Interfaces;
namespace Ingredients
{
public class WizardPlugin : WizardBase
{
protected IngredientsForm wf;
public override bool IsValid
{
get { return wf.HaveIngredients==11; }
}
public WizardPlugin()
{
wf = new IngredientsForm();
base.form = wf;
wf.HaveIngredientsEvent += new EventHandler(OnHaveIngredientsEvent);
}
protected void OnHaveIngredientsEvent(object sender, EventArgs e)
{
RaiseUpdateState();
}
}
}
The RaiseUpdateState
method is implemented by the base class (see above).
The Bake Page
As a final page (I'm not going to walk through the Prepare Dough and Roll Dough pages, you can review the code for those yourself), I'll illustrate the Bake page.
For this wizard page, I've implemented a progress bar (the baking time, relativistic). When the baker clicks on Start, the timer begins, and the Finish button does not become enabled until the timer completes. And lastly, when the baker clicks on "Finish", he is instructed as to what to do with his cookies.
The page's form defines the controls and the Start button's event handler. Note the use of the DoneEvent
.
using System;
using System.Windows.Forms;
namespace Bake
{
public partial class BakeForm : Form
{
public event EventHandler DoneEvent;
protected bool done;
protected Timer timer;
public bool Done
{
get { return done; }
set
{
if (done != value)
{
done = value;
RaiseDoneEvent();
}
}
}
public BakeForm()
{
InitializeComponent();
timer = new Timer();
timer.Tick += new EventHandler(OnTimerTick);
}
protected void OnStart(object sender, EventArgs e)
{
Done = false;
timer.Interval = 500;
timer.Start();
}
protected void OnTimerTick(object sender, EventArgs e)
{
pbBaking.Increment(1);
if (pbBaking.Value == pbBaking.Maximum)
{
timer.Stop();
Done = true;
}
}
protected void RaiseDoneEvent()
{
if (DoneEvent != null)
{
DoneEvent(this, EventArgs.Empty);
}
}
}
}
And our WizardPlugin
class:
using System;
using System.Collections.Generic;
using System.Windows.Forms;
using Clifton.Wizard.Interfaces;
namespace Bake
{
public class WizardPlugin : WizardBase
{
protected BakeForm wf;
public override bool IsValid
{
get { return wf.Done; }
}
public WizardPlugin()
{
wf = new BakeForm();
base.form = wf;
wf.DoneEvent += new EventHandler(OnDoneEvent);
}
public override void OnNext()
{
MessageBox.Show("EAT!!!");
}
protected void OnDoneEvent(object sender, EventArgs e)
{
RaiseUpdateState();
}
}
}
Notice the event callback when the timer raises Done
, and notice the OnNext
method override which is called when the baker clicks on "Finish."
Internals
There, honestly, isn't that much of interest under the hood. Probably, the most interesting thing is the SetState
method in the wizard "controller" class:
protected void SetState()
{
switch (manager.State)
{
case WizardState.Uninitialized:
throw new ApplicationException("The plugin manager has not been" +
" initialized with any plugins.");
case WizardState.Start:
SetButtonState(info.BackButton, false, true);
SetButtonState(info.NextButton, manager.IsValid, true);
SetButtonState(info.FinishButton, false, false);
SetButtonState(info.CancelButton, true, true);
break;
case WizardState.Middle:
SetButtonState(info.BackButton, true, true);
SetButtonState(info.NextButton, manager.IsValid, true);
SetButtonState(info.FinishButton, false, false);
SetButtonState(info.CancelButton, true, true);
break;
case WizardState.End:
SetButtonState(info.BackButton, !manager.IsValid, !manager.IsValid);
SetButtonState(info.NextButton, false, false);
SetButtonState(info.FinishButton, manager.IsValid, true);
SetButtonState(info.CancelButton, !manager.IsValid, !manager.IsValid);
break;
}
}
protected void SetButtonState(Button btn, bool isEnabled, bool isVisible)
{
if (btn != null)
{
btn.Enabled = isEnabled;
btn.Visible = isVisible;
}
}
Not exactly rocket science. Since you may not want all these buttons in your wizard, you are allowed to specify null
for buttons that aren't desired, and therefore the SetButtonState
method checks to make sure this button really exists.
The "next" and "back" handlers are simple enough as well:
protected void OnBack(object sender, EventArgs e)
{
manager.Previous();
SetState();
LoadUI();
}
protected void OnNext(object sender, EventArgs e)
{
manager.Next();
SetState();
LoadUI();
}
So, I think that's taken up enough space describing what's under the hood. The code is commented so it should be easy to change the framework as needed.
What Else
I'm not particularly enamored with Windows Forms--instead, I would like to use MyXaml and declarative markup to instantiate the wizard pages. I will be extending the wizard to support this, and if anyone is interested in this implementation, post a comment, and I'll most likely put together a whole separate article on declarative-based wizard pages. One thing I'd like to investigate is the advantage that a declarative-based solution gives me in creating an AppDomain, so that when the wizard is finished, I can unload all the plug-ins used in the wizard. It would also be interesting to see how this works with WPF.
Enjoy!
Revision History
- 5/23/2008: Update zip file to include the missing Clifton.Wizard and Clifton.Wizard.Interfaces projects.