Monday, November 10, 2008

WPF Wizard Control - Part I

Writing a custom wizard control (or simply a control of any kind) in many widely used GUI toolkits is usually a challenging task. But it turns out that doing such a control in WPF is rather easy. In this post, I'm going to explain how to create stylable, simple wizard that looks like this:

I recommend briefly examining attached source code before reading the post because the code is not short enough to be pasted on a blog. Still reading "pure" text without the code is far from being nice :)

As you probably know, WPF defines so called look less controls. This means the look and feel of the user controls is completely separated from its behavior. So firstly, I'll go through the wizard's behavior, and at the end I'll explain wizard's style in a few sentences. For now let's only assume that the wizard has navigation buttons such as Next, Previous, Finish, etc., and three places for content: wizard's header, left (side) header and of course a place for displaying main content. Note that I'm not defining yet where these pieces are going to be displayed, for now I only assume they exist somewhere.

The first decision I had to take was the class I needed to inherit from. The options were UserControl or a Control. Because I wanted to give the wizard some styling capabilities (via ComponentResourceKey) and also I wanted it to look more "professional" I decided to inherit from a Control class. There's yet another factor that actually convinced me no to using UserControl. UserControl directly inherits from a ContentControl, and the wizard itself does not have content of any kind! Wizard's content is provided by means of wizard pages and the wizard should display one page at a time. Precisely, wizard will "know" which page it should display (i.e. which page is the current one) and the wizard's template will contain ContentControl that will be databound to the main content of the current page. Of course the same approach will be used for the headers.

As you may have already guessed, single wizard page is represented by WizardPage class. Because it indeed has a content, it directly inherits from ContentControl. And because single WizardPage may provide optional header and side header, it has corresponding dependency properties of type object. Besides, this class defines some other properties like CanXXX which indicate if XXX navigation button is enabled for the page, and PageClose along with PageShow events that are raised whenever a page is closed or shown. As outlined above, the Wizard class contains a collection of WizardPage class.

The Wizard class contains two important dependency properties - ActivePage and ActivePageIndex. I hope their names are self descriptive. Not surprisingly, these two properties depend on each other, i.e. if I change ActivePageIndex, I expect ActivePage will automatically get updated, and vice versa. Moreover, I don't want to receive an error if I accidentally set inappropriate value for these properties. All this can be achieved using change and coerce callbacks. Here's the code:

  225 
  226         private static void OnActivePageIndexChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  227         {
  228             Wizard wizard = (Wizard)d;
  229             int index = (int)e.NewValue;
  230             int oldIndex = (int)e.OldValue;
  231 
  232             if (index != -1 && index != oldIndex)
  233                 wizard.ActivePage = wizard.WizardPages[index];
  234             else if (index == -1)
  235                 wizard.ActivePage = null;
  236         }
  237 
  238         private static object CoerceActivePageIndex(DependencyObject d, object value)
  239         {
  240             Wizard wizard = (Wizard)d;
  241             int index = (int)value;
  242 
  243             if (index >= wizard.WizardPages.Count)
  244                 return wizard.WizardPages.Count - 1;
  245 
  246             if (index >= 0 && index < wizard.WizardPages.Count)
  247                 return index;
  248 
  249             if (index < 0 && wizard.WizardPages.Count > 0)
  250                 return 0;
  251 
  252             return -1;
  253         }
  254 
  255         private static void OnActivePageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  256         {
  257             Wizard wizard = (Wizard)d;
  258             WizardPage page = (WizardPage)e.NewValue;
  259             WizardPage oldPage = (WizardPage)e.OldValue;
  260 
  261             if (page != null && oldPage != page)
  262             {
  263                 // Raise event
  264                 if (oldPage != null)
  265                     oldPage.OnPageClose();
  266 
  267                 // update the index
  268                 int index = wizard.WizardPages.IndexOf(page);
  269                 wizard.ActivePageIndex = index;
  270 
  271                 // Set boundary values for navigation buttons
  272                 if (index == 0)
  273                 {
  274                     wizard.ActivePage.CanNavigatePrevious = false;
  275                     if (wizard.WizardPages.Count == 1)
  276                         wizard.ActivePage.CanNavigateNext = false;
  277                 }
  278                 else if (index == wizard.WizardPages.Count - 1)
  279                     wizard.ActivePage.CanNavigateNext = false;
  280 
  281                 // After page is up and runnig, rais event
  282                 page.OnPageShow();
  283             }
  284             else if (page == null)
  285             {
  286                 // Raise event
  287                 if (oldPage != null)
  288                     oldPage.OnPageClose();
  289 
  290                 wizard.ActivePageIndex = -1;
  291             }
  292         }
  293 
  294         private static object CoerceActivePage(DependencyObject d, object value)
  295         {
  296             Wizard wizard = (Wizard)d;
  297             WizardPage page = (WizardPage)value;
  298 
  299             int index = wizard.WizardPages.IndexOf(page);
  300 
  301             // Given page does not exist in the internal collection
  302             if (index == -1)
  303             {
  304                 if (wizard.WizardPages.Count > 0)
  305                     return wizard.WizardPages[0];
  306 
  307                 return null;
  308             }
  309 
  310             return page;
  311         }


