Reputation: 38416
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 entire Fields
list itself is recreated using already-created FormField
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 of SelectedItem
, with - and without - DataTriggers (as in #1 and #2).
Result: Did not successfully select default items in either case, almost as if the SelectedIndex
was being read before the ItemsSource
.
Used a DataTrigger to check the Items.Count
property; if it was greater-than-zero, set the SelectedItem
to the first element in the list.
Result: Did not successfully select an item.
Same as 4, but using SelectedIndex
instead of SelectedItem
.
Result: Same as #1 Result
Used IsSynchronizedWithCurrentItem
with both True
and False
values.
Result: Nothing selected.
Re-ordered the XAML properties to place SelectedItem
(and SelectedIndex
, when used) to be before ItemsSource
. 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 used List
, IEnumerable
, ICollectionView
, and am currently using ObservableCollection
.
Result: All provided the same output, except IEnumerable
- it lost the value after the field was removed/re-added.
Any help would be greatly appreciated.
Upvotes: 2
Views: 7730
Reputation: 141
Using ComboBoxes with databound SelectedItem, inside DataTemplate is tricky.. I resolved this by: instead of using SelectedItem, (TwoWay) bind only SelectedValue (to your custom type property - SelectedOperator) and set DisplayMemberPath (but NOT SelectedValuePath - to have the whole custom type instance as a value)
Upvotes: 0
Reputation: 38416
Though I restructured my application and the above issue is no longer present, I have also figured out a solution to solving it!
The steps:
Taking a hint from a comment by Will, I updated the Form
's codebehind to add a PropertyMetadata
callback to the FieldsProperty
.
The callback from #1 iterates through the entire list of fields and uses Dispatcher.BeginInvoke()
to invoke a Delegate-Action on an Input
-priority level that will set the current field's SelectedOperator
to the first operator in the field's Operators
list.
.BeginInvoke()
or any other lower priority, the update would attempt to access the field before it was GUI-generated and would fail.Removed the DataTriggers
from the Operators
ComboBox
in the DataTemplate
(now, it is the same as the first code-example for DataTemplates.xaml
in my question).
New, working code (updates only):
Form.cs
...
public static readonly DependencyProperty FieldsProperty =
DependencyProperty.RegisterAttached("Fields", typeof(ObservableCollection<FormField>), typeof(Form), new PropertyMetadata(_fieldsListUpdated));
...
// PropertyMetaData-callback for when the FieldsProperty is updated
protected static void _fieldsListUpdated(DependencyObject sender, DependencyPropertyChangedEventArgs args) {
foreach (FormField field in ((Form)sender).Fields) {
// check to see if the current field has valid operators
if ((field.Operators != null) && (field.Operators.Count > 0)) {
Dispatcher.CurrentDispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, (Action)(() => {
// set the current field's SelectedOperator to the first in the list
field.SelectedOperator = field.Operators[0];
}));
}
}
}
The slight caveat to the above is that the SelectedOperator
will always be set to the first in the list. For me, this isn't an issue - but I could see a case where the "last-selected-operator" would want to be re-selected.
After debugging, when the Field
is added back to the list of Fields
, it still retains the previous SelectedItem
value - and then the ComboBox
's SelectedIndex
is immediately set to -1
. Preventing this in the setter for FormField.SelectedOperator
(and by trying SelectedItem
/SelectedIndex
) doesn't help.
Instead, creating a second "placeholder" Property in FormField
named LastOperator
and setting it to the SelectedOperator
when the setter is passed null
, and then updating the field.Operator =
line in Form.cs
seems to work:
FormField.cs
...
public Operator SelectedOperator {
get { return _selectedOperator; }
set {
if (value == null) LastOperator = _selectedOperator;
_selectedOperator = value; _onPropertyChanged("SelectedOperator");
}
}
public Operator LastOperator { get; set; }
Form.cs
...
field.SelectedOperator = ((field.LastOperator != null) ? field.LastOperator : field.Operators[0]);
...
Upvotes: 1
Reputation: 11
Try the following:
FormField.cs
protected ObservableCollection<Operator> _operators;
public ObservableCollection<Operator> Operators {
get { return _operators; }
set {
_operators = value;
_onPropertyChanged("Operators");
}
}
private QuestionOption _selectedItem;
public QuestionOption SelectedItem
{
get
{
return _selectedItem;
}
set
{
if (_selectedItem != value)
{
if (SelectedIndex == -1)
SelectedIndex = Operators.IndexOf(value);
_selectedItem = value;
_onPropertyChanged("SelectedItem");
}
}
}
private int _selectedIndex = -1;
public int SelectedIndex
{
get { return _selectedIndex; }
set
{
if (_selectedIndex != value)
{
_selectedIndex = value;
_onPropertyChanged("SelectedIndex");
}
}
}
DataTemplate.xaml
<ComboBox Width="Auto"
ItemsSource="{Binding Operators}"
SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
SelectedIndex="{Binding SelectedIndex, Mode=TwoWay}"
DisplayMemberPath="Label" SelectedValuePath="Id">
As for ensuring that changes to the Fields fire the PropertyChanged event try the following to force the event to fire:
// Set the changes to the modifiedFormField placeholder
ObservableCollection<FormField> modifiedFormField;
this.Fields = new ObservableCollection<FormField>(modifiedFormField);
I experienced a similar issue whilst working on an MVVM Silverlight 5 Application and did something similar to this to get the binding working. The concepts should be interchangeable with WPF. Hope this helps.
Upvotes: 0