Thursday 8 October 2009

WPF Collapse Converters for easier data layouts

Introduction

This article demonstrates how WPF Converters can be used in an innovate way to replace the use of  WPF Triggers in a common user interface task. The code contains a small set of self contained classes that can be easily added to any WPF project.

Background

A common design issue when presenting complex data is how much detail to display.  Too much detail and the user may get confused or find it more difficult to find the item that they are looking for. Too little and the user may not have all the information that they need. In the past many of these decisions were fixed at design time. Modern user interfaces dynamically hide and display additional data through the users actions.
This example uses a product list from a fictional bike company.
Products have a code, name, price, description, optional photo and a sale ended date.
The data is taken from the SQL Server Adventure Works sample database. All the data is contained in the code so there is no need to use an external database to run the example code.
The example application consists of a single window containing an ordinary list box filled with a list of objects from the Product class. A WPF DataTemplate is used to format each product in the list. This sort of layout can be found in many WPF samples and tutorials. What makes it more interesting is the checkboxes on the window that dynamically control how much information is displayed.
In the second screenshot the images and prices have been hidden and only the currently selected product has its description shown. There are additional automatic features as well. If an item has a blank Sale Ended date then the red line at the bottom is hidden and if an image is not available the image and its border are hidden.
The usual way of doing this
The data template contains contains controls to display each part of the product. Each UIElement  has a Visibility Property and setting that to the value Collapsed hides the element and forces its container to layout the other controls as if it was not there.  So all that is needed is a means for setting the Visibility property under the right circumstances.
The usual way to do this is with a DataTrigger, as show in this code example.
<TextBlock Margin="0,3,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" Width="Auto" Height="Auto" TextWrapping="Wrap" FontStyle="Italic" TextDecorations="None"
Grid.Column="1" Grid.ColumnSpan="1" Grid.Row="1" TextAlignment="Left" Text="{Binding Path=Description}" TextTrimming="WordEllipsis" FontSize="10" >
<TextBlock.Style>
<Style>
<Style.Triggers>
<DataTrigger
Binding="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type ListBoxItem} }, Path=IsSelected}"
Value="False">
<Setter Property="TextBlock.Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>



This is essentially a giant if statement that says if the parent item is not selected then hide the description. I do not like triggers because they add programming logic to the user interface, which is not a good thing. They are also over complicated, the trigger code above takes no account of the 'Always Display Descriptions' checkbox, to do so would need an even more complicated definition.


The Collapse Converters


The great thing about WPF is that there are usually several ways to achieve the same thing so I came up with an alternative way that uses converters instead of triggers.


Converters are used to convert one value type into another type during data binding. A classic case contained in the example code implements a converter that formats a C# DateTime into a string for display as the Sell End Data. Converters are C# classes that implement the IvalueConverter interface.


Before describing the collapse converters converters in detail I will show you how they are used.


public partial class CollapseConverterWindow : Window
{
public CollapseConverterWindow()
{
InitializeComponent();
listBoxProducts.ItemsSource = TWSampleWPFDatabase.SampleData.Instance.Products.Items;
}
} 


The code behind the example window is very minimal the only thing it does is initialize the Window and set the listbox's contents to an ObservableCollection of Product objects.  The product class and the same data is all contained in a separate sub-project that may be worth an article in itself, but all we need to know here is that the Product class has public properties for each item that we want to display.


<Window x:Class="TWCollapseConverters.CollapseConverterWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:TWCollapseConverters"
Title="Collapse Converter Example" Height="419" Width="396" Loaded="Window_Loaded">
<Window.Resources>
<local:CollapseOnBlankConverter x:Key="collapseOnBlankConverter"/>
<local:CollapseOnBlanksConverter x:Key="collapseOnBlanksConverter"/>
<local:CollapseOnBlanksFirstPairOrConverter x:Key="collapseOnBlanksFirstPairOrConverter"/>
<local:DateToShortDateStringConverter x:Key="dateToShortDateStringConverter"/>
<DataTemplate x:Key="ProductTemplate">
.....
</DataTemplate>
</Window.Resources>
<Grid Background="LightGray">
<Grid.RowDefinitions>
<RowDefinition Height="40" />
<RowDefinition />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="3" HorizontalAlignment="Center">
<CheckBox Height="16" Name="checkBox_AlwaysDisplayDescriptions" IsChecked="True">Always Display Descriptions</CheckBox>
<CheckBox Height="16" Margin="10,0,0,0" Name="checkBox_DisplayImages" IsChecked="True">Display Images</CheckBox>
<CheckBox Height="16" Margin="10,0,0,0" Name="checkBox_DisplayPrices" IsChecked="True">Display Prices</CheckBox>
</StackPanel>
<ListBox Grid.Row="1" Name="listBoxProducts"  ItemTemplate="{StaticResource ProductTemplate}" HorizontalContentAlignment="Stretch" />
</Grid>
</Window> 