OnActivePageChanged callback is especially important, because beside updating ActivePageIndex, it performs two other things. Firstly, it raises two events on a WizardPage class - it raises PageClose event on a page that is about to be replaced, then the page gets replaced and PageShow event is raised on a new page. And secondly, it checks if the current page is first or last in the wizard and enables or disables Next/Previous buttons accordingly.

Wizard's look & feel is defined in Themes\Generic.xaml using simple grid layout. The most important part of the control's template is how actually content of wizard's active page gets displayed in the wizard. This is accomplished using content placeholders in form of ContentControl. There is one problem with this design, however. All wizard's pages except the active one are NOT part of the logical tree as they are simply not displayed. And if the active page gets replaced, it is automatically removed from the visual tree. This implies two things. The first one is that the page's DataContext propertyis not propagated to the parent, i.e. if you put an instance of some class in the window's DataContext and you bind controls inside pages to this instance, this won't work (yes, I'm talking about PresentationModel pattern). The second thing is that you cannot bind controls with each other. Hopefully, there is an easy solution to overcome the first problem:

   44 
   45         /// 
   46         /// Returns a collection of wizard's pages.
   47         /// 
   48         public WizardPagesCollection WizardPages
   49         {
   50             get { return m_WizardPages; }
   51             set
   52             {
   53                 m_WizardPages = value;
   54                 m_WizardPages.CollectionChanged += OnWizardPagesChanged;
   55             }
   56         }

  142 
  143         private void OnWizardPagesChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
  144         {
  145             // This code glues all wizard's pages to wizard's DataContext. This is done due to the fact that when pages are switched, the
  146             // page that is hidden looses its data context.
  147             foreach (var page in WizardPages)
  148             {
  149                 var binding = new Binding("DataContext") { Source = this };
  150                 BindingOperations.SetBinding(page, DataContextProperty, binding);
  151             }
  152         }



The second one is still unsolved, but because PresentationModel does work with the wizard, it's not a big deal (Update: to see how to solve these problems, see the second post).

You can download full sample from my Code Gallery.

Have fun!

8 comments:

Radoslaw Urbas said...

It's much easier to implement such stuff in Eclipse RCP.

Piotr Włodek said...

I would be surprised if it was harder. However, bear in mind that WPF is not a Rich Client Platform but rather a gui toolkit and as such is not directly comparable with Eclipse Platform ;)

Radoslaw Urbas said...

Reuse, reuse, reuse ... Why for God's sake you want to develop another framework for Wizards ?!

Unknown said...

hi Can I have complete code for Navigation Wizard in wpf, if you dont mind.

Elad said...

Can you supply the styling code necessary to produce the cool blend like gray look and feel?

Unknown said...

This does the job so nicely for me. It works as expected (not always the case wiht code found on the Internet!) Thank you so much. I can concentrate on other things now.

nabajyoti said...

"Welcome to Data Analysis Wizard" - snap shot displayed...but could you please let me know where in the solution the look and feel is present. It appears that there are 3 projects of which the second and the third are replicas of the first type.

Uchiha Itachi said...

Very interesting, giving much insights to my WinForms Wizard control problem.