Click here to Skip to main content
14,579,769 members

Another Take on a WPF Wizard

Rate this:
4.77 (19 votes)
Please Sign up or sign in to vote.
4.77 (19 votes)
11 Apr 2020CPOL
Any technology suitably advanced will generally be viewed as "magic".

Introduction

This article presents my rendition of a WPF Wizard control. I'm aware there are several other attempts to create (some right here on CodeProject), but the way I see it, there's nothing wrong with having more choices.

Image 1

Background

This is another in my seemingly never-ending series of real-world programming examples, where, instead of theory and flowery praise for this new feature or that new paradigm, I present practical application of thread-worn knowledge. The only thing new about this code is that it's probably never been assembled in this form before. If fate really exists, Microsoft will soon completely abandon support for WPF. They always do that with technology I begrudgingly adopt, especially when I wait the requisite 5-8 years for said technology to mature.

There are several extension classes in the source code that have nothing to do with the wizard, but that were there in the actual app I was writing at work. I left them in there on the off chance that someone might find them useful.

Finally, I'm aware that the way I do things might not be anything like the way you do things. Commenting about style or moving things around in the code because "that's where or how you would do it", will be met with silence (or derision if I'm in a particularly foul mood when I read your comment) unless it would provide tangible improvement regarding code function.

Assumptions

This article is NOT written for rookies. It assumes that the reader has a thorough knowledge of .NET and C#, as well as a reasonably complete working knowledge of WPF. As stated above, I'm not here to discuss theory or best practice. I'm here to share some code I wrote for an actual application. If you want explanations of esoteric principles, Google is your friend. After all, that's how I found out how to do this stuff (well, that, and my obvious mad dev skillz).

The Code

Some friendly heads-up notes:

  • As I wrote this article, I decided I wanted to refactor some minor aspects of the code, and those refactorings may not have made it into the final edit of this article (but they had no tangible affect on the article itself).
  • Comments that exist in the actual source code were removed in the text of this article in the interest of clutter mitigation.
  • The discussion in this section does not include for the compiler directive that forces the code to support demo mode.

The wizard has the following major components:

  • CtrlWizard - the user control that represents the wizard
  • WizardConfig - a configuration object for the wizard
  • WizardPageBase - an abstract base class for all wizard pages
  • WizardWindowBase - an abstract base class for wizard forms (that contain the CtrlWizard object)
  • WizardSharedData/WizardSharedDataItem - an object that can be used to store data shared between wizard pages, the parent form, and the wizard control
  • WizardConfig - contains configuration options for the wizard

WizardConfig

In order to efficiently configure the wizard control, I felt that a configuration object that contains appropriate setting properties would be a good idea. This class inherits from another class that derives from INotifyPropertyChanged. I figured that since this is a WPF assembly, it might be a good idea, even though it's not really necessary when you get right down to it because the setup of the wizard only happens one time when the wizard form is created, so making it notifiable is actually kinda pointless (but look at the neat additional code you get to use). Since this class is nothing more than a property bag, I'll only list the properties, what they're for, and what the default values are.

Property Name Default Value Description
ShowNavPanel true bool - Gets/sets display of the navigation panel (that contains the list box) on/off.
ShowContentBanner true bool - Gets/sets display of the title banner on/off.
ShowReset true bool - Gets/sets display of the Reset button on/off.
ShowPrev true bool - Gets/sets display of the Prev button on/off.
ShowNext true bool - Gets/sets display of the Next button on/off.
ShowFinish true bool - Gets/sets display of the Finish button on/off.
ShowCancel true bool - Gets/sets display of the Cancel button on/off.
ShowBannerImage false bool - Gets/sets display of the banner image on/off.
ShowPage1OfN false bool - Gets/sets display of the "Page N of N" title text on/off.
NavListWidth 150 double - Gets/sets the width of the navigation ListBox.
ContentBannerHeight 40 double - Gets/sets the height of the title banner.
BannerBackgroundColor Colors.LightSteelBlue Color - Gets/sets the color for the banner/button panel background.
BannerBorderColor Colors.SteelBlue Color - Gets/sets the color for the banner/button panel border.
BannerTiitleColor Colors.Black Color - Gets/sets the color for the banner/button panel title text.
BannerSubtitleColor Colors.Black Color - Gets/sets the color for the banner/button panel subtitle text.
ColorBannerBackground N/A SolidColorBrush - Gets the brush (based on the BannerBackgroundColor) for the banner/button panel background.
ColorBannerBorder N.A SolidColorBrush - Gets the brush (based on the BannerBorderColor) for the banner/button panel border.
ColorBannerTitle N/A SolidColorBrush - Gets the brush (based on the BannerTitleColor) for the banner title.
ColorBannerSubtitle N/A SolidColorBrush - Gets the brush (based on the BannerSubtitleColor) for the banner subtitle.
BannerBorderThicknessValue 2 double - Sets the thickness of the banner/button panel border.
BannerTextAlignment HorizontalAlignment.Left HorizontalAlignment - Sets the alignment of the text in the banner.
BannerImageAlignment HorizontalAlignment.Right HorizontalAlignment - Sets the alignment of the image used in the banner.
Pages null ObservableCollection<WizardPageBase> - Contains the wizards pages.
BannerImageFilePath** string.Empty string - Sets the path to the image used in the banner. This is done in the code so that the image can be loaded from the desired assembly resource.
  • (** Not bound in the wizard control itself.)

WizardSharedData/SharedDataItem

When the wizard is instantiated, an object is created that allows the programmer to store data that can be accessed by other pages, the control itself, and the parent window. I added this because in all of my wizards to date, the pages are designed to modify different aspects of a single viewmodel item.

It's a simple ObservableCollection of items that the programmer can add objects to. It's really nothing special, and warrants no real discussion beyond mentioning that you can look at it as a sort of Session variable, like you have in a web application.

Here's a picture of my car, because nobody wants to see an unending page of text.

Image 2

WizardPageBase

This class is an abstract base class from which all of your wizard pages must be derived. WizardPageBase is derived from UserControl because it's always contained by a CtrlWizard object (which itself is a UserControl-derived object). It's also derived from INotityPropertyChanged (and implements the required interface components) so that binding will work on objects contained in the SharedData collection.

