简体   繁体   中英

Cancel combobox selection in WPF with MVVM

I've got a combobox in my WPF application:

<ComboBox  ItemsSource="{Binding CompetitorBrands}" DisplayMemberPath="Value" 
   SelectedValuePath="Key" SelectedValue="{Binding Path=CompMfgBrandID, Mode=TwoWay,
   UpdateSourceTrigger=PropertyChanged}" Text="{Binding CompMFGText}"/>

Bound to a collection of KeyValuePair<string, string>

Here is the CompMfgBrandID property in my ViewModel:

public string CompMfgBrandID
{
    get { return _compMFG; }
    set
    {    
        if (StockToExchange != null && StockToExchange.Where(x => !string.IsNullOrEmpty(x.EnteredPartNumber)).Count() > 0)
        {
            var dr = MessageBox.Show("Changing the competitor manufacturer will remove all entered parts from the transaction.  Proceed?",
                "Transaction Type", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
            if (dr != DialogResult.Yes)
                return;
        }

        _compMFG = value;
        StockToExchange.Clear();

        ...a bunch of other functions that don't get called when you click 'No'...
        OnPropertyChanged("CompMfgBrandID");
    }
}

If you choose "yes", it behaves as expected. Items are cleared and the remaining functions are called. If I choose 'No', it returns and doesn't clear my list or call any of the other functions, which is good, but the combobox still displays the new selection. I need it to revert back to the original selection, as if nothing had changed, when the user picks 'No'. How can I accomplish this? I also tried adding e.Handled = true in codebehind, to no avail.

Very simple solution for .NET 4.5.1+:

<ComboBox SelectedItem="{Binding SelectedItem, Delay=10}" ItemsSource="{Binding Items}"  />

It's works for me in most cases. You can rollback selection in combobox, just fire NotifyPropertyChanged without value assignment.

This can be achieved in a generic and compact way using Blend's Generic Behavior .

The behavior defines a dependency property named SelectedItem , and you should put your binding in this property, instead of in the ComboBox's SelectedItem property. The behavior is in charge of passing changes in the dependency property to the ComboBox (or more generally, to the Selector), and when the Selector's SelectedItem changes, it tries to assign it to the its own SelectedItem property. If the assignment fails (probably because the bound VM proeprty setter rejected the assignment), the behavior updates the Selector's SelectedItem with the current value of its SelectedItem property.

For all sorts of reasons, you might encounter cases where the list of items in the Selector is cleared, and the selected item becomes null (see this question ). You usually don't want your VM property to become null in this case. For this, I added the IgnoreNullSelection dependency property, which is true by default. This should solve such problem.

This is the CancellableSelectionBehavior class:

using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Interactivity;

namespace MySampleApp
{
    internal class CancellableSelectionBehavior : Behavior<Selector>
    {
        protected override void OnAttached()
        {
            base.OnAttached();
            AssociatedObject.SelectionChanged += OnSelectionChanged;
        }

        protected override void OnDetaching()
        {
            base.OnDetaching();
            AssociatedObject.SelectionChanged -= OnSelectionChanged;
        }

        public static readonly DependencyProperty SelectedItemProperty =
            DependencyProperty.Register("SelectedItem", typeof(object), typeof(CancellableSelectionBehavior),
                new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedItemChanged));

        public object SelectedItem
        {
            get { return GetValue(SelectedItemProperty); }
            set { SetValue(SelectedItemProperty, value); }
        }

        public static readonly DependencyProperty IgnoreNullSelectionProperty =
            DependencyProperty.Register("IgnoreNullSelection", typeof(bool), typeof(CancellableSelectionBehavior), new PropertyMetadata(true));

        /// <summary>
        /// Determines whether null selection (which usually occurs since the combobox is rebuilt or its list is refreshed) should be ignored.
        /// True by default.
        /// </summary>
        public bool IgnoreNullSelection
        {
            get { return (bool)GetValue(IgnoreNullSelectionProperty); }
            set { SetValue(IgnoreNullSelectionProperty, value); }
        }

        /// <summary>
        /// Called when the SelectedItem dependency property is changed.
        /// Updates the associated selector's SelectedItem with the new value.
        /// </summary>
        private static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var behavior = (CancellableSelectionBehavior)d;

            // OnSelectedItemChanged can be raised before AssociatedObject is assigned
            if (behavior.AssociatedObject == null)
            {
                System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() =>
                {
                    var selector = behavior.AssociatedObject;
                    selector.SelectedValue = e.NewValue;
                }));
            }
            else
            {
                var selector = behavior.AssociatedObject;
                selector.SelectedValue = e.NewValue;
            }
        }

        /// <summary>
        /// Called when the associated selector's selection is changed.
        /// Tries to assign it to the <see cref="SelectedItem"/> property.
        /// If it fails, updates the selector's with  <see cref="SelectedItem"/> property's current value.
        /// </summary>
        private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            if (IgnoreNullSelection && (e.AddedItems == null || e.AddedItems.Count == 0)) return;
            SelectedItem = AssociatedObject.SelectedItem;
            if (SelectedItem != AssociatedObject.SelectedItem)
            {
                AssociatedObject.SelectedItem = SelectedItem;
            }
        }
    }
}

