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!