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

A Plug-in Wizard Framework

0.00/5 (No votes)
21 May 2008 3  
A wizard framework that supports plug-ins for the wizard pages.

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()
  {
    // Get the form that will contain the area
    // in which the controls from the plugins 
    // will be placed.
    WizardContainerForm form=new WizardContainerForm();

    // Set up the info our wizard "controller" needs.
    ContainerInfo info = new ContainerInfo(form.WizardPluginArea, form.WizardBackButton, 
         form.WizardNextButton, form.WizardFinishButton, form.WizardCancelButton, 
         form.WizardHelpButton);

    // Create the wizard controller.
    WizardForm wf = new WizardForm(info);

    // Tell the controller about the plugin modules it will be using.
    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");

    // Do final initialization.
    wf.Initialize();

    // Start the wizard.
    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
  {
    /// <summary>
    /// The plugin should return true if the current wizard page data is valid.
    /// </summary>
    bool IsValid { get; }

    /// <summary>
    /// The plugin should return true if there is help available.
    /// </summary>
    bool HasHelp { get; }

    /// <summary>
    /// The plugin can implement this method if it needs to do special processing
    /// before the wizard proceeds to the next page.
    /// </summary>
    void OnNext();

    /// <summary>
    /// The plugin can implement this method to display help.
    /// </summary>
    void OnHelp();

    /// <summary>
    /// The plugin should return the controls that the wizard will place in the
    /// container area.
    /// </summary>
    /// <returns></returns>
    List<Control> GetControls();

    /// <summary>
    /// The plugin needs to implement this event container so that the wizard can
    /// be notified of state changes, which the plugin will call itself.
    /// </summary>
    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
{
  /// <summary>
  /// This abstract class defines common fields, properties, and certain
  /// default behavior that each plugin can leverage.
  /// </summary>
  public abstract class WizardBase : IPlugin
  {
    /// <summary>
    /// The event that the plugin can use to notify the wizard of a state change.
    /// </summary>
    public event EventHandler UpdateStateEvent;

    /// <summary>
    /// The control list is preserved so that the control's state is maintained
    /// as the user navigates backwards and forwards through the wizard.
    /// </summary>
    protected List<Control> ctrlList;
    protected Form form;

    /// <summary>
    /// True if the plugin's data is validated and the user can proceed with
    /// the next wizard page. True is the default.
    /// </summary>
    public virtual bool IsValid
    {
      get { return true; }
    }

    /// <summary>
    /// True if the plugin is going to provide help. The default is false.
    /// </summary>
    public virtual bool HasHelp
    {
      get { return false; }
    }

    /// <summary>
    /// Constructor.
    /// </summary>
    public WizardBase()
    {
      ctrlList = new List<Control>();
    }

    /// <summary>
    /// The plugin can override this method if it needs to do
    /// something before the wizard proceeds to the next page.
    /// </summary>
    public virtual void OnNext()
    {
      // Do nothing.
    }

    /// <summary>
    /// The plugin can override this method if it wants to display 
    /// some help.
    /// </summary>
    public virtual void OnHelp()
    {
      // Do nothing.
    }

    /// <summary>
    /// Returns the controls from the form that the plugin assigned
    /// in the class. The plugin can override this method to return
    /// a custom control list.
    /// </summary>
    /// <returns></returns>
    public virtual List<Control> GetControls()
    {
      // If this is the first time we're calling this method,
      // load the controls from the plugin form.
      if (ctrlList.Count == 0)
      {
        // Once loaded, we reuse the same control instances
        // which as the advantage of preserving state if the
        // user goes back to a previous page (and forward again.)
        GetFormControls();
      }

      // Otherwise, return the control list that we acquired from
      // the form. 
      return ctrlList;
    }

    /// <summary>
    /// Iterates through the form's top level controls to construct
    /// a list of form controls.
    /// </summary>
    protected virtual void GetFormControls()
    {
      foreach (Control c in form.Controls)
      {
        ctrlList.Add(c);
      }
    }

    /// <summary>
    /// The plugin can call this method to raise the UpdateStateEvent,
    /// which informs the wizard that the button states need to be updated.
    /// </summary>
    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;

      // Notice this--how we get the controls from the parent container,
      // not our starting form!
      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.

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