As you might guess, the constructor simply performs some essential initialization, along with some sanity checking. Since we may not yet have created the parent wizard control, we set that value to null. Then we create a handler for the controls IsVisibleChanged event. Because I'm manually creating this event handler, I use a destructor to unhook it when the page is disposed. I don't know if .NET has been updated to do this for me, but old habits die hard, and besides, you can never be too safe. Right?

public WizardPageBase(string pageName, string shortName, string subtitle)
{
    if (string.IsNullOrEmpty(pageName.Trim()))
    {
        throw new ArgumentNullException("The pageName must be specified.");
    }
    if (string.IsNullOrEmpty(shortName.Trim()))
    {
        throw new ArgumentNullException("The shortName must be specified.");
    }
    this.PageName  = pageName;
    this.ShortName = shortName;
    this.Subtitle  = subtitle;
    this.IsVisibleChanged += UserControl_IsVisibleChanged;
}

~WizardPageBase()
{
	this.IsVisibleChanged -= UserControl_IsVisibleChanged;
}

This brings us to the only abstract method in the class. The reason we have this method is that all of the pages are instantiated when the wizard control is created, and because all wizard pages are actually UserControl objects, there may be a reason not to perform some app-specific processing until the page is actually visible. This method allows that the programmer to perform said processing. Since the method is abstract, it MUST be implemented in the inheriting class.

protected abstract void OnVisibleChanged();

Other Methods

The following methods are all virtual because they're not required in your own wizard pages, but still can be called to perform processing at specific times.

  • UpdateButtons() - This method is meant to update the buttons in the parent wizard control based on some condition that happens while viewing the page which is usually caused by user interaction with controls in the wizard page. This is the most commonly overridden method in a wizard page.
  • Reset() - This method is called by the wizard control when the user clicks the Reset button.
public virtual void UpdateButtons()
{
	if (this.ParentWiz == null)
	{
		throw new Exception("Parent wizard control has not been specified.");
	}
}

public virtual void Reset()
{
}

There are also a couple of event handlers.

  • ButtonClick() - This event handler handles all button clicks made on the buttons in the control wizard controls button panel. The body of this method in the base class contains an example of usage within your own derived class.
  • UserControl_IsVisibleChanged - This event handler simply calls the overridden OnVisiblityChange method in the derived class.
public virtual void ButtonClick(object sender, ref RoutedEventArgs e)
{
}

protected virtual void UserControl_IsVisibleChanged
    (object sender, DependencyPropertyChangedEventArgs e)
{
	this.OnVisibleChanged();
}

Properties

Property Name Description
ParentWiz CtrlWizard - the wizard control that contains this page
PageName string - the name of the page (shown in the wizard title banner)
ShortName string - the short name. This is intended to be a unique name (case-insensitive) to easily identify the page when using conditional processing with the next/prev buttons.
ParentWizardWindow Window - the parent window that contains the parent wizard control.
Subtitle string - the (optional) subtitle displayed in the title banner. If subtitle text is not specified for a page, the UI element is collapsed on the wizard to allow for proper placement of the main title.
PrevPage virtual string - the ShortName of the page from which the user navigated to the current page. This is set by the wizard control as part of the normal processing of the Next button. The wizard page itself should NOT set this property.
NextPage virtual string - the ShortName of the page to which navigation will take the user when he clicks the Next button. This property only needs to be set in the event the programmer wants to navigate to a specific page based on some condition being met in the current page.
SharedData WizardSharedData - gets the current shared data object from the parent wizard control.

WizardWindowBase

This class is an abstract base class from which your wizard form must be derived. WizardWindowBase is derived from Window. Unless otherwise noted, all methods and event handlers in this class are virtual.

Initialization

The constructor for this class instantiates the collection of pages, as well as the WizardConfig object.

public WizardWindowBase()
{
	this.Pages     = new ObservableCollection<wizardpagebase>();
	this.WizConfig = new WizardConfig();
}

Methods

  • InitWizard() - the (abstract) method where pages should be instantiated and wizard configuration occurs. This method MUST be overridden in the derived class.
  • ConfigureWizard() - This method instantiates the wizard, retrieves the buttons from the wizard (to ease typing later), sets up event handlers, and calls the method that adds the wizard control to the form.
  • AddToUI() - adds the specified pages to the wizard control, and adds the control to the form.
  • SetupEvents() - adds event handlers for the Finish, Cancel, and Reset buttons.
protected abstract void InitWizard();

protected virtual void ConfigureWizard(Grid grid)
{
	this.WizConfig.Pages = this.Pages;

	this.Wizard          = new CtrlWizard(this.WizConfig);

	this.wizBtnFinish    = (Button)(this.Wizard.finishButtonPanel.Children[0]);
	this.wizBtnCancel    = (Button)(this.Wizard.cancelButtonPanel.Children[0]);
	this.wizBtnReset     = (Button)(this.Wizard.resetButtonPanel.Children[0]);
	this.wizBtnNext      = (Button)(this.Wizard.nextButtonPanel.Children[0]);
	this.wizBtnPrevious  = (Button)(this.Wizard.prevButtonPanel.Children[0]);

	this.SetupEvents();
	this.AddToUI(grid);

	this.isInitialized = true;
}

protected virtual void AddToUI(Grid grid)
{
	foreach(WizardPageBase page in this.Wizard.Pages)
	{
		page.ParentWiz = this.Wizard;
	}
	grid.Children.Add(this.Wizard);
}

protected virtual void SetupEvents()
{
	this.wizBtnFinish.Click += this.WizFinish_Click;
	this.wizBtnCancel.Click += this.WizCancel_Click;
	this.wizBtnReset.Click  += this.WizReset_Click;
}

Event Handlers

These event handlers are self-explanatory, but the Window_Closing handler deserves a brief discussion. I was originally going to put this code into the class destructor, but because the buttons were in a child control (the wizard) that had already been destroyed, I had to use the Window_Closing event instead.

protected virtual void WizReset_Click(object sender, RoutedEventArgs e)
{
}

protected virtual void WizFinish_Click(object sender, RoutedEventArgs e)
{
	this.DialogResult = true;			
}

protected virtual void WizCancel_Click(object sender, RoutedEventArgs e)
{
	this.DialogResult = false;
}

protected virtual void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
	this.wizBtnFinish.Click -= this.WizFinish_Click;
	this.wizBtnCancel.Click -= this.WizCancel_Click;
	this.wizBtnReset.Click  -= this.WizReset_Click;
}