The XAML window definition follows a simple layout, with the complexity contained in the DataTemplate. Instances of the converters are defined in the Windows.Resources section and then referenced in the DataTemplate.


Instead of listing the entire data template the parts that demonstrate each of the converts is shown below.

CollapseOnBlankConverter


The simplest of the three collapse converters takes a single value and converts it to either Visible or Collapse.


<StackPanel  Orientation="Horizontal" Visibility="{Binding ElementName=checkBox_DisplayPrices, Path=IsChecked, Converter={StaticResource collapseOnBlankConverter}}" Grid.ColumnSpan="2" HorizontalAlignment="Left">
<TextBlock Text="Price: $" Margin="4,0,0,0" Foreground="DarkGreen" FontWeight="Bold" />
<TextBlock Text="{Binding Path=ListPrice}" Margin="0,0,2,0" Foreground="DarkGreen" FontWeight="Bold" />
</StackPanel> 


In this example visibility of the container for the Price text is bound to the value of the 'Display Prices' checkbox. The IsChecked property returns a boolean value which cannot be directly bound to a visibility field, but by specifying collapseOnBlankConverter  as the Converter the check value will be converted into Visible or Collapse.


<StackPanel Grid.Column="1" Grid.Row="2" Orientation="Horizontal" Visibility="{Binding Path=SellEndDate, Converter={StaticResource collapseOnBlankConverter}}">
<TextBlock Text="Sale Ended On: " Margin="0,3,3,0" Foreground="DarkRed" FontWeight="Bold" />
<TextBlock Text="{Binding Path=SellEndDate, Converter={StaticResource dateToShortDateStringConverter}}" Margin="0,3,0,0" Foreground="DarkRed" FontWeight="Bold" />
</StackPanel> 


In the second example the visibility is bound to the value of the date and this too is converted into Visible or Collapse.

How the Converter interprets its input gives rise to its name. If a value can be considered blank then it will return Collapse otherwise it returns Visible. So a boolean false, a null reference, an empty string, a blank date, or the number zero are all considered blank and so cause the element to be hidden.

CollapseOnBlanksConverter


The second collapse converter takes a list of inputs instead on a single input. Defining a multiple binding cannot be done via the inline notation in the previous example. Instead it has to be defined the verbose way.


<Border Margin="5" BorderThickness="1" BorderBrush="DarkGray" HorizontalAlignment="Center" VerticalAlignment="Top">
<Border.Visibility>
<MultiBinding  Converter="{StaticResource collapseOnBlanksConverter}">
<MultiBinding.Bindings>
<Binding ElementName="checkBox_DisplayImages" Path="IsChecked"  />
<Binding Path="ThumbNailPhotoAvailable"  />
</MultiBinding.Bindings>
</MultiBinding>
</Border.Visibility>
<Image  Source="{Binding Path=ThumbNailPhoto}" />
</Border> 


This example the visibility of the image border element is bound to both the 'Display Images' check box and the boolean property of the Product that indicates if an image is available.

The CollapseOnBlanksConverter checks that all its inputs and if any one is blank it will return Collapse. That way the image will be hidden if is not available or the user has turned images off with the checkbox.


There is no limit to the number of conditions that you can set in this way.


The Image element will not display anything is its source is not defined so this converter may look unnecessary. However the border will still be displayed around a blank image and anyway the Product class returns an image valid image.


CollapseOnBlanksFirstPairOrConverter


