This was an interesting project that required a bit of hacking from time to time. But I managed it mostly with the help of multi-bindings and a couple value converters. This example covers every feature you requested, and has been encapsulated into a single Window
for ease of demonstration. First, let's start with the XAML for the window, where most of the magic happens:
<Window x:Class="TestWpfApplication.BoundRadioButtonListBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
xmlns:local="clr-namespace:TestWpfApplication"
Title="BoundRadioButtonListBox" Height="200" Width="500"
DataContext="{Binding RelativeSource={RelativeSource Self}}">
<Window.Resources>
<local:ItemContainerToIndexConverter x:Key="ItemContainerToIndexConverter"/>
<local:IndexMatchToBoolConverter x:Key="IndexMatchToBoolConverter"/>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ListBox ItemsSource="{Binding Models}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<ItemsControl x:Name="DescriptionList" ItemsSource="{Binding Descriptions}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<RadioButton Content="{Binding}" Margin="5"
Command="{Binding RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type ItemsControl}}, Path=DataContext.CheckCommand}"
CommandParameter="{Binding RelativeSource={RelativeSource Self}, Path=Tag}"
GroupName="{Binding RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type ItemsControl}}, Path=DataContext.GroupName}">
<RadioButton.Tag>
<MultiBinding Converter="{StaticResource ItemContainerToIndexConverter}">
<Binding RelativeSource="{RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}"
Mode="OneWay"/>
<Binding RelativeSource="{RelativeSource Self}"
Path="DataContext"/>
</MultiBinding>
</RadioButton.Tag>
<RadioButton.IsChecked>
<MultiBinding Converter="{StaticResource IndexMatchToBoolConverter}">
<Binding RelativeSource="{RelativeSource Self}"
Path="Tag"/>
<Binding RelativeSource="{RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}"
Path="DataContext.SelectedOption"/>
</MultiBinding>
</RadioButton.IsChecked>
</RadioButton>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Border Background="LightGray" Margin="15,5">
<RadioButton Content="Don't Know"
Command="{Binding CheckCommand}"
GroupName="{Binding GroupName}">
<RadioButton.CommandParameter>
<sys:Int32>-1</sys:Int32>
</RadioButton.CommandParameter>
</RadioButton>
</Border>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="1">
<Label>The selected index for each line is shown here:</Label>
<ItemsControl ItemsSource="{Binding Models}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Label Content="{Binding SelectedOption}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Grid>
The trick here is that the first ListBox
is bound to the top-level models. Each model's ItemTemplate
creates another embedded ItemsControl
, which we use to display the item descriptions. That is how we can support a dynamic number of descriptions (this works for any number).
Next, let's check out the code-behind for this window:
/// <summary>
/// Interaction logic for BoundRadioButtonListBox.xaml
/// </summary>
public partial class BoundRadioButtonListBox : Window
{
public ObservableCollection<LineModel> Models
{
get;
private set;
}
public BoundRadioButtonListBox()
{
Models = new ObservableCollection<LineModel>();
List<string> descriptions = new List<string>()
{
"Option 1", "Option 2", "Option 3"
};
LineModel model = new LineModel(descriptions, 2);
Models.Add(model);
descriptions = new List<string>()
{
"Option A", "Option B", "Option C", "Option D"
};
model = new LineModel(descriptions, 1);
Models.Add(model);
InitializeComponent();
}
}
public class LineModel : DependencyObject
{
public IEnumerable<String> Descriptions
{
get;
private set;
}
public static readonly DependencyProperty SelectedOptionProperty =
DependencyProperty.Register("SelectedOption", typeof(int), typeof(LineModel));
public int SelectedOption
{
get { return (int)GetValue(SelectedOptionProperty); }
set { SetValue(SelectedOptionProperty, value); }
}
public ICommand CheckCommand
{
get;
private set;
}
public string GroupName
{
get;
private set;
}
private static int Index = 1;
public LineModel(IEnumerable<String> descriptions, int selected)
{
GroupName = String.Format("Group{0}", Index++);
Descriptions = descriptions;
SelectedOption = selected;
CheckCommand = new RelayCommand((index) => SelectedOption = ((int)index));
}
}
All of this should be very clear. The LineModel
class represents the model you described in your question. Thus, it features a collection of string descriptions as well as a SelectedOption
property, which has been made a DependencyProperty
for automatic change notifications.
Next, the code for the two value converters:
public class ItemContainerToIndexConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length == 2 &&
values[0] is ItemsControl &&
values[1] is string)
{
ItemsControl control = values[0] as ItemsControl;
ContentPresenter item = control.ItemContainerGenerator.ContainerFromItem(values[1]) as ContentPresenter;
return control.ItemContainerGenerator.IndexFromContainer(item);
}
return -1;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
return null;
}
}
public class IndexMatchToBoolConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length == 2 &&
values[0] is int &&
values[1] is int)
{
return (int)values[0] == (int)values[1];
}
return false;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
return null;
}
}
The index-matching converter is extremely simple- it just compares two indices and returns true or false. The container to index converter is a bit more complex, and relies on a few ItemContainerGenerator
methods.
Now, the finished result, 100% data-bound:
The radio-buttons are generated on the fly and checking each radio-button results in the SelectedOption
property being updated on your model.