Fields

Field Name Description
wizBtnFinish Button - the Finish button
wizBtnCancel Button - the Cancel button
wizBtnReset Button - the Reset button
wizBtnNext Button - the Next button
wizBtnPrevious Button - the Prev button

Properties

Property Name Description
IsValid bool - set by the wizard control after its been initialized. It simply lets the programmer know if everything is copestic, not specifically what is wrong. Typically, this property will be false if there are no pages specified, one or more pages has the same ShortName.
Pages ObservableCollection<wizardpagebase> - this is the list of pages created in the derived Window.
Wizard CtrlWizard - this is the control wizard, which is instantiated when the derived window calls ConfigureWizard method.
WizConfig WizardConfig - this is the wizard configuration object which is instantiated when the derived window calls ConfigureWizard method.
Here's another picture of my car.

Image 3

CtrlWizard

This is the "big kahuna" in this article, and is the main reason we're here. During initial development of your wizard form, my advice is to put breakpoints in the exception ifs so that the debugger doesn't stop at seemingly bizarre elsewhere in the code because this object didn't initialize correctly.

Initialization

  • Constructor - performs some sanity checks on the WizardConfig object and the page collection, creates the SharedData object, and initializes itself from the WizardConfig object.
  • InitWithConfig() - Sets the visibility of the specified wizard pages to Collapsed, adds them to the body of the wizard control, and activates the first page.
public CtrlWizard(WizardConfig config)
{
    if (config == null)
    {
        throw new ArgumentNullException("config");
    }
    if (config.Pages == null || config.Pages.Count == 0)
    {
        throw new InvalidOperationException
            ("The WizardConfig.Pages collection cannot be null or empty.");
    }

    this.ActivePageIndex = -1;
    this.ActivePage      = null;

    this.WizConfig       = config;
    this.SharedData      = new WizardSharedData();

    this.InitializeComponent();
    this.DataContext     = this;

    this.InitWithConfig(config);
}

private void InitWithConfig(WizardConfig config)
{
    this.CanEnableFinishButton = config.ShowNavPanel;
    if (this.Pages == null)
    {
        this.Pages = new ObservableCollection<WizardPageBase>();
        foreach(WizardPageBase page in config.Pages)
        {
            this.Pages.Add(page);
            ((UserControl)(page)).Visibility = Visibility.Collapsed;
            page.ParentWiz = this;
            this.gridContentBody.Children.Add(((UserControl)(page)));
        }
        this.PageCount = this.Pages.Count;
        this.SharedData.AddUpdateItem("WizardPages", this.Pages);
    }
    this.UpdateForm(0);
}

Methods

  • UpdatedConfig() - updates the control with new config settings
  • UpdateForm(int newPageIndex) - selects the page specified by the newPageIndex as the active page
  • UpdateForm(string shortName) - selects the page specified by the shortName as the active page
  • UpdateButtons() - standard button updates (button state can be overridden inside each page as necessary)
  • IsLastPage() - determines if the current page is the last page (controls state of the Finish and Next buttons)
  • IsFirstPage() - determines if the current page is the last page (controls state of the Prev button)
  • GetCurrentPage() - gets the currently active WizardPage object
  • PagesAreDistinct() - performs sanity check for unique page names - case sensitivity is not a factor during this check because this code wants to promote the use of truly unique names
public void UpdatedConfig()
{
    this.InitWithConfig(this.WizConfig);
    this.NotifyPropertyChanged("WizConfig");
}

public void UpdateForm(int newPageIndex)
{
    if (this.ActivePageIndex != newPageIndex)
    {
        if (this.ActivePage != null )
        {
            ((UserControl)(this.ActivePage)).Visibility = Visibility.Collapsed;
        }

        this.ActivePageIndex = newPageIndex;
        this.ActivePage      = this.GetCurrentPage();
        this.ActivePageName  = this.ActivePage.PageName;

        ((UserControl)(this.ActivePage)).Visibility = Visibility.Visible;
        this.lbNavigation.SelectedIndex             = this.ActivePageIndex;
        this.NotifyPropertyChanged("PageSubtitle");
        this.NotifyPropertyChanged("PageSubtitleVisibility");
        this.UpdateButtons();
    }
}

public void UpdateForm(string shortName)
{
    if (string.IsNullOrEmpty(shortName))
    {
        throw new ArgumentNullException
          ("A shortName was not returned by the current page. Navigation aborted" );
    }

    WizardPageBase newPage = this.Pages.Where(x=>x.ShortName == shortName).FirstOrDefault();
    if (newPage == null)
    {
        throw new Exception(string.Format("Could not find page '{0}'. Navigation aborted."));
    }

    WizardPageBase currentPage = this.GetCurrentPage();
    int index = this.Pages.IndexOf(newPage);
    this.UpdateForm(index);
}

public void UpdateButtons()
{
    bool isFirstPage  = this.IsFirstPage(this.ActivePage);
    bool isLastPage   = this.IsLastPage(this.ActivePage);
    bool enableFinish = ((this.WizConfig.ShowFinishButton) && 
                         (isLastPage || this.CanEnableFinishButton));

    this.btnReset.IsEnabled  = this.WizConfig.ShowResetButton  ? true : false;
    this.btnPrev.IsEnabled   = isFirstPage      ? false : true;
    this.btnNext.IsEnabled   = isLastPage       ? false : true;
    this.btnFinish.IsEnabled = enableFinish;
    this.btnCancel.IsEnabled = this.WizConfig.ShowCancelButton ? true : false;

    this.ActivePage.UpdateButtons();
}

public bool IsLastPage(WizardPageBase page)
{
    bool result = false;
    WizardPageBase last = this.Pages.Last<wizardpagebase>();
    result = (last.PageName == page.PageName && last.GetType().Name == page.GetType().Name);
    return result;
}

public bool IsFirstPage(WizardPageBase page)
{
    bool result = false;
    WizardPageBase first = this.Pages.First<wizardpagebase>();
    result = (first.PageName == page.PageName);
    return result;
}

private WizardPageBase GetCurrentPage()
{
    WizardPageBase currentPage = null;
    if (this.ActivePageIndex >= 0)
    {
        currentPage = (WizardPageBase)(this.gridContentBody.Children[ActivePageIndex]);
    }
    return currentPage;
}

