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
SelectedItem
is 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
SelectedItem
is 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 entireFields
list itself is recreated using already-createdFormField
items, the selected item is empty again. Also, it would be nice to pre-select the previously selected operator (not a requirement though).Used
SelectedIndex
instead ofSelectedItem
, with - and without - DataTriggers (as in #1 and #2).
Result: Did not successfully select default items in either case, almost as if theSelectedIndex
was being read before theItemsSource
.Used a DataTrigger to check the
Items.Count
property; if it was greater-than-zero, set theSelectedItem
to the first element in the list.
Result: Did not successfully select an item.Same as 4, but using
SelectedIndex
instead ofSelectedItem
.
Result: Same as #1 ResultUsed
IsSynchronizedWithCurrentItem
with bothTrue
andFalse
values.
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
Operators
property. 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.