This is the way to use it in XAML:

<Window x:Class="MySampleApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="My Smaple App" Height="350" Width="525"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:local="clr-namespace:MySampleApp"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance local:MainWindowViewModel}">
    <StackPanel>
        <ComboBox ItemsSource="{Binding Options}">
            <i:Interaction.Behaviors>
                <local:CancellableSelectionBehavior SelectedItem="{Binding Selected}" />
            </i:Interaction.Behaviors>
        </ComboBox>
    </StackPanel>
</Window>

and this is a sample of the VM property:

private string _selected;

public string Selected
{
    get { return _selected; }
    set
    {
        if (IsValidForSelection(value))
        {
            _selected = value;
        }
    }
}

To achieve this under MVVM....

1] Have an attached behavior that handles the SelectionChanged event of the ComboBox. This event is raised with some event args that have Handled flag. But setting it to true is useless for SelectedValue binding. The binding updates source irrespective of whether the event was handled.

2] Hence we configure the ComboBox.SelectedValue binding to be TwoWay and Explicit .

3] Only when your check is satisfied and messagebox says Yes is when we perform BindingExpression.UpdateSource() . Otherwise we simply call the BindingExpression.UpdateTarget() to revert to the old selection.


In my example below, I have a list of KeyValuePair<int, int> bound to the data context of the window. The ComboBox.SelectedValue is bound to a simple writeable MyKey property of the Window .

XAML ...

    <ComboBox ItemsSource="{Binding}"
              DisplayMemberPath="Value"
              SelectedValuePath="Key"
              SelectedValue="{Binding MyKey,
                                      ElementName=MyDGSampleWindow,
                                      Mode=TwoWay,
                                      UpdateSourceTrigger=Explicit}"
              local:MyAttachedBehavior.ConfirmationValueBinding="True">
    </ComboBox>

Where MyDGSampleWindow is the x:Name of the Window .

Code Behind ...

public partial class Window1 : Window
{
    private List<KeyValuePair<int, int>> list1;

    public int MyKey
    {
        get; set;
    }

    public Window1()
    {
        InitializeComponent();

        list1 = new List<KeyValuePair<int, int>>();
        var random = new Random();
        for (int i = 0; i < 50; i++)
        {
            list1.Add(new KeyValuePair<int, int>(i, random.Next(300)));
        }

        this.DataContext = list1;
    }
 }

And the attached behavior

public static class MyAttachedBehavior
{
    public static readonly DependencyProperty
        ConfirmationValueBindingProperty
            = DependencyProperty.RegisterAttached(
                "ConfirmationValueBinding",
                typeof(bool),
                typeof(MyAttachedBehavior),
                new PropertyMetadata(
                    false,
                    OnConfirmationValueBindingChanged));

    public static bool GetConfirmationValueBinding
        (DependencyObject depObj)
    {
        return (bool) depObj.GetValue(
                        ConfirmationValueBindingProperty);
    }

    public static void SetConfirmationValueBinding
        (DependencyObject depObj,
        bool value)
    {
        depObj.SetValue(
            ConfirmationValueBindingProperty,
            value);
    }

