During this week I was working on improving the UX on an internal business application we've been actively developing over the last 6 months. The existing UI utilised an ItemsControl with a DataTemplate containing a Grid. The result was an unattractive UI that was uneccessarily large. A big problem was the most common state of all controls within the DataTemplate was read only, which was accomplished by setting the IsEnabled property to false. The customer was complaining that the disabled controls were hard to read.
Unfortunately I don't have a "before" version to show here but it was indeed awful. My first attempt was to use a GridView for the read-only items and keep the existing ItemControl for entering new items in the grid, positioned above list of "Terminated" items. Unfortunately I ran into problems with the method we use to keep track of validation errors (there's a known 'bug' in WPF which we keep bumping into with this). With now only 2 weeks before go-live I didn't have time to code a work-around for the problem, which would involve changes that would impact the entire application and require full regression test.
I decided to merge the two areas together into one GridView and use two different DataTemplates selected at run-time based on if the item could be modified or not.
In WPF there are three way to modified DataTemplates
- Data trigger
- Value converter
- Template selector
I chose to use a template selector, which involves subclassing DataTemplateSelector and overriding the SelectTemplate method to return the desired data template.
Because I'm using a GridView I needed to create two data templates and a template selector for each GridViewColumn of potentially edittable data. Once created, each GridViewColumn's CellTemplateSelector property is set to the x:Key value of the appropriate template selector. When the GridView is populating each row it will use the specified template selector to determine the data template for each column.
I created a re-usable template selector called KeyedTemplateSelector which maps a data template to a particular value of a property.
[System.Windows.Markup.ContentProperty("Template")]
public class TemplateMaping
{
public string Key { get; set; }
public DataTemplate Template { get; set; }
}
[System.Windows.Markup.ContentProperty("DataTemplates")]
public class KeyedTemplateSelector : DataTemplateSelector
{
public ObservableCollection<TemplateMaping> DataTemplates { get; private set; }
public string KeyPropertyPath { get; set; }
private Dictionary<string, DataTemplate> templateLookup;
public KeyedTemplateSelector()
{
DataTemplates = new ObservableCollection<TemplateMaping>();
DataTemplates.CollectionChanged += DataTemplates_CollectionChanged;
templateLookup = new Dictionary<string, DataTemplate>();
}
void DataTemplates_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
IList convertersToProcess = null;
if (e.Action == NotifyCollectionChangedAction.Add ||
e.Action == NotifyCollectionChangedAction.Replace)
{
convertersToProcess = e.NewItems;
}
else if (e.Action == NotifyCollectionChangedAction.Remove)
{
foreach (TemplateMaping maping in e.OldItems)
this.templateLookup.Remove(maping.Key);
}
else if (e.Action == NotifyCollectionChangedAction.Reset)
{
this.templateLookup.Clear();
convertersToProcess = this.DataTemplates;
}
if (convertersToProcess != null && convertersToProcess.Count > 0)
{
foreach (TemplateMaping mapping in convertersToProcess)
{
templateLookup.Add(mapping.Key, mapping.Template);
}
}
}
public override DataTemplate SelectTemplate(object item, DependencyObject container)
{
FrameworkElement element = container as FrameworkElement;
if (element != null && item != null)
{
string key = EvaluatePropertyPath(item, KeyPropertyPath);
if (key == null)
return null;
DataTemplate templateKey;
if (templateLookup.TryGetValue(key, out templateKey))
{
return templateKey;
}
}
return null;
}
private static string EvaluatePropertyPath(object obj, string path)
{
if (string.IsNullOrEmpty(path))
return null;
object curObj = obj;
foreach (var item in path.Split('.'))
{
var property = curObj.GetType().GetProperty(item);
if (property != null)
{
curObj = property.GetValue(curObj, null);
if (curObj == null)
return null;
}
else
return null;
}
return curObj.ToString();
}
}
Once you've built the project containing this class, add a namespace to the desired xaml file.
xmlns:cts="clr-namespace:MySystem.Infrastructure.TemplateSelectors;assembly=MySystem.Infrastructure"
You'll need to add a KeyedTemplateSelector for every column. Because I wanted to select templates based on the value of the boolean CanModify property, I use the following xaml, adding a TemplateMapping for each possible value in the CanModify parameter.
<cts:KeyedTemplateSelector x:Key="customerDetailTemplateSelector" KeyPropertyPath="CanModify">
<cts:TemplateMaping Key="True" Template="{StaticResource activeCustomerColumn}" />
<cts:TemplateMaping Key="False" Template="{StaticResource inactiveCustomerColumn}" />
</cts:KeyedTemplateSelector>
The final step, once you've defined a KeyedTemplateSelector for each column is to hook it up to each GridViewColumn CellTemplateSelector property in xaml.
<ListView.View>
<GridView AllowsColumnReorder="False">
<cctl:FixedWidthColumn Header="Customer" CellTemplateSelector="{StaticResource customerDetailTemplateSelector}" FixedWidth="230" />
<cctl:FixedWidthColumn Header="Start Date" CellTemplateSelector="{StaticResource startDateTemplateSelector}" FixedWidth="150" />
<cctl:FixedWidthColumn Header="End Date" CellTemplateSelector="{StaticResource endDateTemplateSelector}" FixedWidth="150" />
<cctl:FixedWidthColumn Header="Frequency" CellTemplateSelector="{StaticResource frequencyTemplateSelector}" FixedWidth="150" />
<cctl:FixedWidthColumn Header="Amount" CellTemplateSelector="{StaticResource amountTemplateSelector}" FixedWidth="120" />
<GridViewColumn Header="Status" ccxtn:GridViewSort.PropertyName="Status">
<GridViewColumn.CellTemplate>
<DataTemplate>
<cctl:ReferenceItemControl ItemsSource="{Binding Path=DataContext.ArrangementToPayStatuses,ElementName=LayoutRoot}"
SelectedValuePath="StatusNumber" SelectedValue="{Binding Status}"
DisplayMemberPath="Status" HorizontalAlignment="Stretch" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Width="300">
<GridViewColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Button Name="DeleteButton" Content="Delete" Command="{StaticResource deleteArrangementToPay}" CommandParameter="{Binding }"
Style="{StaticResource arrangementToPayButtonStyle}" Visibility="{Binding ElementName=DeleteButton,Path=IsEnabled,Converter={StaticResource boolVisConverter}}"/>
<Button Name="CreateButton" Content="Create" Command="{StaticResource createArrangementToPay}" CommandParameter="{Binding }"
Style="{StaticResource arrangementToPayButtonStyle}" Visibility="{Binding ElementName=CreateButton,Path=IsEnabled,Converter={StaticResource boolVisConverter}}"/>
<Button Name="TerminateButton" Content="Terminate" Command="{StaticResource terminateArrangementToPay}" CommandParameter="{Binding }"
Style="{StaticResource arrangementToPayButtonStyle}" Visibility="{Binding ElementName=TerminateButton, Path=IsEnabled,Converter={StaticResource boolVisConverter}}"/>
<Button Name="ConfirmButton" Content="Confirm" Command="{StaticResource confirmArrangementToPay}" CommandParameter="{Binding }"
Style="{StaticResource arrangementToPayButtonStyle}" Visibility="{Binding ElementName=ConfirmButton,Path=IsEnabled,Converter={StaticResource boolVisConverter}}"/>
</StackPanel>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
I haven't included it, but a good modification would be to provide a default template to use when no TemplateMapping can be found for the current value of the KeyProperty.