private bool PagesAreDistinct()
{
    int distinct = this.Pages.DistinctBy(x=>x.ShortName).Count();
    bool result = (this.Pages.Count > 0 && distinct == this.Pages.Count);
    return result;
}
Here's a picture of my car, because racecar.

Image 4

Event Handlers

  • lbNavigation_SelectionChanged() - handles selection changed in the navigation panel lListBox.
  • btnReset_Click() - activates the first page of the wizard (the parent form is responsible for resetting application data associated with the wizard instance
  • btnPrev_Click() - activates the previous page in the wizard. If the current page was displayed as the result of a condition, the appropriate previous page will be selected.
  • btnNext_Click() - activates the next page in the wizard. The current page determines the next page in the sequence (via the page.ButtonClick method).
  • btnFinish_Click() - closes the wizard after the last page finishes its processing (ostensibly, saving the data via the page.ButtonClick method).
  • btnCancel_Click() - closes the wizard.
private void lbNavigation_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    if (!e.Handled)
    {
        ListBox listbox = sender as ListBox;
        this.UpdateForm(listbox.SelectedIndex);
        e.Handled = true;
    }
}

private void btnReset_Click(object sender, RoutedEventArgs e)
{
    this.UpdateForm(0);
}

private void btnPrev_Click(object sender, RoutedEventArgs e)
{
    if (this.HasChildren)
    {
        WizardPageBase currentPage = this.GetCurrentPage();
        currentPage.ButtonClick(sender, ref e); 

        if (!e.Handled)
        {
            string prevPage = currentPage.PrevPage;
            if (string.IsNullOrEmpty(prevPage))
            {
                this.UpdateForm(this.ActivePageIndex - 1);
            }
            else
            {
                this.UpdateForm(prevPage);
            }
            e.Handled = true;
        }
    }
    else
    {
        e.Handled = true;
    }
}

private void btnNext_Click(object sender, RoutedEventArgs e)
{
    if (this.HasChildren)
    {
        WizardPageBase currentPage = this.GetCurrentPage();
        currentPage.ButtonClick(sender, ref e);

        if (!e.Handled)
        {
            string nextPage = currentPage.NextPage;
            if (string.IsNullOrEmpty(nextPage))
            {
                this.UpdateForm(this.ActivePageIndex + 1);
                currentPage = this.GetCurrentPage();
                currentPage.PrevPage = string.Empty;
            }
            else
            {
                string prevPage = currentPage.ShortName;
                this.UpdateForm(nextPage);
                currentPage = this.GetCurrentPage();
                // so we can find our way back if they hit the "Prev" button
                currentPage.PrevPage = prevPage;
            }
            e.Handled = true;
        }
        else
        {
            // do we need to add any processing here?
        }
    }
    else
    {
        e.Handled = true;
    }
}

private void btnFinish_Click(object sender, RoutedEventArgs e)
{
    if (this.HasChildren)
    {
        this.GetCurrentPage().ButtonClick(sender, ref e);
        if (this.Parent is Window)
        {
            ((Window)(this.Parent)).Close();
            e.Handled = true;
        }
    }
    else
    {
        e.Handled = true;
    }
}

private void btnCancel_Click(object sender, RoutedEventArgs e)
{
    if (this.HasChildren)
    {
        this.GetCurrentPage().ButtonClick(sender, ref e);

        if (this.Parent is Window)
        {
            ((Window)(this.Parent)).Close();
            e.Handled = true;
        }
    }
    else
    {
        e.Handled = true;
    }
}

Properties

Property Name Description
IsValid bool - Gets the valid status of the wizard control. It will return false if any of the following conditions are true:
  • the SharedData object is null.
  • the WizardConfig is null.
  • the collection of wizard pages in the WzardConfig object is null or empty.
  • one or more of the pages has a similar ShortName.
PageCount int - Gets/sets the number of pages in the wizard page collection. (*)
ActivePageIndex int - Gets/sets the active wizard page index, and is used for both display and navigation.
ActivePageNumber int - Gets/sets the active page number, and is used for display.
ActivePageName string - Gets/sets the active pages title.
ActivePage WizardPageBase - Gets/sets the active wizard page object
CanEnableFinishButton bool - Gets/sets value that indicates whether or not the default enabled status of the Finish button is enabled (*).
HasChildren bool - Gets a value that indicates whether the wizard control has child pages (shot-hand for checking the count in the collection).
Pages ObservableCollection<WizardPageBase> - Gets/sets the wizard page collection.
SharedData WizardSharedData - Gets/sets the SharedData object.
WizConfig WizardConfig - Gets/sets the WizardConfig object.
PageSubtitle string - Gets/sets the page subtitle text.
PageSubtitleVisibility Visibility - Gets the visibility of the subtitle text, base on the value of the WizardConfig.ShowSubtitle flag AND whether or not the subtitle text is null/empty.
ParentWindow Window - Gets the controls parent window.

(*) Properties marked this way could benefit from some refactoring.

Using the Code - The Sample Application

Know ahead of time that for the purposes of creating a reasonable sample application, I made some changes to the actual wizard configuration object which resolved some binding issues that I was having with the WPF Colors object. I used the compiler directive __DEMO__ in the sample aplication and WpfControls projects to control whether the code is (or is not) in "demo" mode. When you actually use this code, simply deleting the compiler directive from WPFControls will change the way the bound brush properties instantiate the returned SolidColorBrush objects.

The Main Window

Image 5

The main window is a simple WPF form that allows you to change visual aspects of the wizard. I recognize that it makes absolutely no sense to hide all of the buttons and the title banner, but in the interest of completeness, I allowed that to be done. I want to discuss a couple of things regarding this form, but only because they presented interesting problems.

Binding to Enumerators

Image 6

One of the things I ran into was the need to bind a couple of ComboBox controls to the HorizontalAlignment enumerator.

The first step was to define on ObjectDataProvider. If you're doing this for an enumerator that you've created, don't forget that you have to add/use a namespace declaration that points to your project code.

<ObjectDataProvider MethodName="GetValues" ObjectType="{x:Type sys:Enum}" x:Key="HorzAlign">
    <ObjectDataProvider.MethodParameters>
        <x:Type TypeName="HorizontalAlignment" />
    </ObjectDataProvider.MethodParameters>
</ObjectDataProvider>