    private static void OnConfirmationValueBindingChanged
        (DependencyObject depObj,
        DependencyPropertyChangedEventArgs e)
    {
        var comboBox = depObj as ComboBox;
        if (comboBox != null && (bool)e.NewValue)
        {
            comboBox.Tag = false;
            comboBox.SelectionChanged -= ComboBox_SelectionChanged;
            comboBox.SelectionChanged += ComboBox_SelectionChanged;
        }
    }

    private static void ComboBox_SelectionChanged(
        object sender, SelectionChangedEventArgs e)
    {
        var comboBox = sender as ComboBox;
        if (comboBox != null && !(bool)comboBox.Tag)
        {
            var bndExp
                = comboBox.GetBindingExpression(
                    Selector.SelectedValueProperty);

            var currentItem
                = (KeyValuePair<int, int>) comboBox.SelectedItem;

            if (currentItem.Key >= 1 && currentItem.Key <= 4
                && bndExp != null)
            {
                var dr
                    = MessageBox.Show(
                        "Want to select a Key of between 1 and 4?",
                        "Please Confirm.",
                        MessageBoxButton.YesNo,
                        MessageBoxImage.Warning);
                if (dr == MessageBoxResult.Yes)
                {
                    bndExp.UpdateSource();
                }
                else
                {
                    comboBox.Tag = true;
                    bndExp.UpdateTarget();
                    comboBox.Tag = false;
                }
            }
        }
    }
}

In the behavior I use ComboBox.Tag property to temporarily store a flag that skips the rechecking when we revert back to the old selected value.

Let me know if this helps.

I found a much simpler answer to this question by user shaun on another thread: https://stackoverflow.com/a/6445871/2340705

The basic problem is that the property changed event gets swallowed. Some would called this a bug. To get around that use BeginInvoke from the Dispatcher to force the property changed event to be put back onto the end of UI event queue. This requires no change to the xaml, no extra behavior classes, and a single line of code changed to the view model.

The problem is that once WPF updates the value with the property setter, it ignores any further property changed notifications from within that call: it assumes that they will happen as a normal part of the setter and are of no consequence, even if you really have updated the property back to the original value.

The way I got around this was to allow the field to get updated, but also queue up an action on the Dispatcher to "undo" the change. The action would set it back to the old value and fire a property change notification to get WPF to realize that it's not really the new value it thought it was.

Obviously the "undo" action should be set up so it doesn't fire any business logic in your program.

I had the same issue, causes by UI thread and the way that biding works. Check the this link: SelectedItem on ComboBox

The structure in the sample uses code behind but the MVVM is exactly the same.

I prefer "splintor's" code sample over "AngelWPF's". Their approaches are fairly similar though. I have implemented the attached behavior, CancellableSelectionBehavior, and it works as advertised. Perhaps it was just that the code in splintor's example was easier to plug into my application. The code in AngelWPF's attached behavior had references to a KeyValuePair Type that would have called for more code alteration.

In my application, I had a ComboBox where the items that are displayed in a DataGrid are based on the item selected in the ComboBox. If the user made changes to the DataGrid, then selected a new item in the ComboBox, I would prompt the user to save changes with Yes|NO|Cancel buttons as options. If they pressed Cancel, I wanted to ignore their new selection in the ComboBox and keep the old selection. This worked like a champ!

For those who frighten away the moment they see references to Blend and System.Windows.Interactivity, you do not have to have Microsoft Expression Blend installed. You can download the Blend SDK for .NET 4 (or Silverlight).

Blend SDK for .NET 4

Blend SDK for Silverlight 4

Oh yeah, in my XAML, I actually use this as my namespace declaration for Blend in this example:

xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"

Here is the general flow that I use (doesn't need any behaviors or XAML modifications):

  1. I just let the change pass through the ViewModel and keep track of whatever's passed in before. (If your business logic requires the selected item to not be in an invalid state, I suggest moving that to the Model side). This approach is also friendly to ListBoxes that are rendered using Radio Buttons as making the SelectedItem setter exit as soon as possible will not prevent radio buttons from being highlighted when a message box pops out.
  2. I immediately call the OnPropertyChanged event regardless of the value passed in.
  3. I put any undo logic in a handler and call that using SynchronizationContext.Post() (BTW: SynchronizationContext.Post also works for Windows Store Apps. So if you have shared ViewModel code, this approach would still work).

     public class ViewModel : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; public List<string> Items { get; set; } private string _selectedItem; private string _previouslySelectedItem; public string SelectedItem { get { return _selectedItem; } set { _previouslySelectedItem = _selectedItem; _selectedItem = value; if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs("SelectedItem")); } SynchronizationContext.Current.Post(selectionChanged, null); } } private void selectionChanged(object state) { if (SelectedItem != Items[0]) { MessageBox.Show("Cannot select that"); SelectedItem = Items[0]; } } public ViewModel() { Items = new List<string>(); for (int i = 0; i < 10; ++i) { Items.Add(string.Format("Item {0}", i)); } } } 