The third converter is a variation on the second in that it takes the combined value of the first two elements and compares those with the rest.

e.g. CollapseOnBlanksConverter => (A && B && C && D) and CollapseOnBlanksFirstPairOrConverter => ((A || B) && C && D)


<TextBlock Margin="0,3,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" Width="Auto" Height="Auto" TextWrapping="Wrap" FontStyle="Italic" TextDecorations="None"
Grid.Column="1" Grid.ColumnSpan="1" Grid.Row="1" TextAlignment="Left" Text="{Binding Path=Description}" TextTrimming="WordEllipsis" FontSize="10" >
<TextBlock.Visibility>
<MultiBinding  Converter="{StaticResource collapseOnBlanksFirstPairOrConverter}">
<MultiBinding.Bindings>
<Binding ElementName="checkBox_AlwaysDisplayDescriptions" Path="IsChecked"  />
<Binding Path="IsSelected" RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType={x:Type ListBoxItem} }"   />
<Binding Path="Description"  />
</MultiBinding.Bindings>
</MultiBinding>
</TextBlock.Visibility>
</TextBlock> 


So in this example the logic is to always hide the description if it is blank and to show it if the item is selected or if the 'Always Display Descriptions' checkbox is ticked.

This does the same job and a lot more than the Trigger example given at the top of the article and is I think a lot easier to define.


The C# code


Now you have seen how they are used all that is left is to show you the Converters code implementation.

The code is in a single C# file TWCollapseConvertersLib.cs, which is part of the main project. It could be put in its own library but its too small to bother.


CollapseOnBlanksConverter


public class CollapseOnBlankConverter : IValueConverter
{
static public bool IsBlank(object value)
{
if ((value == null) ||
((value is bool) && ((bool)value == false)) ||
((value is string) && (((string)value).Length == 0)) ||
((value is int) && (((int)value) == 0)) ||
((value is double) && (((double)value) == 0)) ||
((value is decimal) && (((decimal)value) == (decimal)0)) ||
((value is System.DateTime) && ((System.DateTime)value == System.DateTime.MinValue))
)
return true;
return false;
}
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if (CollapseOnBlankConverter.IsBlank(value))
return System.Windows.Visibility.Collapsed;
return System.Windows.Visibility.Visible;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotSupportedException("unexpected Convertback");
}
} 


The static IsBlank method interprets whether the input is blank or not. The value is passed in a an object so it has to determine its type and then  perform the relevant comparison. If you are using a type not listed here then its a simple matter to add a new clause to the list.


The IvalueConverter requires we define conversion going both ways. We never need to convert back so an exceptions has been added as a sanity check.


CollapseOnBlanksConverter

This converter implements the multi value interface and so loops through the given list.


public class CollapseOnBlanksConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
foreach (object v in values)
if (CollapseOnBlankConverter.IsBlank(v))
return System.Windows.Visibility.Collapsed;
return System.Windows.Visibility.Visible;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotSupportedException("unexpected Convertback");
}
} 


CollapseOnBlanksFirstPairOrConverter

This converter is the most complex of the three as it has to treat the first two values differently, so the code is a little more complex.

However it is so much easier to Implement complex logic in C# than it is in XAML using Triggers.


public class CollapseOnBlanksFirstPairOrConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
for (int i = 0; i < values.Length; i++)
{
if ((i==0) && (values.Length > 1))
{
if ((CollapseOnBlankConverter.IsBlank(values[i])) && (CollapseOnBlankConverter.IsBlank(values[i+1])))
return System.Windows.Visibility.Collapsed;
i++;
}
else if (CollapseOnBlankConverter.IsBlank(values[i]))
return System.Windows.Visibility.Collapsed;
}
return System.Windows.Visibility.Visible;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotSupportedException("unexpected Convertback");
}
} 


Conclusion

Hopefully this article will have shown you how WPF can be used in different ways from the standard tutorials and how a little extra C# coding can go a long way to make your XAML layout a lot easier to develop.


Download

Copyright (c) 2009 Tom Wright. All rights reserved.

This code is distributed under the Microsoft Public License (Ms-PL).


Exe file


Full Source code - VS2008

No comments:

Post a Comment