Next, I created a DataTemplate in Window.Resources so I could properly display the enumerator item names.

<DataTemplate x:Key="alignComboItem" >
    <StackPanel Orientation="Horizontal" Margin="0,1.5,0,0" >
        <TextBlock Text="{Binding Path=Value}" Margin="5,0,0,0" />
    </StackPanel>
</DataTemplate>

Finally, for the ComboBox itself, I set up the appropriate bindings. Notice the binding for the ItemsSource property - it's Binding Source=, instead of the usual Binding Path=.

<ComboBox ... ItemsSource="{Binding Source={StaticResource HorzAlign}}" 

             SelectedValue="{Binding Path=Config.BannerImageAlignment}" />

Selecting Colors

Image 7

I wanted to keep the form as simple as possible, so I figured I'd setup some comboboxes that allow you to select the desired colors by name instead of doing up some fancy color picker control. To put it bluntly, this was a royal pain in the anal pore, because the WPF Colors object does not lend itself to quick/easy binding to a control. In fact, supporting this idea is the sole reason for the "demo mode" code (notice that the word "demonic" starts with the word "demo"). After taking several runs at this, I settled for the following.

First, I added appropriate string properties to the WizardConfig class to hold the names of the desired color, and added SolidColorBrush properties that use those names (only one of each is shown for the purpose of example). I also added a helper function that is only used to ease the chore of typing.

#if __DEMO__
string bannerBackgroundColorName = "LightSteelBlue";
#else
...
#endif

public SolidColorBrush BannerBackgroundBrush
{
#if __DEMO__
    get { return new SolidColorBrush(this.NameToColor(this.bannerBackgroundColorName)); }
#else
...
#endif
}

#if __DEMO__
private Color NameToColor(string name)
{
    Color color = (Color)(ColorConverter.ConvertFromString(name));
    return color;
}
#endif

Next, I created a class to represent the named WPF color, and using Reflection, initialized a collection of the colors in the window's constructor. To make it more efficient, I added a SolidColorBrush object to each WpfColor item to satisfy the requirements of the associated XAML data template.

public class WpfColor
{
    public string          Name  { get; set; }
    public SolidColorBrush Brush { get; set; }
}

public MainWindow()
{
	this.WpfColors = new List<WpfColor>();
	PropertyInfo[] properties = typeof(Colors).GetProperties(BindingFlags.Public | 
                                BindingFlags.Static);
	foreach(PropertyInfo prop in properties)
	{
		this.WpfColors.Add(new WpfColor()
        { 
            Name = prop.Name, 
            Brush = new SolidColorBrush
                    ((Color)(ColorConverter.ConvertFromString(prop.Name))) });
	}
	...
}

Next, I created a data template in the windows XAML.

<DataTemplate x:Key="colorComboItem2" >
    <StackPanel Orientation="Horizontal" Margin="0,1.5,0,0" >
        <Rectangle Width="13" Height="13" 

        Stroke="Black" Fill="{Binding Path=Brush}" />
        <TextBlock Text="{Binding Path=Name}" Margin="5,0,0,0" />
    </StackPanel>
</DataTemplate>

Finally, I added a Combobox with appropriate bindings to the XAML.

<ComboBox ItemsSource="{Binding Path=WpfColors}" 

          ItemTemplate="{StaticResource colorComboItem2}" 

          SelectedValuePath="Name" 

          SelectedValue="{Binding Path=Config.BannerTitleColorName}" />

Wizard Form - The Wizard Pages

To create a wizard page, perform the following steps:

  1. Create a new UserControl, and name it appropriately.
  2. In the XAML, add a namespace reference to the WpfControls.Controls namespace (I used "wiz")
  3. In the XAML, change the class name from UserControl to wiz:WizardPageBase.

    At this point, your XAML should look something like this:

    <wiz:WizardPageBase x:Class="WpfWizard.WizPgIntro"
    
                 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    
                 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    
                 xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
    
                 xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
    
                 xmlns:wiz="clr-namespace:WpfCommon.Controls;assembly=WpfCommon"
    
                 mc:Ignorable="d" 
    
                 d:DesignHeight="300" d:DesignWidth="600" >
        <Grid>
        </Grid>
    </wiz:WizardPageBase>
  4. In the XAML.CS, add a using statement for WpfCommon.Controls.
  5. In the XAML.CS, change the inherited object type from UserControl, to WizardBasePage.
  6. In the XAML.CS, add the following constructor overload:
    public PageObject(string pageName, string shortName, string subtitle)
           :base(pageName, shortName, subtitle)
    {
        this.InitializeComponent();
    }
  7. In the XAML.CS, add the override to the abstract method OnVisibleChanged. This is the method where you would initialize the page when it becomes visible.
    protected override void OnVisibleChanged()
    {
        // TO-DO: add initialization code here, such as loading data, 
        //        and/or setting button state.
    }

Congratulations! You've got a fully implemented wizard page (with no real functionality of course).

The Wizard Form Itself