I did it in a similar way to what splintor has above.

Your view:

<ComboBox  
ItemsSource="{Binding CompetitorBrands}" 
DisplayMemberPath="Value" 
SelectedValuePath="Key" 
SelectedValue="{Binding Path=CompMfgBrandID, 
Mode=TwoWay,
UpdateSourceTrigger=Explicit}" //to indicate that you will call UpdateSource() manually to get the property "CompMfgBrandID" udpated 
SelectionChanged="ComboBox_SelectionChanged"  //To fire the event from the code behind the view
Text="{Binding CompMFGText}"/>

Below is the code for the event handler "ComboBox_SelectionChanged" from the code file behind the view. For example, if you view is myview.xaml, the code file name for this event handler should be myview.xaml.cs

private int previousSelection = 0; //Give it a default selection value

private bool promptUser true; //to be replaced with your own property which will indicates whether you want to show the messagebox or not.

private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            ComboBox comboBox = (ComboBox) sender;
            BindingExpression be = comboBox.GetBindingExpression(ComboBox.SelectedValueProperty);

            if (comboBox.SelectedValue != null && comboBox.SelectedIndex != previousSelection)
            {
                if (promptUser) //if you want to show the messagebox..
                {
                    string msg = "Click Yes to leave previous selection, click No to stay with your selection.";
                    if (MessageBox.Show(msg, "Confirm", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes) //User want to go with the newest selection
                    {

                        be.UpdateSource(); //Update the property,so your ViewModel will continue to do something
                        previousSelection = (int)comboBox.SelectedIndex;  
                    }
                    else //User have clicked No to cancel the selection
                    {
                        comboBox.SelectedIndex = previousSelection; //roll back the combobox's selection to previous one
                    }
                }
                else //if don't want to show the messagebox, then you just have to update the property as normal.
                {
                    be.UpdateSource();
                    previousSelection = (int)comboBox.SelectedIndex;
                }
            }
        }

I think the problem is that the ComboBox sets the selected item as a result of the user action after setting the bound property value. Thus the Combobox item changes no matter what you do in the ViewModel. I found a different approach where you don't have to bend the MVVM pattern. Here's my example (sorry that it is copied from my project and does not exactly match the examples above):

public ObservableCollection<StyleModelBase> Styles { get; }

public StyleModelBase SelectedStyle {
  get { return selectedStyle; }
  set {
    if (value is CustomStyleModel) {
      var buffer = SelectedStyle;
      var items = Styles.ToList();
      if (openFileDialog.ShowDialog() == true) {
        value.FileName = openFileDialog.FileName;
      }
      else {
        Styles.Clear();
        items.ForEach(x => Styles.Add(x));
        SelectedStyle = buffer;
        return;
      }
    }
    selectedStyle = value;
    OnPropertyChanged(() => SelectedStyle);
  }
}

The difference is that I completely clear the items collection and then fill it with the items stored before. This forces the Combobox to update as I'm using the ObservableCollection generic class. Then I set the selected item back to the selected item that was set previously. This is not recommended for a lot of items because clearing and filling the combobox is kind of expensive.

I would like to complete splintor's answer because I stumbled upon a problem with the delayed initialization in OnSelectedItemChanged :

When OnSelectedItemChanged is raised before AssociatedObject is assigned, using the System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke can have unwanted side effects, such as trying to initialize the newValue with the default value of the combobox selection.

So even if your ViewModel is up to date, the behaviour will trigger a change from the ViewModel's SelectedItem current value to the default selection of the ComboBox stored in e.NewValue . If your code triggers a Dialog Box, the user will be warned of a change although there is none. I can't explain why it happens, probably a timing issue.

