wpfmvvmcomboboxbindingeventargs

Handling a ComboBox Selection using WPF,MVVM, and the SelectionChanged Event


I have a ComboBox. When users try change the selectedItem on the ComboBox, I want a MessageBox to pop up and confirm whether or not they want to change their selection. If not, the selection should not change. This is what I originally had:

SelectSiteView (XAML):

        <ComboBox Name="SiteSelectionComboBox"
                  ItemsSource="{Binding SiteList}"
                  SelectedItem="{Binding SelectedSite}"/>

SelectSiteViewModel:

        private List<string> siteList;
        public List<string> SiteList
        {
            get { return siteList; }
            set { siteList = value;
                OnPropertyChanged();
            }
        }

        private string selectedSite;
        public string SelectedSite
        {
            get { return selectedSite; }
            set {
                if (CanChangeSite())
                {
                    selectedSite = value;
                    OnPropertyChanged();
                }
                //if false, I DONT want the combobox to change it's selection. 
                //What I end up getting is a mismatch. The SelectedSite never gets updated to the new 'value', but the ComboBox still changes
            }
        }

        private bool CanChangeSite()
        {
            MessageBoxResult result = MessageBox.Show("Are you sure?", "WARNING", MessageBoxButton.YesNo, MessageBoxImage.Question);
            if (result == MessageBoxResult.Yes)
                return true;
            else return false;
        }

I couldn't get that to work. I'm guessing since the binding is two-ways, even though I don't update my SelectedSite value, the ComboBox still changes the SelectedItem value from the UI, leading to a case where my binding can be mismatched.

I then moved forward to try and handle it the SelectionChanged event and had no luck. Here's how I tried Implementing it:

SelectSiteView (XAML):

        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"

        <ComboBox Name="SiteSelectionComboBox"
                  ItemsSource="{Binding SiteList}"
                  SelectedItem="{Binding SelectedSite}">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="SelectionChanged">
                    <i:InvokeCommandAction Command="{Binding SelectionChangedCommand}" PassEventArgsToCommand="True"/>
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </ComboBox>

SelectSiteViewModel:

        public ICommand SelectionChangedCommand { get; set; }
        public SelectSiteViewModel(MainViewModel main)
        {
            SelectionChangedCommand = new RelayCommand(o => SelectionChangedAction(o));
        }
        public void SelectionChangedAction(object param)
        {
            MessageBox.Show("selection changed command activated, param = " + param.ToString());
            if (param == null)
                MessageBox.Show("NULL");
            //MessageBox.Show("Added item: " + param.AddedItems[0]); 
            //DOESNT WORK
        }

I try passing in my ComboBox selection event arguments as a parameter to the ViewModel. When I print its ToString value, I get: "System.Windows.Controls.SelectionChangedEventArgs" and on the debugger, I can see that it has "AddedItems" and "RemovedItems" properties that I'm interested in. However, I can't access these items. Is it because the object isn't passed correctly? How would I go about accessing these items so that I can reject the change in the comboBox? Is using this event handler the best way to go about doing this?

I saw a solution using MVVMLite, I would prefer not to use any additional dependencies if possible. Thanks in advance!