To create a wizard form, perform the following steps:

  1. Create a new Window, and name it appropriately.
  2. In the XAML, add a namespace reference to the WpfControls.Controls namespace (I used "wiz")
  3. In the XAML, change the class name from Window to wiz:WizardWindowBase.
  4. I like to set the backgrounds of my forms to something other than white, so while I'm in the XAML, I take this opportunity to make the desired color change (as well as other changes such as font size, form size, etc). At this point, you should have something that looks like this:
    <wiz:WizardWindowBase x:Class="WpfWizard.WndWizard"
    
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    
            xmlns:wiz="clr-namespace:WpfCommon.Controls;assembly=WpfCommon"
    
            Title="Wizard Form" Height="400" Width="800" Background="#eaeaea" 
    
            WindowStartupLocation="CenterOwner">
        <Grid x:Name="gridMain" >
        </Grid>
    </wiz:WizardWindowBase>
  5. In the XAML.CS, add a using statement for WpfCommon.Controls.
  6. In the XAML.CS, change the inherited object type from Window, to WizardWindowPage.
  7. In the XAML.CS, add the following lines to the constructor:
    this.DataContext = this;
    this.InitWizard();

    Added on 2019.09.18 - If your wizard form is also the app's main window, add the following line to the constructor:

    this.IsAppWindow = true;
    

    This line controls how the window closes itself when you click the Finish or Cancel buttons.

  8. In the XAML.CS, add the following method. Take note of the comments at various points in the following code block, and of course, replace all examples with your own code. Remember that all of the necessary objects for a basic wizard (such as the WizConfig, SharedData, and Pages objects) are created in the base class, and are available for immediate use.
    protected override void InitWizard(WizardConfig config=null)
    {
        if (!this.isInitialized)
        {
            // TO-DO: create pages here. Example:
            WizPgIntro pg0 = new WizPgIntro("Introduction",  "page0",  string.Empty );
    
            // TO-DO: Add pages to the page collection (defined and instantiated 
            //        in the base WizardWindowBase class). Example: 
    		this.Pages.Add(pg0);
    
            // Add the pages to the wizard control, and setup event hooks (see 
            // WpfCommon.Controls.WndWizardBase.cs). This method can be overridden, 
    		// but MUST be called.
            this.ConfigureWizard(this.gridMain);
    
    		// TO-DO: If you want to change the config settings from their default 
            // values, this is where you do it.
            if (this.Wizard != null)
            {
                // Examples:
                this.WizConfig.ShowResetButton      = false;
                this.WizConfig.ShowNavPanel         = true;
                this.WizConfig.ShowPage1OfN         = false;
                this.WizConfig.ContentBannerHeight  = 80;
                this.WizConfig.NavListWidth         = 250;
                this.WizConfig.ShowBannerImage      = false;
                this.WizConfig.BannerTextAlignment  = HorizontalAlignment.Left;
            }
    
            // You only have to call this method if you changed the config defaults.
            this.Wizard.UpdatedConfig();
    
            // TO-DO: Add shared data if applicable. Example:
            this.Wizard.SharedData.Add(new WizardSharedDataItem("SampleData", sample));
        }
    }

The Wizard in the Sample Application

Image 8

The sample application is VERY limited as far as what's going on. There are five sample wizard pages. The intro and finish pages don't do anything other than display private static data. The pages in between is where we exercise the conditional page sequence and data shared between pages.

Sample Page 1

Image 9

This page presents a CheckBox that, when checked when the Next button is clicked, causes the wizard control to display Sample Page 3 instead of Sample Page 2. If you're also displaying the navigation listbox, you will see the listbox update according to the checkbox state. This is determined by setting the NextPage property:

public override string NextPage
{
    get
    {
        return (this.cbNextPage.IsChecked == true) ? "page3" : "page2";
    }
    set
    {
        base.NextPage = "";
    }
}

Sample Page 2

Image 10

This page presents the value of a SharedData item, and a checkbox that enables/disables the next button. This exercises the virtual UpdateButtons method. If the checkbox is unchecked, the Next button is disabled (but you can still select the page in the navigation list box). In this case, the UpdateButtons() interaction is triggered by a call from the Checked and Unchecked handlers.

Notice that we check the IsReady property before we handle the event. The reason is that if you set default values in the XAML, the event to fire BEFORE the page has been added to the parent wizard control, which may cause an exception to be thrown.

public override void UpdateButtons()
{
    this.ParentWiz.nextButtonPanel.IsEnabled = (this.cbEnableNext.IsChecked == true);
}

private void CheckBox_Checked(object sender, RoutedEventArgs e)
{
    if (this.IsReady)
    {
        this.UpdateButtons();
    }
}

Additionally, clicking the Next button causes a new SharedData item to be added for display in Sample Page 3. This new item is NOT added (intentionally) if you use the Navigation list box to display Sample Page 3.

public override void ButtonClick(object sender, ref RoutedEventArgs e)
{
    if (sender is Button )
    {
        Button button = sender as Button;
        switch (button.Name)
        {
            case "btnNext" : 
                this.SharedData.AddUpdateItem("SampleDataChanged", 
                                               string.Format("{0} for a different page", 
                                                             this.SampleData)); 
                break;
        }
        e.Handled = false;
    }
    else
    {
    }
}

Sample Page 3

This page presents both shared data items (if both exist), and removes that data when the user clicks the Prev button.

Image 11
Image 12
public string SampleData 
{ 
    get 
    { 
        return this.GetValue("SampleData"); 
    } 
}

public string SampleDataChanged 
{ 
    get 
    { 
        string data = this.GetValue("SampleDataChanged");
        return string.IsNullOrEmpty(data) ? "SharedData item not present" : data; 
    } 
}

public override void ButtonClick(object sender, ref RoutedEventArgs e)
{
    if (sender is Button)
    {
        Button button = sender as Button;
        switch (button.Name)
        {
            case "btnPrev" : 
                this.SharedData.DeleteItem("SampleDataChanged");
                break;
        }
        e.Handled = false;
    }
    else
    {
    }
}
Image 13

Additional Code (2019.09.16) Not in Download

While using this code, I found a need to be able to click one of the wizard control buttons based on other user actions in a given page. Add this code to CtrlWizard.cs:

using System.Windows.Controls.Primitives;

public void ClickResetButton()
{
    this.ClickWizardButton(this.resetButtonPanel);
}

public void ClickNextButton()
{
    this.ClickWizardButton(this.nextButtonPanel);
}

public void ClickPrevButton()
{
    this.ClickWizardButton(this.prevButtonPanel);
}

public void ClickCancelButton()
{
    this.ClickWizardButton(this.cancelButtonPanel);
}

public void ClickFinishButton()
{
    this.ClickWizardButton(this.finishButtonPanel);
}

public void ClickWizardButton(StackPanel panel)
{
    Button btn = ((Button)(panel.Children[0]));
    if (btn.Visibility == Visibility.Visible && btn.IsEnabled)
    {
        btn.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent);
    }
}

Usage (from within a wizard page):

this.ParentWiz.ClickNextButton();

UPDATE! New Code (not in download) for 2020.04.11

While using the code in this article, I developed a need to change the control template for the buttons to match the button styles I use in the app. The best way to handle this is to add a method to the CtrlWizard.xaml.cs file (in the WPFCommon assembly). If you don't implement a custom button template in your code, you can safely ignore this section. However, you may still want to implement these changes in the event that you develop a need for such functionality.

The Custom Styling I Use - Added to My App.xaml File

I have a custom button style defined, which I want to use for ALL buttons in the app. Because I want all of my buttons to look the same, I don't bother specifying a x:Key attribute.

