Extensible WPF


Imagine an application that can be used to set configuration values. The GUI could be written as a typical WPF form:

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="auto"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="auto"/>
        <RowDefinition Height="auto"/>
    </Grid.RowDefinitions>

    <TextBlock Grid.Column="0" Grid.Row="0" Text="Server address"/>
    <TextBox Grid.Column="1" Grid.Row="0" Text="{Binding ServerAddress}"/>

    <TextBlock Grid.Column="0" Grid.Row="1" Text="Database name"/>
    <TextBox Grid.Column="1" Grid.Row="1" Text="{Binding DatabaseName}"/>
</Grid>

This would be bound to a view model object:

public class ConfigViewModel
{
    public string ServerAddress { get; set; }
    public string DatabaseName { get; set; }
}

A common goal in writing good software is to allow for extension without needing to modify the existing code (open/closed principle). When you have such a design, new requirements can be fulfilled with minimum risk to existing functionality. The risk in modifying such a simple application is fairly low, but the example is intentionally simple for the sake of brevity.

You can easily imagine new requirements being asked of this application, not only more simple string configuration values, but other values that need to be represented differently in the UI e.g. by a ComboBox. The rest of this article is going to aim to come up with a design that will allow new config values to be added to this application with a minimum impact on the existing code.

Let's say we want to add a new config value called 'Username'. Forgetting the above design for a moment, what's the most low impact way you can think of to add this to the system?

What if it were as simple as adding this line in bold?

var items = new ItemViewModel[]
{
    new ItemViewModel(name: "Server address"),
    new ItemViewModel(name: "Database name"),
    new ItemViewModel(name: "Username")
};

That's it. No other changes. No changes to any XAML.

Here's the ItemViewModel class:

public class ItemViewModel
{
    public string Name { get; private set; }
    public string Value { get; set; }

    public ItemViewModel(string name)
    {
        Name = name;
    }
}

The ConfigViewModel would need to change to contain a collection of ItemViewModels:

public class ConfigViewModel
{
    public ItemViewModel[] Items { get; private set; }

    public ConfigViewModel(ItemViewModel[] items)
    {
        Items = items;
    }
}

Here's the new XAML:

<Grid>
    <ItemsControl ItemsSource="{Binding Items}">
    
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel Grid.IsSharedSizeScope="True"/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition SharedSizeGroup="ColumnOne"/>
                        <ColumnDefinition Width="*"/>
                    </Grid.ColumnDefinitions>
                    <TextBlock Grid.Column="0" Text="{Binding Name}"/>
                    <TextBox Grid.Column="1" Text="{Binding Value}"/>
                </Grid>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    
    </ItemsControl>
</Grid>

This is certainly more complicated than the original XAML, but extensibility often comes at the cost of some increase in complexity. Whether it's worth it can only be judged on a case by case basis.

Let's break down what's going on here. We've got an ItemsControl which is bound to our collection of ItemViewModels. Each item in the collection is represented by a grid with two columns: one for the Name and the other for the Value.

The Grid.IsSharedSizeScope="True" and SharedSizeGroup="ColumnOne" parts aren't really important for this example. They just ensure that the first column is the same size for all config values which leads to a nicer look.

This is a good start, but it only deals with config items that can be represented by a simple TextBox. Let's take this further and add a new config value called 'Mode' that can have a value of either 'Development' or 'Production'. We want this to be represented in the GUI by a ComboBox.

As before we'll start with imagining the simplest way we could add this to the system:

var items = new ItemViewModel[]
{
    new ItemViewModel(name: "Server address"),
    new ItemViewModel(name: "Database name"),
    new ItemViewModel(name: "Username"),
    new ItemChoiceViewModel(name: "Mode", choices: new[] { "Development", "Production" })
};

The ItemChoiceViewModel class looks like this:

public class ItemChoiceViewModel : ItemViewModel
{
    public string[] Choices { get; private set; }

    public ItemChoiceViewModel(string name, string[] choices) 
    : base(name)
    {
        Choices = choices;
    }
}

Designing an extensible system means providing extension points in the right places. In order to identify the right places for extension points, you need to identify where change is likely. Once you've done that you can separate the places where change is likey from the places where it is not.

In order for this latest change to work, we need an extension point in the XAML - we need to identify the part that needs to change (highlighted in bold below).

<Grid>
    <ItemsControl ItemsSource="{Binding Items}">
    
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel Grid.IsSharedSizeScope="True"/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition SharedSizeGroup="ColumnOne"/>
                        <ColumnDefinition Width="*"/>
                    </Grid.ColumnDefinitions>
                    <TextBlock Grid.Column="0" Text="{Binding Name}"/>
                    <TextBox Grid.Column="1" Text="{Binding Value}"/>
                </Grid>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    
    </ItemsControl>
</Grid>

This line assumes that all config values will be represented by a TextBox, but we know we need the option to use a ComboBox.

Luckily, WPF has a feature that can provide the extension point we need:

<Grid>
    <ItemsControl ItemsSource="{Binding Items}">
    
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel Grid.IsSharedSizeScope="True"/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition SharedSizeGroup="ColumnOne"/>
                        <ColumnDefinition Width="*"/>
                    </Grid.ColumnDefinitions>
                    <TextBlock Grid.Column="0" Text="{Binding Name}"/>
                    <ContentControl Grid.Column="1" Content="{Binding}"/>
                </Grid>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    
    </ItemsControl>
</Grid>

The ContentControl is essentially a placeholder for some other content i.e. a UserControl. Specifying Content={Binding} means that we are binding the Content property to the view model itself (in this case the ItemViewModel object) rather than a property on the view model as we've seen earlier. What does this mean in practice? When the application runs, WPF will attempt to find a way of rendering the each ItemViewModel. If we don't tell it how to do so, it will just call ToString() on each object and show that in the GUI.

We can tell WPF how to render our objects using DataTemplates:

<Application 
    xmlns:viewModels="clr-namespace:MyProject.ViewModels"
    xmlns:views="clr-namespace:MyProject.Views">
    <Application.Resources>
        <DataTemplate DataType="{x:Type viewModels:ItemViewModel}">
            <views:ItemView/>
        </DataTemplate>
        <DataTemplate DataType="{x:Type viewModels:ItemChoiceViewModel}">
            <views:ItemChoiceView/>
        </DataTemplate>
    </Application.Resources>
</Application>

The above XAML means that whenever WPF needs to render an ItemViewModel, it will use the ItemView. Similarly whenever it needs to render an ItemChoiceViewModel, it will use the ItemChoiceView.

Here are the views:

<UserControl x:Class="MyProject.Views.ItemView">
    <Grid>
        <TextBox Text="{Binding Value}"/>
    </Grid>
</UserControl>

<UserControl x:Class="MyProject.Views.ItemChoiceView">
    <Grid>
        <ComboBox ItemsSource="{Binding Choices}" SelectedItem="{Binding Value}"/>
    </Grid>
</UserControl>

Therefore, the ItemViewModel will be rendered as a TextBox and the ItemChoiceViewModel will be rendered as a ComboBox. With this, we've achieved our goal. Adding a new config value involves adding a single line of C# to the existing code, and optionally adding a new view model and view (if the way we want the config value to appear in the GUI isn't already supported).