Here's my fix

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Interactivity;

namespace MyApp
{
    internal class CancellableSelectionBehaviour : Behavior<Selector>
    {
        protected override void OnAttached()
        {
            base.OnAttached();

            if (MustPerfomInitialChange)
            {
                OnSelectedItemChanged(this, InitialChangeEvent);
                MustPerfomInitialChange = false;
            }

            AssociatedObject.SelectionChanged += OnSelectionChanged;
        }

        protected override void OnDetaching()
        {
            base.OnDetaching();

            AssociatedObject.SelectionChanged -= OnSelectionChanged;
        }

        public static readonly DependencyProperty SelectedItemProperty =
            DependencyProperty.Register("SelectedItem", typeof(object), typeof(CancellableSelectionBehaviour),
                new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedItemChanged));

        public object SelectedItem
        {
            get { return GetValue(SelectedItemProperty); }
            set { SetValue(SelectedItemProperty, value); }
        }

        public static readonly DependencyProperty IgnoreNullSelectionProperty =
            DependencyProperty.Register("IgnoreNullSelection", typeof(bool), typeof(CancellableSelectionBehaviour), new PropertyMetadata(true));

        /// <summary>
        /// Determines whether null selection (which usually occurs since the combobox is rebuilt or its list is refreshed) should be ignored.
        /// True by default.
        /// </summary>
        public bool IgnoreNullSelection
        {
            get { return (bool)GetValue(IgnoreNullSelectionProperty); }
            set { SetValue(IgnoreNullSelectionProperty, value); }
        }

        /// <summary>
        /// OnSelectedItemChanged can be raised before AssociatedObject is assigned so we must delay the initial change. 
        /// Using System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke has unwanted side effects.
        /// So we use this bool to know if OnSelectedItemChanged must be called afterwards, in OnAttached
        /// </summary>
        private bool MustPerfomInitialChange { get; set; }

        /// <summary>
        /// OnSelectedItemChanged can be raised before AssociatedObject is assigned so we must delay the initial change. 
        /// Using System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke has unwanted side effects.
        /// So we use this DependencyPropertyChangedEventArgs to save the argument needed to call OnSelectedItemChanged.
        /// </summary>
        private DependencyPropertyChangedEventArgs InitialChangeEvent { get; set; }

        /// <summary>
        /// Called when the SelectedItem dependency property is changed.
        /// Updates the associated selector's SelectedItem with the new value.
        /// </summary>
        private static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var behavior = (CancellableSelectionBehaviour)d;

            // OnSelectedItemChanged can be raised before AssociatedObject is assigned so we must delay the initial change.
            if (behavior.AssociatedObject == null)
            {
                behavior.InitialChangeEvent = e;
                behavior.MustPerfomInitialChange = true;               
            }
            else
            {
                var selector = behavior.AssociatedObject;
                selector.SelectedValue = e.NewValue;               
            }
        }

        /// <summary>
        /// Called when the associated selector's selection is changed.
        /// Tries to assign it to the <see cref="SelectedItem"/> property.
        /// If it fails, updates the selector's with  <see cref="SelectedItem"/> property's current value.
        /// </summary>
        private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            if (IgnoreNullSelection && (e.AddedItems == null || e.AddedItems.Count == 0)) return;
            SelectedItem = AssociatedObject.SelectedItem;
            if (SelectedItem != AssociatedObject.SelectedItem)
            {
                AssociatedObject.SelectedItem = SelectedItem;
            }
        }
    }
}

--Xaml

 <ComboBox SelectedItem="{Binding SelectedItem, Mode=TwoWay, Delay=10}" ItemsSource="{Binding Items}"  />

--ViewModel

private object _SelectedItem;
public object SelectedItem 
{
    get { return _SelectedItem;}
    set {
           if(_SelectedItem == value)// avoid rechecking cause prompt msg
            { 
               return;
            } 
            MessageBoxResult result = MessageBox.Show
                    ("Continue change?", MessageBoxButton.YesNo);
            if (result == MessageBoxResult.No)
            {
                ComboBox combo = (ComboBox)sender;
                handleSelection = false;
                combo.SelectedItem = e.RemovedItems[0];
                return;
            }
            _SelectedItem = value;
            RaisePropertyChanged(); 
        }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM