Search code examples
.netwpfxamlbindingradio-button

WPF RadioButton Binding issue when linked with the SelectedItem of a Datagrid


First, sorry for my english. I have a problem in a project I'm working on and I can't put the finger on what is wrong.
I would really appreciate if someone can help me or give me an advice to solve this issue.
I could create a minimal reproduction case to better explain my issue:

Let's create a class called Foo:

    public class Foo
    {
        public Foo(byte _id, string _name)
        { 
            ID = _id;
            Name = _name;
        }
        public byte ID { get; set; }
        public string Name { get; set; }
        public bool Option1 { get; set; }
        public bool Option2 { get; set; }
        public bool Option3 { get; set; }
    }

Then in the MainWindow, let's create an 'ObservableCollection' called 'Foos' and populate it with some values:

    public partial class MainWindow : Window
    {
        public ObservableCollection<Foo> Foos { get; set; } = new ObservableCollection<Foo>();
        public MainWindow()
        {
            InitializeComponent();

            DataContext = this;

            Foos.Add(new Foo(1, "foo 1") { Option1 = true });
            Foos.Add(new Foo(2, "foo 2") { Option2 = true });
            Foos.Add(new Foo(3, "foo 3") { Option3 = true });

        }
    }

In the UI, I have now a datagrid binded to Foos displaying some elements (Name & ID).
On the right side I have some RadioButton linked to the 'SelectedItem' of the datagrid displaying the Option 1,2&3:

<StackPanel Orientation="Horizontal">
    <DataGrid x:Name="DtgSource" ItemsSource="{Binding Foos}"
              AutoGenerateColumns="False"
              CanUserAddRows="False"
              Width="100"
              SelectionChanged="DtgSource_SelectionChanged"
              SelectedValuePath="Name">
        <DataGrid.Columns>
            <DataGridTextColumn Binding="{Binding ID}" IsReadOnly="True"/>
            <DataGridTextColumn Binding="{Binding Name}" IsReadOnly="True"/>
        </DataGrid.Columns>
    </DataGrid>
    <StackPanel Orientation="Vertical" >
        <RadioButton GroupName="Options" IsChecked="{Binding ElementName=DtgSource,
            Path=SelectedItem.Option1}" Content="Option 1"/>
        <RadioButton GroupName="Options" IsChecked="{Binding ElementName=DtgSource,
            Path=SelectedItem.Option2}" Content="Option 2"/>
        <RadioButton GroupName="Options" IsChecked="{Binding ElementName=DtgSource,
            Path=SelectedItem.Option3}" Content="Option 3"/>
    </StackPanel>
</StackPanel>

The Problem is that sometimes, when I select an object in the datagrid, the options change even if I never clicked on any radiobutton: I have this behaviour:

  • Select Foo 1: radiobutton state: Op1:True | Op2:False | Op3:False
  • Select Foo 2: radiobutton state: Op1:False | Op2:True | Op3:False
  • Select Foo 3: radiobutton state: Op1:False | Op2:False | Op3:True
  • Select Foo 1: radiobutton state: Op1:True | Op2:False | Op3:False
  • Select Foo 3: radiobutton state: Op1:False | Op2:False |Op3:False

ui

Did I missed something or is there a better implementation to avoid this behaviour? On the project I'm working on, I have other controls than radiobutton and I face this issue only with the RadioButton.

I tried with other Control for the selection panel such as ListView and ListBox. -> same behaviour

I tried also to implement Foo differently to have more information to debug and to catch the moment the options are modified:
SelectionChanged method:

    private void DtgSource_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var d = sender as DataGrid;
        Foo selitem = d.SelectedItem as Foo;
        Console.WriteLine($"SelectionChanged to {d.SelectedValue}| options -> {selitem.Option1}|{selitem.Option2}|{selitem.Option3}");
    }

Option implementation for debugging:

    public class Foo
    {
        private bool option1;
        public bool Option1 { get => option1; set { if (option1 != value) { option1 = value; Console.WriteLine($"{Name}|Option1 set to {value}"); } } }
    //...

And as described, The output in the console is as follow:

SelectionChanged to foo 1| options -> True|False|False 
SelectionChanged to foo 2| options -> False|True|False  
SelectionChanged to foo 3| options -> False|False|True 
foo 3|Option3 set to False
SelectionChanged to foo 1| options -> True|False|False
SelectionChanged to foo 3| options -> False|False|False

By inserting a breakpoint on the 'set' of any options and looking at the call stack, I see that the call has been done by 'external code' -> the UI

/!\ Before asking this question I could find this : wpf RadioButton Binding Issue That seems related. But the accepted answer is saying to avoid using the 'GroupName' property. It's something that I tried and sadly, the behaviour is exactly the same.

As I said, any help would be really appreciated to understand what is happening here :)


