You are about to get to know the wizard framework that is a part of the Two-Layered GUI Toolkit. It helps you easily create wizard user interfaces. By wizard, we mean a user interface that lets users navigate through a series of pages with Next and Back buttons in order to gradually gather all data required for a given operation.
As the wizard framework is rather extensive, its types are stored in separate namespaces. Therefore, add the following using
directives to your file (replace the Gtk# one with the appropriate GUI toolkit if you are using something else):
using TwoLayeredGUI.Wizard; using TwoLayeredGUI.Gtk.Wizard;
When displaying a wizard, you always have to supply a so-called settings object. That object is the object whose settings are edited in the wizard. The whole wizard, including all of its pages, is strongly typed to the type of that settings object. This ensures a typesafe access to the properties of the settings object without any casting.
In the following example code, we are going to define a wizard that has the user input some information about an author. Hence, the exemplary settings object stores some information about an author:
public class Author { public string GivenName { get; set; } public string Surname { get; set; } public int YearsActive { get; set; } public string EmailAddress { get; set; } public string PhoneNumber { get; set; } public override string ToString() { return string.Format("{0}, {1} (active for {2} year(s))", Surname, GivenName, YearsActive) + (EmailAddress != null ? "; " + EmailAddress : "") + (PhoneNumber != null ? "; " + PhoneNumber : ""); } }
In addition to the various attributes, the ToString
method has been overridden to return some meaningful information about the author object. We can use this later to verify that the data was correctly saved.
A wizard is defined by a self-contained object of type Wizard<TSettings>
. Therefore, it is advisable to create a separate method that prepares the wizard:
private static Wizard<Author> CreateAuthorWizard() { var wz = new Wizard<Author>(); // insert pages here return wz; }
For now, we will add a few pages that will be displayed in a linear way.
The first page should welcome users and explain what the wizard does. The appropriate page type is a text page, which can either be created by explicitly instantiating the TextPage<TSettings>
class, or by using the AddTextPage<TSettings>
convenience extension method. The text page just requires two pieces of information, a page headline and the textual contents of the page:
wz.Pages.AddTextPage("Welcome!", "This wizard will guide you through the steps required to store some information about an author.");
Note that the method returns the newly added TextPage<TSettings>
instance, in case any other settings need to be changed.
The next two pages will be used to gather some information about the author - first, his or her name. For this purpose, a ValueInputPage<TSettings>
will be used. This page type displays a list of input controls. The input controls are defined the same way as for input boxes.
Beside a page title and short description, and the definition of the input controls, the data has to be transferred between the input controls and the settings object. This is why we also specify two methods that copy the data from the settings object into the input controls (represented by an array of values) and vice-versa:
var namePage = new ValueInputPage<Author>("Name", "Please enter the name of the author.", (controller, values) => { values[0] = controller.Settings.GivenName ?? ""; values[1] = controller.Settings.Surname ?? ""; }, (controller, values) => { controller.Settings.GivenName = values[0] as string; controller.Settings.Surname = values[1] as string; }, new StringValue(new AccessString("Given name(s):", 'g')), new StringValue(new AccessString("Surname(s):", 's'))); wz.Pages.Add(namePage);
As gets evident here, the methods for loading and saving the values receive a controller object that can be used to issue various commands to a wizard while it is active, and to retrieve some information about its state. In this case, it is used to retrieve the current settings object that is being edited.
Note that if you are not comfortable with the many anonymous methods (more to come below) for the constructor, it is perfectly fine to use a constructor with less parameters and use the LoadValuesActions
and the StoreValuesActions
events to transfer the values between your settings object and the page.
The next page, for entering a number of years during which the author was active, is created analogously. Do not forget to add the pages to the wizard after instantiating them!
var yearsActivePage = new ValueInputPage<Author>("Experience", "Please indicate for how many years the author has been active.", (controller, values) => { values[0] = Math.Min(Math.Max(controller.Settings.YearsActive, 1), 200); }, (controller, values) => { controller.Settings.YearsActive = (int)values[0]; }, new Int32Value(new AccessString("Years", 'y')) { MinValue = 1, MaxValue = 200 }); wz.Pages.Add(yearsActivePage);
To be on the safe side, authors can be active for up to 200 years. Always leave some tolerance when restricting numbers. Note that the restriction is not only enforced by the Int32Value
, but also when loading the value from our settings object: That is because our settings object does not contain any validation code; if it did, we could skip the calls to Math.Min
and Math.Max
and just assign the value from the object.
Finally, the wizard should notify the user that all data has been entered. For this purpose, we can add another text page. As the Finish button should be active on this page, an AddTextPage<TSettings>
overload with an allowFinish
parameter will be used (another way to achieve this would be setting the AllowFinish
property of the page to true
):
wz.Pages.AddTextPage("Done", "All required information about the author has been entered.", true);
Now that the wizard has been defined, it needs to be invoked. The WizardDialog<TSettings>
class of the GUI binding you are using is employed for this purpose. This class offers a LaunchWizard
method to display a wizard in a dialog box for a given settings object:
var wz = CreateAuthorWizard(); Author author = new Author(); var dlg = new WizardDialog<Author>(); dlg.Title = "Example Wizard"; if (dlg.LaunchWizard(wz, author)) { MessageBox.Show(author.ToString()); }
Before the wizard is displayed, the title bar text is set to Example Wizard. Also, if the wizard is completed rather than canceled, the contents of the settings object in its final state are shown in a wiki:Help-Tutorial-Direct-MessageBoxes.
The wizard has been completely linear up to now. As the respective members in our settings class are still unused, we will give users a choice on what contact information can be supplied for the author.
We are going to offer three options:
This can be implemented with a ChoicePage<TSettings>
. That page type offers a list of options to the user, one of which has to be selected. The options are defined as BooleanValue
instances from the value input framework. Similarly to the aforementioned ValueInputPage<TSettings>
, two methods that populate the page and store the settings made on the page need to be specified. However, as the only setting on a choice page is the index of the selected option, there is no values
array, but only said int
value to load and store.
var contactTypePage = new ChoicePage<Author>("Contact Information", "How should the author be contacted?", controller => { if (controller.Settings.EmailAddress != null) { return 0; } else if (controller.Settings.PhoneNumber != null) { return 1; } else { return 2; } }, (controller, selection) => { switch (selection) { case 0: controller.Settings.EmailAddress = controller.Settings.EmailAddress ?? ""; controller.Settings.PhoneNumber = null; break; case 1: controller.Settings.PhoneNumber = controller.Settings.PhoneNumber ?? ""; controller.Settings.EmailAddress = null; break; default: controller.Settings.EmailAddress = null; controller.Settings.PhoneNumber = null; break; } }, new BooleanValue(new AccessString("E-Mail", 'e')), new BooleanValue(new AccessString("Phone", 'p')), new BooleanValue(new AccessString("None", 'o'))); wz.Pages.Add(contactTypePage);
In this implementation, the EmailAddress
and PhoneNumber
properties of our Author
class are used. Properties that are not used are set to null
, so at most one of these properties can have a non-null
value. The selection index is zero-based, hence the first option matches index 0.
When we run the wizard like this, the choice page has no visible effect, as there is no way to input either an e-mail address or a phone number. On the other hand, we cannot just put two new wizard pages for e-mail addresses and phone numbers into the wizard because at most one of them must be shown.
The solution is the NextWizards
list. It can contain one or more wizards whose pages are displayed after the user has visited all pages of the current wizard. Like this, partial wizards can be reused and nested to any level.
This list of chained wizards may safely be modified while the wizard is being displayed, so we can insert a wizard with a single page for e-mail address or phone number input, depending on the selection on the choice page. This should happen immediately after changing the selection, so we will use the ChoiceChanged
event:
contactTypePage.ChoiceChanged += (sender, e) => { wz.NextWizards.Clear(); switch (e.Choice) { case 0: wz.NextWizards.Add(CreateEmailWizard()); break; case 1: wz.NextWizards.Add(CreatePhoneWizard()); break; } e.Controller.UpdateButtonStates(); };
There are three things to note in this event handler:
private static Wizard<Author> Create...Wizard()
.The two partial wizards are pretty straightforward - both use values input pages. Note how all input values also receive a validator - unless the user enters a valid value, the Next button will remain disabled.
private static Wizard<Author> CreateEmailWizard() { var page = new ValueInputPage<Author>("E-Mail Address", "Please enter the author's e-mail address.", (controller, values) => { values[0] = controller.Settings.EmailAddress ?? ""; }, (controller, values) => { controller.Settings.EmailAddress = values[0] as string; }, new StringValue(new AccessString("Address:", 'a'), new RegexStringValidator(@"^[^@]+\@[^@]+\.[^@]+$"))); return new Wizard<Author>(page); } private static Wizard<Author> CreatePhoneWizard() { var page = new ValueInputPage<Author>("Phone Number", "Please enter the author's phone number.", (controller, values) => { var match = System.Text.RegularExpressions.Regex.Match(controller.Settings.PhoneNumber, @"^((?:00|\+)[0-9]{1,3})\s*(0[0-9]{1,5})\s*([0-9]{1,10})$"); if (match.Success) { values[0] = match.Groups[1].Value; values[1] = match.Groups[2].Value; values[2] = match.Groups[3].Value; } else { values[0] = ""; values[1] = ""; values[2] = ""; } }, (controller, values) => { controller.Settings.PhoneNumber = (string)values[0] + (string)values[1] + (string)values[2]; }, new StringValue(new AccessString("Country code:", 'o'), new RegexStringValidator(@"^(?:00|\+)[0-9]{1,3}$")), new StringValue(new AccessString("Area prefix:", 'a'), "0", new RegexStringValidator("^0[0-9]{1,5}$")), new StringValue(new AccessString("Number:", 'u'), new RegexStringValidator("^[0-9]{1,10}$"))); return new Wizard<Author>(page); }
When trying the wizard like this, something seems wrong: The final page appears before the wizards for entering the e-mail address or phone number are displayed. That is because the final page is still a page in the list of the outermost wizard, which will be shown before any chained wizards.
Therefore, a pattern has to be used that you will frequently encounter when working with non-linear wizards in the Two-Layered GUI Toolkit: The final page has to be added as another chained wizard to the end of the NextWizards
list.
Remove the following line, as the final page should not be a page in wz
any more:
wz.Pages.AddTextPage("Done", "All required information about the author has been entered.", true);
Instead, add a new chained wizard that contains only one text page. You can either create a new Wizard<Author>
instance yourself and add a text page as described above, or you can use the WizardList<TSettings>.Add
method with the appropriate set of parameters, as shown here:
wz.NextWizards.Add("Done", "All required information about the author has been entered.", true);
Finally, remember that the event handler for the ChoiceChanged
event of our choice page has to be slightly adapted, as the last item in the list of chained wizards (the wizard with the final page) has to remain intact (do not clear the list, but remove anything except for the last item) and at the end of the list (insert additional partial wizards at the beginning of the list):
contactTypePage.ChoiceChanged += (sender, e) => { while (wz.NextWizards.Count > 1) { wz.NextWizards.RemoveAt(0); } switch (e.Choice) { case 0: wz.NextWizards.Insert(0, CreateEmailWizard()); break; case 1: wz.NextWizards.Insert(0, CreatePhoneWizard()); break; } e.Controller.UpdateButtonStates(); };
Try this wizard, it should behave correctly now.
Now, we have talked about settings objects, combining partial wizards and their reusability. But, look at the following Book
class:
public class Book { public Book() { FirstAuthor = new Author(); SecondAuthor = new Author(); } public string Title { get; set; } public string Subtitle { get; set; } public int PageCount { get; set; } public Author FirstAuthor { get; private set; } public Author SecondAuthor { get; private set; } }
You have learned quite a lot about wizards by now, and you have no difficulties defining a Wizard<Book>
to edit instances of that class. Likewise, creating a value input page where users can input the title, subtitle, and the number of pages poses no obstacle to you. You have probably come up with something like this:
private static Wizard<Book> CreateBookWizard() { var wz = new Wizard<Book>(); var page = new ValueInputPage<Book>("Book Information", "Please enter some superficial information about the book.", (controller, values) => { values[0] = controller.Settings.Title ?? ""; values[1] = controller.Settings.Subtitle ?? ""; values[2] = controller.Settings.PageCount; }, (controller, values) => { controller.Settings.Title = values[0] as string; controller.Settings.Subtitle = values[1] as string; controller.Settings.PageCount = (int)values[2]; }, new StringValue(new AccessString("Title:", 't')), new StringValue(new AccessString("Subtitle:", 's')), new Int32Value(new AccessString("Number of pages:", 'u')) { MinValue = 1, MaxValue = 10000 }); wz.Pages.Add(page); // more wizard contents? return wz; }
But what about the authors? Wouldn't it be pretty if we could reuse our Wizard<Author>
that we have defined above? Let's give it a try:
wz.NextWizards.Add(CreateAuthorWizard());
Uh ... compiler errors CS1503 and CS1502. Somehow, this was to be expected: wz
and its chained wizards are of type Wizard<Book>
, whereas CreateAuthorWizard()
creates a Wizard<Author>
. And moreover, how would the author wizard know which one of the two authors in the edited Book
instance to modify?
By looking at WizardList<TSettings>
, the type of NextWizards
, it gets evident that the wizard list is actually a list of something called IRunnableWizard<TSettings>
. Beside Wizard<TSettings>
itself, that interface is also implemented by the WizardAdapter<TSettings, TSpecialSettings>
class. Instances of this class can be wrapped around a wizard with a particular special settings object type, while the wizard adapter provides a wizard interface with another settings object type to the outside world. Furthermore, the wizard adapter knows how to obtain the special settings object from the other settings object.
WizardList<TSettings>
already provides a courtesy method that requests the relevant information via its parameters and internally creates a wizard adapter:
wz.NextWizards.Add(book => book.FirstAuthor, CreateAuthorWizard()); wz.NextWizards.Add(book => book.SecondAuthor, CreateAuthorWizard());
As you see, the methods used for extracting the special settings object are quite trivial in this case - they simply return the Author
instance to edit.
Finally, add a new final page for the Book
wizard:
wz.NextWizards.Add("Book Information Complete", "Thank you for entering all the information on the book.", true);
The wizard still requires a little bit of tweaking; the welcome and final pages of the author wizard could be skipped, and if they are not, the final pages should at least not allow terminating the wizard with the Finish button. Other than that, all that remains are a few final notes ...
The following enumeration contains a few tidbits of further information about the wizard framework:
AllowBack
property to false
.ProgressPage<TSettings>
type. It works in a similar fashion as progress dialogs.SummaryPage<TSettings>
class.WizardPage<TSettings>
or from CustomTypeWizardPage<TSettings>
for the page definition, and implement the IWizardGuiFactory<TSettings, TGui>
interface for the GUI toolkit you are targetting.
Wiki: Help-Patterns-Wizards
Wiki: Help-Tutorial-Direct-FileDialogs
Wiki: Help-Tutorial-Direct-InputBoxes
Wiki: Help-Tutorial-Direct-ProgressDialogs
Wiki: Help-Tutorial-Direct