Solution

  • You don't want to handle UI events in your View Model in an MVVM application.

    This is a classic property validation scenario.
    You have to validate the property by implementing INotifyDataErrorInfo. Then your view model will ignore invalid property values for example when INotifyDataErrorInfo.HasErrors returns true or when INotifyDataErrorInfo.GetErrors returns an error for a particular property, the view model knows its in an invalid state.

    Signalling an invalid property value will then automatically (via binding engine) trigger the view to show an error indicator to the user.

    Forcing values in the GUI is not a recommended solution as this will appear like black magic to the user. From his point of view his selection will magically change to something else, probably unrecognized.

    In this context it is best to indicate the invalid selection and let the user change it explicitly.
    Or even better: disallow invalid input by disabling (grey out) or filtering invalid options. This way the user can only pick valid items. This is the most clean solution and makes displaying input errors obsolete, providing a smooth and fluent (uninterrupted) UX.

    I recommend filtering invalid input options from the UI as this is the most elegant solution. It improves UX in many ways. It doesn't interrupt the flow and it doesn't clutter the UI with invalid or disabled elements.

    If you want to force the binding target to refuse the invalid value, you can do this either from the control (would require to extend the control or implement related logic as an attached behavior) or from the view model.

    Important: since you are asking for a MVVM solution, all of the following examples, except the first and the last, expect the View Model to implement INotifyDataErrorInfo (or binding validation in general).
    The examples will also work for view level binding validation (Binding.ValidationRules, which you should try to avoid in a MVVM scenario).

    Filter invalid items for data presentation (recommended)

    This solution does not require INotifyDataErrorInfo or binding validation in general. It won't allow any errors. Therefore, there is no error feedback needed.

    Because in WPF the binding engine will automatically bind to the ICollectionView of any collection, we can simply use the default ICollectionView to filter invalid items in order to prevent them from showing up in the UI. Filtering this way won't modify the collection itself:

    MainViewModel.cs

    class MainViewModel : INotifyPropertyChanged
    {
      public ObservableCollection<object> SourceCollection { get; set; }
    
      public MainViewModel()
      {
        this.SourceCollection = new ObservableCollection<object>();
    
        ICollectionView collectionView = CollectionViewSource.GetDefaultView(this.SourceCollection);
        collectionView.Filter = IsValueValid;
      }
    
      // Return true if the item should appear in the collection view
      private bool IsValueValid(object itemToValidate) => true;
    }
    

    Force valid value in extended Control

    You would implement the logic inside the dependency property changed callback.
    In case of the ComboBox (or any Selector) we can handle the Selector.SelectionChanged event by overriding the handler:

    CustomComboBox.cs

    public class CustomComboBox : ComboBox
    {
      protected override void OnSelectionChanged(SelectionChangedEventArgs e)
      {
        base.OnSelectionChanged(e);
    
        BindingExpression selectedItemPropertyBinding = GetBindingExpression(Selector.SelectedItemProperty);
        var isSelectedItemInvalid = selectedItemPropertyBinding.HasValidationError;
        if (isSelectedItemInvalid)
        {
          this.SelectedItem = e.RemovedItems[0];
        }
      }
    }
    

    Force valid value in code-behind

    The example will handle the Selector.SelectionChanged event of the ComboBox in code-behind:

    MainWindow.xaml.cs

    private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
      var comboBox = sender as ComboBox;
    
      BindingExpression selectedItemPropertyBinding = comboBox.GetBindingExpression(Selector.SelectedItemProperty);
      if (selectedItemPropertyBinding.HasValidationError)
      {
        comboBox.SelectedItem = e.RemovedItems[0];
      }
    }
    

    Force valid value in View Model (not recommended)

    This solution does not require INotifyDataErrorInfo or binding validation in general. It won't allow any errors. Therefore, there is no error feedback needed.

    After validating the source property that is bound to the Selector.SelectedItem property, the view model class will override the invalid value if the validation has failed.

    Aside from design decisions (especially UX design), from the required code alone you can tell that this is not the most elegant way.:

    MainViewModel

    private object selectedDataItem;
    public object SelectedDataItem
    {
      get => this.selectedDataItem;
      set
      {
        if (value == this.SelectedDataItem)
        {
          return;
        }
        
        var oldItem = this.selectedDataItem;
        this.selectedDataItem = value;
        OnPropertyChanged();
    
        if (IsValueValid(value))
        {
          // Defer overriding the property value because we must leave the scope
          // to allow the binding to complete the current transaction.
          // Then we can start a new to actually override the current value.
          // This example uses a background thread to allow the current context to leave the scope.
          // Note: you can use Dispatcher.InvokeAsync (do not await it) to achieve the same. But you probably want to avoid tight coupling to the Singleton.
          Task.Run(() => this.SelectedDataItem = oldItem);
        }
      }
    }
    

    Alternatively, configure the Binding to execute asynchronously by setting Binding.IsAsync to true.
    This eliminates the need for the background thread or the Dispatcher.InvokeAsync in order to "trick" the Binding flow:

    <ComboBox SelectedItem="{Binding SelectedDataItem, IsAsync=True}" />
    
    private object selectedDataItem;
    public object SelectedDataItem
    {
      get => this.selectedDataItem;
      set
      {
        if (value == this.SelectedDataItem)
        {
          return;
        }
        
        var oldItem = this.selectedDataItem;
        this.selectedDataItem = value;
        OnPropertyChanged();
    
        if (IsValueValid(value))
        {
          // Because the Binding.IsAsync is set to true, 
          // the Binding can continue to leave the scope while the property executes. 
          // This allows the second PropertyChanged to trigger the Binding to update as expected.
          this.SelectedDataItem = oldItem;
        }
      }
    }