Friday, November 6, 2020

Binding problem caused by SetProperty

 A popular implementation of the PropertyChanged event looks like this

public event PropertyChangedEventHandler PropertyChanged;
public void SetProperty<T>(ref T storageT value, [CallerMemberNamestring name = "")
{
    if (!Object.Equals(storagevalue))
    {
        storage = value;
        if (PropertyChanged != null)
            PropertyChanged(thisnew PropertyChangedEventArgs(name));
    }
}
 
If the value has changed, store it and notify the framework. You see it in many places on the Internet and I have copied and used it many times. But it causes a problem if you bind a Combobox.SelectedValue to an integer. Allow me to elucidate.

Start a new Visual Studio C#, WPF application. I used .Net Core. Call it "BindingToDataTable" (slightly wrong because it's binding to an integer that is the problem).

The XAML defines three combo boxes that are bound to the same data table but in different ways and a button that repopulates the data table.

<Window x:Class="BindingToDataTable.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:BindingToDataTable"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <RoutedCommand x:Key="RepopulateCommand"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource RepopulateCommand}" Executed="RepopulateCommand_Executed"/>
    </Window.CommandBindings>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="20"/>
            <RowDefinition Height="20"/>
            <RowDefinition Height="20"/>
            <RowDefinition Height="20"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="200"/>
            <ColumnDefinition Width="200"/>
        </Grid.ColumnDefinitions>
        <ComboBox Grid.Row="0" Grid.Column="0" ItemsSource="{Binding Names}" SelectedValuePath="ID" SelectedValue="{Binding SelectedNameID}" DisplayMemberPath="Name"/>
        <TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding SelectedNameID}"/>
        <ComboBox Grid.Row="1" Grid.Column="0" ItemsSource="{Binding Names}" SelectedValuePath="ID" SelectedValue="{Binding SelectedNameIDString}" DisplayMemberPath="Name"/>
        <TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding SelectedNameIDString}"/>
        <ComboBox Grid.Row="2" Grid.Column="0" ItemsSource="{Binding Names}" SelectedItem="{Binding SelectedName}" DisplayMemberPath="Name"/>
        <TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding SelectedName[ID]}"/>
        <Button Grid.Row="3" Grid.Column="0" Content="Repopulate" Command="{StaticResource RepopulateCommand}" Height="20" Width="200"/>
    </Grid>
</Window>

The code behind contains code to populate the data table and some properties.

using System;
using System.ComponentModel;
using System.Data;
using System.Runtime.CompilerServices;
using System.Windows;
namespace BindingToDataTable
{
    public partial class MainWindow : WindowINotifyPropertyChanged
    {
        private DataTable _Names = null;
        public DataTable Names
        {
            get { return _Names; }
            set { SetProperty(ref _Names, value); }
        }
        private int _SelectedNameID;
        public int SelectedNameID
        {
            get { return _SelectedNameID; }
            set { SetProperty(ref _SelectedNameID, value); }
        }
        private String _SelectedNameIDString;
        public String SelectedNameIDString
        {
            get { return _SelectedNameIDString; }
            set { SetProperty(ref _SelectedNameIDString, value); }
        }
        private DataRowView _SelectedName;
        public DataRowView SelectedName
        {
            get { return _SelectedName; }
            set { SetProperty(ref _SelectedName, value); }
        }
        public event PropertyChangedEventHandler PropertyChanged;
        public void SetProperty<T>(ref T storageT value, [CallerMemberNamestring name = "")
        {
            if (!Object.Equals(storagevalue))
            {
                storage = value;
                if (PropertyChanged != null)
                    PropertyChanged(thisnew PropertyChangedEventArgs(name));
            }
        }
        public MainWindow()
        {
            InitializeComponent();
            InitializeNames();
        }
        private void InitializeNames()
        {
            Names = CreateNamesTable();
            SelectedNameID = (int)Names.Rows[0]["ID"];
            SelectedNameIDString = Names.Rows[0]["ID"].ToString();
            SelectedName = Names.DefaultView[0];
        }
        private DataTable CreateNamesTable()
        {
            DataTable n = new DataTable();
            n.Columns.Add("ID"Type.GetType("System.Int32"));
            n.Columns.Add("Name"Type.GetType("System.String"));
            n.Rows.Add(0, "Anne");
            n.Rows.Add(1, "Bob");
            n.Rows.Add(2, "Stephan");
            return n;
        }
        private void RepopulateCommand_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
        {
            InitializeNames();
        }
    }
}

When you first run the program everything looks fine. Don't play with the combo boxes yet.


Now click the [Repopulate] button. Oh, dear!

You can see the first combo box did not set it's selected item correctly and has a binding error as indicated by the red border. The other two combo boxes repopulated correctly. Let's try an experiment. Start the program again, select a different name in the first combo box, and click [Repopulate] again. It worked correctly. That's a clue.



The first combo box binds SelectedValue to an integer.
The second combo box binds SelectedValue to a string
The third combo box binds SelectedItem to a DataRowView.

Here's what is happening - when you repopulate the Item Source, the SelectedValue is changed in curious ways. Strings and objects become null but integers are unchanged possibly because they can't hold a null value. When we populate SelectedNameID we set it to zero, SelectedNameString is set to "0", and SelectedName is set to a DataRowView. In each case, SetProperty is called. But the value of SelectedNameID didn't change so PropertyChanged is not called. That's the problem.

There are several ways to fix this problem.
  • Always call PropertyChanged from SetProperty. Could be side-effects.
    public event PropertyChangedEventHandler PropertyChanged;
    public void SetProperty<T>(ref T storageT value, [CallerMemberNamestring name = "")
    {
        storage = value;
        if (PropertyChanged != null)
            PropertyChanged(thisnew PropertyChangedEventArgs(name));
    }
    
  • Bind SelectedValue to a string (combo box 2). But if it's an integer, it should be stored as an integer.
  • Bind SelectedItem instead of SelectedValue (combo box 3 - my preferred solution)
  • Bind to a nullable integer (my second favorite solution)
    private int? _SelectedNameID;
    public int? SelectedNameID
    {
        get { return _SelectedNameID; }
        set { SetProperty(ref _SelectedNameID, value); }
    }
    
  • Set the bound integer to a non-zero value (-1) before repopulating the data table in InitializeNames. Guaranteed WTF during code review.
    private void InitializeNames()
    {
        SelectedNameID = -1;
        Names = CreateNamesTable();
        SelectedNameID = (int)Names.Rows[0]["ID"];
        SelectedNameIDString = Names.Rows[0]["ID"].ToString();
        SelectedName = Names.DefaultView[0];
    }
    



No comments:

Post a Comment