<Style TargetType="{x:Type Button}">
    <Setter Property="Foreground" Value="White" />
    <Setter Property="Background" Value="SteelBlue" />
    <Setter Property="BorderBrush" Value="Black" />
    <Style.Triggers>
        <Trigger Property="Control.IsMouseOver" Value="True">
            <Setter Property="Background" Value="LightSteelBlue" />
            <Setter Property="Foreground" Value="Black" />
        </Trigger>
    </Style.Triggers>
</Style>
Because I specified non-system colors and appearance that don't jive with WPF's button trigger behavior, I also had to create a custom template to make the colors change appropriately:

<ControlTemplate x:Key="AppButtonTemplate" TargetType="{x:Type ButtonBase}">
    <Border x:Name="border" CornerRadius="4"  BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}" SnapsToDevicePixels="True">
        <ContentPresenter x:Name="contentPresenter" ContentTemplate="{TemplateBinding ContentTemplate}" Content="{TemplateBinding Content}" ContentStringFormat="{TemplateBinding ContentStringFormat}" Focusable="False" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" Margin="{TemplateBinding Padding}" RecognizesAccessKey="True" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
    </Border>
    <ControlTemplate.Triggers>
        <Trigger Property="Button.IsDefaulted" Value="True">
            <Setter Property="BorderBrush" TargetName="border" Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"/>
        </Trigger>
        <Trigger Property="IsMouseOver" Value="True">
            <Setter Property="Background" TargetName="border" Value="#FFBEE6FD"/>
            <Setter Property="BorderBrush" TargetName="border" Value="#FF3C7FB1"/>
        </Trigger>
        <Trigger Property="IsPressed" Value="True">
            <Setter Property="Background" TargetName="border" Value="#FFC4E5F6"/>
            <Setter Property="BorderBrush" TargetName="border" Value="#FF2C628B"/>
        </Trigger>
        <Trigger Property="ToggleButton.IsChecked" Value="True">
            <Setter Property="Background" TargetName="border" Value="#FFBCDDEE"/>
            <Setter Property="BorderBrush" TargetName="border" Value="#FF245A83"/>
        </Trigger>
        <Trigger Property="IsEnabled" Value="False">
            <Setter Property="Background" TargetName="border" Value="#FFF4F4F4"/>
            <Setter Property="BorderBrush" TargetName="border" Value="#FFADB2B5"/>
            <Setter Property="Foreground" Value="#FF838383"/>
        </Trigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

Notice that the template MUST have a target type of ButtonBase

The template resource will PROBABLY be defined in your App.XAML file, which means when you use it on forms in your app, you do something like this:

<Button Template="{DynamicResource AppButtonTemplate}" />

Note that you have to use DynamicResource because it's defined in an external file (App.xaml.cs), which is also why you can reference the new template from any form in your app.

Modifying CtrlWizard.xaml and CtrlWizard.xaml.cs

First, I had to implement a method (in CtrlWizard.xaml.cs) that I could call from the app. WPF provides a way to find resources, and then set a dynamic resource for a given control.

/// <summary>
/// Sets the control template for all of the wizard control's buttons
/// </summary>
/// <param name="xaml">The name of a dynamic resource defined in the app's App.XAML file.</param>
/// <exception cref="ResourceReferenceKeyNotFoundException"></exception>
public void SetButtonTemplates(string resourceName)
{
    // make sure the specified resource exists in the application
    if (Application.Current.FindResource(resourceName) != null)
    {
        // you must use the control's SetResourceReference method if you're using a DynamicrESOURCE. 
        this.btnCancel.SetResourceReference(Control.TemplateProperty, resourceName);
        this.btnFinish.SetResourceReference(Control.TemplateProperty, resourceName);
        this.btnNext.SetResourceReference(Control.TemplateProperty, resourceName);
        this.btnPrev.SetResourceReference(Control.TemplateProperty, resourceName);
        this.btnReset.SetResourceReference(Control.TemplateProperty, resourceName);
    }
    else
    {
        throw new ResourceReferenceKeyNotFoundException("Specified button template not found", resourceName);
    }
}

When I added/tested that code, I realized that using specific button widths (in CtrlWizard.xaml) was a bad idea, so I added the following property. I want all buttons to be the same width, but I also want all the buttons to be wide enough to contain all of the text. The obvious solution is to find the widest button of the bunch (using the ActualWidth, and bind each button's Width property to that value.

	public double LargestButton
{
    get
    {
        double value = 0;
        value = Math.Max(value, this.btnCancel.ActualWidth);
        value = Math.Max(value, this.btnFinish.ActualWidth);
        value = Math.Max(value, this.btnNext.ActualWidth);
        value = Math.Max(value, this.btnPrev.ActualWidth);
        value = Math.Max(value, this.btnReset.ActualWidth);
        return value;
    }
}

...and I changed the button markup in CtrlWizard.xaml. Not that we now bind the button Width property to the new LargestButton class property. This makes all buttons the same width, AND makes all buttons wide enough to contain their label text. In the interest of completeness, I included the element that contains all of the buttons:

<Border x:Name="gridContentNavigation" Grid.Row="2" 

        Background="{Binding Path=WizConfig.BannerBackgroundBrush, Mode=OneWay}" 

        BorderBrush="{Binding Path=WizConfig.BannerBorderBrush, Mode=OneWay}" 

        BorderThickness="{Binding Path=WizConfig.NavPanelBorderThickness, Mode=OneWay}" >
    <Grid >
        <!-- 0) set button panel visibility so you also hide the spacer grid associated with that button -->
        <!-- 1) always do a stackpanel containing the button as the FRIST child element -->
        <StackPanel x:Name="stackNaveLeftSide" Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,5,0,5" 

                    Visibility="{Binding Path=WizConfig.ShowResetButton, Mode=OneWay, Converter={StaticResource visibilityConverter}}">
            <StackPanel x:Name="resetButtonPanel" x:FieldModifier="public" Orientation="Horizontal" >
                <Button Content="  << Reset  " Width="{Binding Path=LargestButton}" x:Name="btnReset" Click="btnReset_Click" Margin="5,0,0,0"/>
                <Grid Width="5" />
            </StackPanel>
        </StackPanel>
        <StackPanel x:Name="stackNaveRightSide"  Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,5,0,5">
            <StackPanel x:Name="prevButtonPanel" x:FieldModifier="public" Orientation="Horizontal" 

                        Visibility="{Binding Path=WizConfig.ShowPrevButton, Mode=OneWay, Converter={StaticResource visibilityConverter}}" >
                <Button Content="  < Prev  " Width="{Binding Path=LargestButton}" x:Name="btnPrev" Click="btnPrev_Click" />
                <Grid Width="5" />
            </StackPanel>
            <StackPanel x:Name="nextButtonPanel" x:FieldModifier="public" Orientation="Horizontal" Visibility="{Binding Path=WizConfig.ShowNextButton,Mode=OneWay,Converter={StaticResource visibilityConverter}}" >
                <Button Content="  Next >  " Width="{Binding Path=LargestButton}" x:Name="btnNext" Click="btnNext_Click" />
                <Grid Width="5" />
            </StackPanel>
            <StackPanel x:Name="finishButtonPanel" x:FieldModifier="public" Orientation="Horizontal" Visibility="{Binding Path=WizConfig.ShowFinishButton,Mode=OneWay,Converter={StaticResource visibilityConverter}}" >
                <Button Content="  Finish  " Width="{Binding Path=LargestButton}" x:Name="btnFinish" Click="btnFinish_Click" />
                <Grid Width="5" />
            </StackPanel>
            <StackPanel x:Name="cancelButtonPanel" x:FieldModifier="public" Orientation="Horizontal" Visibility="{Binding Path=WizConfig.ShowCancelButton,Mode=OneWay,Converter={StaticResource visibilityConverter}}" >
                <Button Content="  Cancel  " Width="{Binding Path=LargestButton}" x:Name="btnCancel" Click="btnCancel_Click" />
                <Grid Width="5" />
            </StackPanel>
        </StackPanel>
    </Grid>