Solution

  • Explanation

    TL;DR - RadioButton groups are problematic when two-way binding IsChecked to individual bool properties and the binding source changes.

    The underlying problem here is that each RadioButtons respective IsChecked properties only get updated one at a time when SelectedItem changes. However, RadioButton, in grouped mode, is programmed to update the other buttons in the group to false immediately when any one changes to true, which of course makes sense.

    The unfortunate side effect of this is that when you change the selection there is a period when the buttons are not all pointing to the same object, yet WPF still treats the buttons as a group, so the wrong objects' OptionX properties are getting set to false.

    Here's the step-by-step of what happens:

    Initial state - buttons are unbound. Select Foo1 in the grid.

    1. Button1.IsChecked = Foo1.Option1 (true). Other buttons are still unbound, no further action.
    2. Button2.IsChecked = Foo1.Option2 (false). No further action.
    3. Button3.IsChecked = Foo1.Option3 (false). No further action.

    Select Foo2 in the grid.

    1. Button1.IsChecked = Foo2.Option1 (false). No further action.
    2. Button2.IsChecked = Foo2.Option2 (true). However this also causes: Foo2.Option1.IsChecked = Button1.IsChecked = false and Foo1.Option3.IsChecked = Button3.IsChecked = false. The latter is not a typo; it's a mismatch, because Button3 is still bound to Foo1, though it's harmless because Foo1.Option3 was already false.
    3. Button3.IsChecked = Foo2.Option3 (false).

    Select Foo1 in the grid.

    1. Button1.IsChecked = Foo1.Option1 (true). This also causes : Foo2.Option2.IsChecked = Button2.IsChecked = false and Foo2.Option3.IsChecked = Button3.IsChecked = false. Another mismatch. It's harmless for Foo2.Option3 because it's false anyway, but it incorrectly forces Foo2.Option2 to be set to false, since Button2 hasn't yet had its binding source switched back to Foo1 and thus it falses out Foo2.Option2 instead of Foo1.Option2.
    2. Button2.IsChecked = Foo1.Option2 (false).
    3. Button3.IsChecked = Foo1.Option3 (false).

    Select Foo2 in the grid.

    1. Button1.IsChecked = Foo2.Option1 (false).
    2. Button2.IsChecked = Foo2.Option2 (false).
    3. Button3.IsChecked = Foo2.Option3 (false).

    So you see the problem is that the binding source changes for each RadioButton one at a time, rather than atomically. When any binding change results in a RadioButton becoming checked, the other buttons in the group are immediately affected even if their own binding sources haven't yet updated. Consequently you can't safely bind IsChecked with grouped RadioButtons to individual bool backing properties if the binding sources could change. Whether or not this could be called a bug in WPF is something I'll leave for others to opine on, but at least the behavior is explainable.


    Workaround

    If you want to stick with individual bool backing properties and don't want to deal with enums, one workaround would be to ungroup the RadioButtons and handle exclusivity on the view model side, like so:

    public class Foo : INotifyPropertyChanged
    {
        public Foo(byte _id, string _name)
        {
            ID = _id;
            Name = _name;
        }
        public byte ID { get; set; }
        public string Name { get; set; }
    
        bool _option1;
        public bool Option1
        {
            get => _option1;
            set
            {
                if (value == _option1)
                    return;
                _option1 = value;
                if (value)
                {
                    this.Option2 = false;
                    this.Option3 = false;
                }
                OnPropertyChanged();
            }
        }
    
        bool _option2;
        public bool Option2
        {
            get => _option2;
            set
            {
                if (value == _option2)
                    return;
                _option2 = value;
                if (value)
                {
                    this.Option1 = false;
                    this.Option3 = false;
                }
                OnPropertyChanged();
            }
        }
    
        bool _option3;
        public bool Option3 
        {
            get => _option3;
            set
            {
                if (value == _option3)
                    return;
                _option3 = value;
                if (value)
                {
                    this.Option1 = false;
                    this.Option2 = false;
                }
                OnPropertyChanged();
            }
        }
    
        public event PropertyChangedEventHandler? PropertyChanged;
    
        protected void OnPropertyChanged([CallerMemberName]string property = null)
        {
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
        }
    }
    

    (Don't omit the extra code for property change notification otherwise the UI won't update the other buttons when one changes).

    In XAML:

            <StackPanel x:Name="_stack"
                        Orientation="Vertical" DataContext="{Binding ElementName=DtgSource, Path=SelectedItem}">
                <RadioButton GroupName="1" 
                             IsChecked="{Binding Option1}" Content="Option 1" />
                <RadioButton GroupName="2"
                             IsChecked="{Binding Option2}" Content="Option 2" />
                <RadioButton GroupName="3"
                             IsChecked="{Binding Option3}" Content="Option 3" />
            </StackPanel>
    

    Here you need to assign different group names to override the default grouping behavior.

    Other Workarounds

    One drawback of the enum-less workaround is that it doesn't scale well and can lead to subtle errors if you don't false-out the right properties each time.

    If you're willing to use enums instead, Sir Rufo's example in the comment below is great and deserves to be its own answer. Another good alternative (my preferred actually) is the accepted answer to this question:

    How do you work around the binding problems with radio button groups

    The reason these work whereas the individual bool property bindings don't is a bit subtle, but ultimately they both prevent the backing property from being altered when a RadioButton gets unchecked by the UI due to being in a mutually exclusive group.