In
Part 1 I covered the window settings and the main activity display. We're now
ready to look at the ActivityDataObject detail displays. Details are stored as an XML string
in ActivityDataObject and consist of a Name and Description. A ComboBox lists all
activities by name and determines which activities are displayed in the other controls.
A TabControl shows 2 views of the details: a ListBox and a customized HeaderedItemsControl.
There is also a pair of TextBoxes and a Button to add new details to the selected
activity.
<StackPanel Grid.Column="1" Grid.Row="1" Margin="15,5,5,5">
<TextBlock FontSize="12" Text="Activity Detail Settings" Foreground="Firebrick" HorizontalAlignment="Center" Margin="0,5,0,5"/>
<ComboBox x:Name="detailItemList" ItemsSource="{Binding Activities}" DisplayMemberPath="Name" IsSynchronizedWithCurrentItem="True"/>
<TabControl Height="170" Margin="0,5,0,0">
<TabItem Header="ListBox">
<ContentControl ContentTemplate="{StaticResource DetailContentTemplate}" Content="{Binding Activities}" />
</TabItem>
<TabItem Header="ItemsControl" DataContext="{Binding ElementName=detailItemList, Path=SelectedItem}">
<TabItem.Resources>
<local:DetailDisplaySelector x:Key="DetailDisplaySelector" DefaultTemplate="{StaticResource DetailBodyDefaultTemplate}" AlternateTemplate="{StaticResource DetailBodyAlternateTemplate}"/>
<local:DetailsHeaderSelector x:Key="DetailsHeaderSelector" DefaultTemplate="{StaticResource DetailHeaderDefaultTemplate}" AlternateTemplate="{StaticResource DetailHeaderAlternateTemplate}"/>
</TabItem.Resources>
<HeaderedItemsControl DataContext="{Binding Path=Details, Converter={StaticResource XmlStringToXmlDocumentConverter}}"
ItemsSource="{Binding Path=/}" Header="{Binding ElementName=detailItemList, Path=SelectedItem}"
HeaderTemplateSelector="{StaticResource DetailsHeaderSelector}" ItemTemplateSelector="{StaticResource DetailDisplaySelector}"
Style="{StaticResource HeaderedItemsControlStyle}"/>
</TabItem>
</TabControl>
<DockPanel Margin="0,10,0,0">
<Label Content="Name" DockPanel.Dock="Left"/>
<TextBox x:Name="detailName" Text="name"/>
</DockPanel>
<DockPanel Margin="0,5,0,5">
<Label Content="Description" DockPanel.Dock="Left"/>
<TextBox x:Name="detailDescription" Text="description"/>
</DockPanel>
<Button Content="Add Detail" Command="{Binding ElementName=detailItemList, Path=SelectedItem.AddDetailCommand.Command}"
local:CommandBinder.Command="{Binding ElementName=detailItemList, Path=SelectedItem.AddDetailCommand}">
<Button.CommandParameter>
<MultiBinding Converter="{StaticResource ConcatenateStringsMultiConverter}" ConverterParameter=";">
<Binding ElementName="detailName" Path="Text"/>
<Binding ElementName="detailDescription" Path="Text"/>
</MultiBinding>
</Button.CommandParameter>
</Button>
</StackPanel>
There are a lot of new concepts being used here. First we'll look at the ListBox
TabItem. Instead of using a ListBox directly here there's a ContentControl
using a ContentTemplate. By binding this to the same Activities collection used
by the ComboBox we get just the single item that is selected by the ComboBox passed
to the ContentTemplate. Here's the template:
<DataTemplate x:Key="DetailContentTemplate">
<StackPanel DataContext="{Binding Path=Details, Converter={StaticResource XmlStringToXmlDocumentConverter}}">
<ListBox ItemsSource="{Binding XPath=DetailList/Detail}" ItemTemplate="{StaticResource XmlDetailTemplate}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled" MaxHeight="135"/>
</StackPanel>
</DataTemplate>
Here we see the first usage of a nested DataContext. To get binding access to the
XML detail data a converter is used to create an XmlDocument from the Details string
at the StackPanel level.
The ListBox can then use XPath Binding to get to the individual details. Now each
list data item consists of a single Detail element with Name and Description child
elements available for XPath Binding.
The other TabItem uses another nested DataContext, this time bound directly to the
ComboBox's SelectedItem. This DataContext could also bind directly to Activities
with the same result, thanks to automatic current item synchronization. A HeaderedItemsControl
uses two TemplateSelectors which are declared in the TabItem's Resources. These
selectors choose from a Default or Alternate template which are passed in as properties.
Here's the code from the header selector:
public class DetailsHeaderSelector : DataTemplateSelector
{
private DataTemplate _DefaultTemplate;
public DataTemplate DefaultTemplate
{
get { return _DefaultTemplate; }
set { _DefaultTemplate = value; }
}
private DataTemplate _alternateTemplate;
public DataTemplate AlternateTemplate
{
get { return _alternateTemplate; }
set { _alternateTemplate = value; }
}
public override DataTemplate SelectTemplate(object item, DependencyObject container)
{
ActivityDataObject dataItem = item as ActivityDataObject;
if (dataItem == null)
return null;
if (dataItem.Name == dataItem.Name.ToLower())
return AlternateTemplate;
return DefaultTemplate;
}
}
This method removes the usage of hardcoded template names and avoids exceptions
that may be thrown by using FindResource. The HeaderedItemsControl also uses a further
nested DataContext of its own that sets up the XmlDocument. The Header binds to
the selected ActivityDataObject outside of the DataContext and the ItemsSource binds
to the XmlDocument to access the list of details. Finally, a Style is applied to
set up the template for the control. The default template for a HeaderedItemsControl
doesn't actually display the content assigned to the Header so this needs to be
replaced here with a custom template. Here's the template part of the Style:
<ControlTemplate TargetType="{x:Type HeaderedItemsControl}">
<DockPanel>
<ContentPresenter DockPanel.Dock="Top" Content="{TemplateBinding Header}" ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}" ContentTemplate="{TemplateBinding HeaderTemplate}"/>
<Border DockPanel.Dock="Bottom" SnapsToDevicePixels="true" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" Padding="{TemplateBinding Padding}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
</ScrollViewer>
</Border>
</DockPanel>
</ControlTemplate>
A ContentPresenter is added to hold the Header content and an ItemsPresenter is
used to implicitly bind to the ItemsSource. The items and header both change their
appearance based on whether their data items have an all lowercase name. In flipping
through the detail data you'll notice the different text styles being applied by
the selected templates. The last control in this section of the app is a button
for adding details to the selected activity. Here's its XAML again:
<Button Content="Add Detail" Command="{Binding ElementName=detailItemList, Path=SelectedItem.AddDetailCommand.Command}"
local:CommandBinder.Command="{Binding ElementName=detailItemList, Path=SelectedItem.AddDetailCommand}">
<Button.CommandParameter>
<MultiBinding Converter="{StaticResource ConcatenateStringsMultiConverter}" ConverterParameter=";">
<Binding ElementName="detailName" Path="Text"/>
<Binding ElementName="detailDescription" Path="Text"/>
</MultiBinding>
</Button.CommandParameter>
</Button>
Since all of our data and logic is being held in the ViewModel class rather that
the code-behind we don't want to use a Click event handler for the button (event
handler methods can only exist in the code-behind of the XAML file using them, in
this case Window1.xaml.cs). We also
want to perform an action on a specific data item that is selected in another control. To accomplish this a Command is used to
bind the Button's click to an action in the ViewModel's data. This is an important part of the separation in
the M-V-VM pattern. The command technique used here is based on an example on
Dan
Crevier's blog. Look at the CommandModel, CommandBinder, and ActivityDataObject
classes to
see how this works. The AddDetailCommand property exists on the ActivityDataObject
itself and creates a new detail when its Command is executed. This way the WindowViewModel
class doesn't even need to keep track of the selected item outside of the ICollectionView
itself. Although not the usual method of using commands this works well for maintaining
the separation of the ViewModel and View. The MultiBinding here
puts together the name and description fields to pass into the single CommandParameter
property (there are many different ways to use this property since it is an Object).
The last two sections provide controls for setting the global State override and
for filtering the displayed activities.
<Border Grid.Row="2" Margin="5">
<StackPanel>
<CheckBox x:Name="GlobalStatesCB" Content="Set all activity states" IsChecked="{Binding UseGlobalState}" />
<StackPanel Orientation="Horizontal" IsEnabled="{Binding ElementName=GlobalStatesCB, Path=IsChecked}">
<RadioButton IsChecked="{Binding Path=GlobalState, Mode=TwoWay, Converter={StaticResource EnumMatchToBooleanConverter}, ConverterParameter=Off}" Content="Off" Margin="5"/>
<RadioButton IsChecked="{Binding Path=GlobalState, Mode=TwoWay, Converter={StaticResource EnumMatchToBooleanConverter}, ConverterParameter=Ready}" Content="Ready" Margin="5"/>
<RadioButton IsChecked="{Binding Path=GlobalState, Mode=TwoWay, Converter={StaticResource EnumMatchToBooleanConverter}, ConverterParameter=Starting}" Content="Starting" Margin="5"/>
<RadioButton IsChecked="{Binding Path=GlobalState, Mode=TwoWay, Converter={StaticResource EnumMatchToBooleanConverter}, ConverterParameter=On}" Content="On" Margin="5"/>
<RadioButton IsChecked="{Binding Path=GlobalState, Mode=TwoWay, Converter={StaticResource EnumMatchToBooleanConverter}, ConverterParameter=Stopping}" Content="Stopping" Margin="5"/>
</StackPanel>
</StackPanel>
</Border>
<Border Grid.Row="2" Grid.Column="1" Margin="5">
<StackPanel>
<TextBlock Text="Filter Out Categories"/>
<StackPanel Orientation="Horizontal">
<CheckBox IsChecked="{Binding Path=FilterCategory1, Mode=OneWayToSource}" Content="1" Margin="5"/>
<CheckBox IsChecked="{Binding Path=FilterCategory2, Mode=OneWayToSource}" Content="2" Margin="5"/>
<CheckBox IsChecked="{Binding Path=FilterCategory3, Mode=OneWayToSource}" Content="3" Margin="5"/>
<CheckBox IsChecked="{Binding Path=FilterCategory4, Mode=OneWayToSource}" Content="4" Margin="5"/>
</StackPanel>
</StackPanel>
</Border>
The GlobalState RadioButtons use the same method shown in the Category2 template
but bind to a different property. Changes to this property trigger code in the ViewModel
that sets the State value for all activities. The FilterCategory CheckBoxes each
bind to separate boolean properties, all of which are used in the Activities CollectionView
Filter. Here's the code for one of the properties along with the filtering method
and the CollectionView initialization:
public bool FilterCategory1
{
get { return _filterCategory1; }
set
{
if (_filterCategory1 == value)
return;
_filterCategory1 = value;
OnPropertyChanged("FilterCategory1");
Activities.Refresh();
}
}
private bool FilterMethod(object checkItem)
{
ActivityDataObject activity = checkItem as ActivityDataObject;
if (activity == null)
return false;
if (activity.Category == ActivityCategory.Category1)
return !FilterCategory1;
if (activity.Category == ActivityCategory.Category2)
return !FilterCategory2;
if (activity.Category == ActivityCategory.Category3)
return !FilterCategory3;
if (activity.Category == ActivityCategory.Category4)
return !FilterCategory4;
return false;
}
public WindowViewModel()
{
_activities = new ObservableCollection<ActivityDataObject>(DataLoader.GetXmlData());
_activitiesView = CollectionViewSource.GetDefaultView(_activities);
_activitiesView.Filter = new Predicate<object>(FilterMethod);
_useGlobalState = false;
_globalState = ActivityState.Off;
}
Each of the FilterCategory setters refreshes the Activities CollectionView to update
the filtering. WindowViewModel also implements the INotifyPropertyChanged interface to
send notices to WPF when bindings need to be updated. This is what the OnPropertyChanged
call is for. Activities wraps _activitiesView which is actually an ICollectionView
that is created by CollectionViewSource's GetDefaultView static method.
Try out the app and try making some changes of your own. The activity data is loaded
on startup from a TestData.xml file so you can easily try out different data settings.
Window1.xaml can load in Blend RTM so you can try out all of the functionality Blend
makes available for UI and data binding editing. When looking at the WindowViewModel
code notice how there are no references to any part
of the XAML UI. This type of class is easy to unit test and can be hooked up to
completely different XAML or even a WinForms or web UI! Here again
is the code.