</Border>

Finally, to give the class an opportunity to set the LargestButton property value, I had to add a handler for the form's Loaded event. This is a two step process. First, you change the CtrlWizard.xaml.cs file to add the event handler method:

private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
    this.NotifyPropertyChanged("LargestButton");
}
...and then you add the event to the CtrlWizard.xaml file:
<UserControl x:Class="WpfCommon.Controls.CtrlWizard" 

             [... other attributes...]

             Loaded="UserControl_Loaded">

Using the new code

Now that we're setup for our template setting functionality, and assuming you've already added a custom template in your App.XAML file, let's use it in our application code.

You should have an InitWizard() method in your application. At the bottom of that method, add this line:

this.Wizard.SetButtonTemplates("AppButtonTemplate");

When you run your application, all of the buttons should match in terms of appearance and functionality.

Points of Interest, Caveats, and Emptors

Today's point of interest is Fort Stockton, TX - home of the world's largest roadrunner. Google it, and prepare to be amazed.

History

  • 2020.04.11 - Added a new Update section to illustrate a change to CtrlWizard.xaml (in the WPFCoommon assembly) to change the template for the buttons, as well as make the button sizing more fluid.
  • 2019.09.18 - Added information to Step 6 in the "The Wizard Form Itself" section, correct some spelling errors, and added missing language tags in the appropriate <pre> blocks
  • 2019.09.16 - Added functionality to allow a wizard page to click a wizard button
  • 2016.04.14 - Initial release

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

#realJSOP
Software Developer (Senior) Paddedwall Software
United States United States
I've been paid as a programmer since 1982 with experience in Pascal, and C++ (both self-taught), and began writing Windows programs in 1991 using Visual C++ and MFC. In the 2nd half of 2007, I started writing C# Windows Forms and ASP.Net applications, and have since done WPF, Silverlight, WCF, web services, and Windows services.

My weakest point is that my moments of clarity are too brief to hold a meaningful conversation that requires more than 30 seconds to complete. Thankfully, grunts of agreement are all that is required to conduct most discussions without committing to any particular belief system.

Comments and Discussions

 
QuestionUpdated Article Pin
#realJSOP11-Apr-20 4:33
mva#realJSOP11-Apr-20 4:33 
QuestionUnable to download zip-file Pin
sditt17-Sep-19 21:48
Membersditt17-Sep-19 21:48 
AnswerRe: Unable to download zip-file Pin
#realJSOP18-Sep-19 1:20
mva#realJSOP18-Sep-19 1:20 
QuestionOutstanding Pin
Nelek17-Sep-19 7:52
protectorNelek17-Sep-19 7:52 
QuestionAdded Some Code Pin
#realJSOP16-Sep-19 3:51
mva#realJSOP16-Sep-19 3:51 
QuestionUsed the article today Pin
#realJSOP13-Sep-19 1:40
mva#realJSOP13-Sep-19 1:40 
QuestionNice car Pin
Member 1241391517-Jun-19 2:31
MemberMember 1241391517-Jun-19 2:31 
QuestionI found a small issue Pin
#realJSOP9-Nov-16 4:09
mva#realJSOP9-Nov-16 4:09 
QuestionTexas car Pin
Dennis Dykstra19-Apr-16 13:58
MemberDennis Dykstra19-Apr-16 13:58 
QuestionFive stars Pin
FrodoFlee14-Apr-16 17:03
MemberFrodoFlee14-Apr-16 17:03 
QuestionAnd the winner of the most gratuitous placement of a picture of a car goes to... Pin
Chris Maunder14-Apr-16 8:06
cofounderChris Maunder14-Apr-16 8:06 
AnswerRe: And the winner of the most gratuitous placement of a picture of a car goes to... Pin
#realJSOP14-Apr-16 22:40
mva#realJSOP14-Apr-16 22:40 
GeneralRe: And the winner of the most gratuitous placement of a picture of a car goes to... Pin
dandy7218-Sep-19 9:14
Memberdandy7218-Sep-19 9:14 
GeneralRe: And the winner of the most gratuitous placement of a picture of a car goes to... Pin
#realJSOP19-Sep-19 2:35
mva#realJSOP19-Sep-19 2:35 
AnswerRe: And the winner of the most gratuitous placement of a picture of a car goes to... Pin
Raphael Muindi Jr.1-Jul-20 20:11
MemberRaphael Muindi Jr.1-Jul-20 20:11 
GeneralCar Pin
Warrick Procter13-Apr-16 23:11
MemberWarrick Procter13-Apr-16 23:11 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Article
Posted 13 Apr 2016

Tagged as

Stats

28K views
1K downloads
28 bookmarked