How can I get a WPF ComboBox that is within a DataTemplate within an ItemsControl element to have, and always have, a default SelectedItem, all while sticking strictly to the MVVM pattern?
My goal is to define a list of "form fields" that then are translated, via templates, into actual form fields (i.e. - TextBox, ComboBox, DatePicker, etc.). The list of fields is 100% dynamic and fields can be added and removed (by the user) at any time.
The pseudo-implementation is:
MainWindow
-> Sets FormViewModel as DataContext
FormViewModel (View Model)
-> Populated the `Fields` Property
Form (View)
-> Has an `ItemsControl` element with the `ItemsSource` bound to FormViewModel's `Fields` Property
-> `ItemsControl` element uses an `ItemTemplateSelector` to select correct template based on current field's type**
FormField
-> Class that has a `DisplayName`, `Value`, and list of `Operators` (=, <, >, >=, <=, etc.)
Operator
-> Class that has an `Id` and `Label` (i.e.: Id = "<=", Label = "Less Than or Equal To")
DataTemplate
-> A `DataTemplate` element for each field type* that creates a form field with a label, and a `ComboBox` with the field's available Operators
*** The `Operators` ComboBox is where the issue occurs ***
** The actual field's "type" and the implementation contained therein is not included in this question as it's not relevant to the display issue.
Here are the primary classes required to generate the form, based on the pseudo-implementation above:
FormViewModel.cs
public class FormViewModel : INotifyPropertyChanged {
protected ObservableCollection<FormField> _fields;
public ObservableCollection<FormField> Fields {
get { return _fields; }
set { _fields = value; _onPropertyChanged("Fields"); }
}
public event PropertyChangedEventHandler PropertyChanged;
protected void _onPropertyChanged(string propertyName) {
if (PropertyChanged != null) PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
public FormViewModel() {
// create a sample field that has a list of operators
Fields = new ObservableCollection<FormField>() {
new FormField() {
DisplayName = "Field1",
Value = "Default Value",
Operators = new ObservableCollection<Operator>() {
new Operator() { Id = "=", Label = "Equals" },
new Operator() { Id = "<", Label = "Less Than" },
new Operator() { Id = ">", Label = "Greater Than" }
}
}
};
}
}
Form.xaml
<UserControl.Resources>
<ResourceDictionary Source="DataTemplates.xaml" />
</UserControl.Resources>
<ItemsControl
ItemsSource="{Binding Fields}"
ItemTemplateSelector="{StaticResource fieldTemplateSelector}">
<ItemsControl.Template>
<ControlTemplate TargetType="ItemsControl">
<ItemsPresenter />
</ControlTemplate>
</ItemsControl.Template>
</ItemsControl>
Form.xaml.cs
public partial class Form : UserControl {
public static readonly DependencyProperty FieldsProperty = DependencyProperty.RegisterAttached("Fields", typeof(ObservableCollection<FormField>), typeof(Form));
public ObservableCollection<FormField> Fields {
get { return ((ObservableCollection<FormField>)GetValue(FieldsProperty)); }
set { SetValue(FieldsProperty, ((ObservableCollection<FormField>)value)); }
}
public Form() {
InitializeComponent();
}
}
FieldTemplateSelector.cs
public class FieldTemplateSelector : DataTemplateSelector {
public DataTemplate DefaultTemplate { get; set; }
public override DataTemplate SelectTemplate(object item, DependencyObject container) {
FrameworkElement element = (container as FrameworkElement);
if ((element != null) && (item != null) && (item is FormField)) {
return (element.FindResource("defaultFieldTemplate") as DataTemplate);
}
return DefaultTemplate;
}
}
DataTemplates.xaml
<local:FieldTemplateSelector x:Key="fieldTemplateSelector" />
<DataTemplate x:Key="defaultFieldTemplate">
<StackPanel Orientation="Vertical">
<TextBlock Text="{Binding Path=DisplayName}" />
<TextBox Text="{Binding Path=Value, UpdateSourceTrigger=PropertyChanged}" />
<ComboBox
ItemsSource="{Binding Path=Operators}"
DisplayMemberPath="Label" SelectedValuePath="Id"
SelectedItem="{Binding SelectedOperator, Mode=TwoWay}"
HorizontalAlignment="Right"
/>
</StackPanel>
</DataTemplate>
FormField.cs
public class FormField : INotifyPropertyChanged {
public string DisplayName { get; set; }
public string Value { get; set; }
protected ObservableCollection<Operator> _operators;
public ObservableCollection<Operator> Operators {
get { return _operators; }
set {
_operators = value;
_onPropertyChanged("Operators");
}
}
protected Operator _selectedOperator;
public Operator SelectedOperator {
get { return _selectedOperator; }
set { _selectedOperator = value; _onPropertyChanged("SelectedOperator"); }
}
public event PropertyChangedEventHandler PropertyChanged;
protected void _onPropertyChanged(string propertyName) {
if (PropertyChanged != null) PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
Operator.cs
public class Operator {
public string Id { get; set; }
public string Label { get; set; }
}
The form is properly generated; All "form fields" in the Fields list are created as TextBox elements with their name's displayed as labels, and they each have a ComboBox full of operators. However, the ComboBox doesn't have an item selected by default.
My initial step to fix the issue was to set SelectedIndex=0 on the ComboBox; this didn't work. After trial and error, I opted to use a DataTrigger such as the following:
<ComboBox
ItemsSource="{Binding Path=Operators}"
DisplayMemberPath="Label" SelectedValuePath="Id"
SelectedItem="{Binding SelectedOperator, Mode=TwoWay}"
HorizontalAlignment="Right">
<ComboBox.Style>
<Style TargetType="{x:Type ComboBox}">
<Style.Triggers>
<!-- select the first item by default (if no other is selected) -->
<DataTrigger Binding="{Binding RelativeSource={RelativeSource Self}, Path=SelectedItem}" Value="{x:Null}">
<Setter Property="SelectedIndex" Value="0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ComboBox.Style>
</ComboBox>
The trigger I added will check if the current SelectedItem is null and, if so, set the SelectedIndex to 0. This works! When I run the application, each ComboBox has an item selected by default! But wait, there's more:
If an item is then removed from the Fields list and at any time added back, the ComboBox has no item selected again. Basicaly, what's happening is, when the field is created for the first time, the data-trigger selects the first item in the operators list and sets that as the field's SelectedItem. When the field is removed and then added back, SelectedItem is no longer null so the original DataTrigger doesn't work. Oddly enough, even though there is clearly a binding for the SelectedItem property, the currently-selected item is not being selected.
Summarized: When a ComboBox is used within a DataTemplate, the SelectedItem for the ComboBox is not using its bound property as a default value.
What I've tried:
DataTrigger when
SelectedItemis null to select the first item in the list.
Result: Correctly selects the item when the field is created; Loses the item when the field is removed from the display and then added back.Same as 1, plus a DataTrigger for when
SelectedItemis not null to re-select the first item in the list.
Result: Same as #1 Result + Correctly selects the first item in the list when the field is removed from the display and then added back; If the entireFieldslist itself is recreated using already-createdFormFielditems, the selected item is empty again. Also, it would be nice to pre-select the previously selected operator (not a requirement though).Used
SelectedIndexinstead ofSelectedItem, with - and without - DataTriggers (as in #1 and #2).
Result: Did not successfully select default items in either case, almost as if theSelectedIndexwas being read before theItemsSource.Used a DataTrigger to check the
Items.Countproperty; if it was greater-than-zero, set theSelectedItemto the first element in the list.
Result: Did not successfully select an item.Same as 4, but using
SelectedIndexinstead ofSelectedItem.
Result: Same as #1 ResultUsed
IsSynchronizedWithCurrentItemwith bothTrueandFalsevalues.
Result: Nothing selected.Re-ordered the XAML properties to place
SelectedItem(andSelectedIndex, when used) to be beforeItemsSource. This was done for every test as I've read online that it helps.
Result: Doesn't help.Tried different types of collections for the
Operatorsproperty. I've usedList,IEnumerable,ICollectionView, and am currently usingObservableCollection.
Result: All provided the same output, exceptIEnumerable- it lost the value after the field was removed/re-added.
Any help would be greatly appreciated.