newfurniturey
newfurniturey

Reputation: 38416

Default SelectedItem for a WPF ComboBox inside a DataTemplate used in an ItemsControl not working

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:

  1. 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.

  2. 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).

  3. 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.

  4. 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.

  5. Same as 4, but using SelectedIndex instead of SelectedItem.
    Result: Same as #1 Result

  6. Used IsSynchronizedWithCurrentItem with both True and False values.
    Result: Nothing selected.

  7. 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.

  8. 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

Answers (3)

Tono
Tono

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

newfurniturey
newfurniturey

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:

  1. Taking a hint from a comment by Will, I updated the Form's codebehind to add a PropertyMetadata callback to the FieldsProperty.

  2. 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.

    • Without using .BeginInvoke() or any other lower priority, the update would attempt to access the field before it was GUI-generated and would fail.
  3. 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

Mduduzi Shelembe
Mduduzi Shelembe

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

Related Questions