Search code examples
c#wpfdata-bindingdatagrid

Why is the DataGrid replacing a bound property with an old, invalid value when edited?


I've got a simple DataGrid bound to an ObservableCollection of view models. One column is a DataGridTemplateColumn whose CellTemplate is a TextBlock and CellEditingTemplate is a TextBox (I realize I could use a DataGridTextColumn, but I want to be explicit about which controls to use). The TextBox is configured to validate on errors.

<DataGrid
    ItemsSource="{Binding People}"
    AutoGenerateColumns="False"
    >
    <DataGrid.Columns>
        <DataGridTemplateColumn Header="First Name">
            <DataGridTemplateColumn.CellTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding FirstName}" />
                </DataTemplate>
            </DataGridTemplateColumn.CellTemplate>
            <DataGridTemplateColumn.CellEditingTemplate>
                <DataTemplate>
                    <TextBox Text="{Binding FirstName, UpdateSourceTrigger=PropertyChanged,
                                                       ValidatesOnDataErrors=True}" />
                </DataTemplate>
            </DataGridTemplateColumn.CellEditingTemplate>
        </DataGridTemplateColumn>
    </DataGrid.Columns>
</DataGrid>

The view model for each item (PersonViewModel) simply defines a FirstName property, and implements IDataErrorInfo for validation. I'm using the MVVM Light Toolkit for property notification.

public class PersonViewModel : ViewModelBase, IDataErrorInfo
{
    private string firstName;
    public string FirstName
    {
        get => firstName;
        set => Set(nameof(FirstName), ref firstName, value);
    }

    public string this[string columnName]
    {
        get
        {
            if (columnName == nameof(FirstName))
            {
                if (string.IsNullOrEmpty(FirstName))
                    return "First name cannot be empty";
            }

            return null;
        }
    }

    public string Error => null;
}

In addition, I've got a button that, when clicked, sets the FirstName of every person to "Hello".

public ICommand HelloCommand =>
    new RelayCommand(() =>
    {
        foreach (var person in People)
            person.FirstName = "Hello";
    });

Here's the issue: when I enter an invalid FirstName (that is, I set it to the empty string), and then click on the Hello button, it correctly replaces the FirstName with "Hello". But then if I try to edit it again, the FirstName is immediately replaced with the empty string.

As a test, I made it so that it's an error to have the string "a". Doing the same steps as above, the "Hello" string is replaced with "a" in the TextBox. It's as if the TextBox doesn't know that FirstName was changed to "Hello," even though the TextBlock correctly displays it and they're both bound to the same property.

Does anyone know what's going on or ways in which to solve this issue? The behavior I expected was for the TextBox to contain the value the bound property changed to (regardless of whether there was a validation error on that TextBox).

Note: I'm using .NET 4.0 because I have to.


Solution

  • Obviously, the reason of this behavior is that at the time of HelloCommand executing, the TextBox that initiated validation error does not already exist. And hence clearing of validation error can't proceed usual way.

    It is hard to say why the new value is not taken from the ViewModel at the moment of new TextBox creating and binding restoring. Even more interesting is where this erroneous value comes from. One may think, that if we take the new value of the FirstName property from the ViewModel and set it to TextBox.Text property in the DataContext_Changed event handler of the TextBox, then it should solve problem, because at this moment both the TextBox and the ViewModel are having a new (valid) value, and there are just no room where to take the wrong one from. But magically it still comes from somewhere :)

    Happily, there is a trick that helps to get around the problem. The idea is to cancel all pending binding operations before executing HelloCommand. It is not too obvious why it should work. After all at this point the BindingExpression that caused the error is not exists. Nevertheless it works.

    Give name to DataGrid:

    <DataGrid x:Name="myGrid" ItemsSource="{Binding People}"  AutoGenerateColumns="False">
    

    Add click handler to the button:

    <Button Content="Hello" Command="{Binding HelloCommand}" Click="Hello_Click"/>
    

    with that code

        private void Hello_Click(object sender, RoutedEventArgs e)
        {
            foreach (var bg in BindingOperations.GetSourceUpdatingBindingGroups(myGrid))
                bg.CancelEdit();
        }
    

    UPD

    As GetSourceUpdatingBindingGroups is not available in .NET 4.0, you could try this way:

    private void Hello_Click(object sender, RoutedEventArgs e)
    {
        for (int i = 0; i < myGrid.Items.Count; i++)
        {
            DataGridRow row = (DataGridRow)myGrid.ItemContainerGenerator.ContainerFromIndex(i);
            if (row != null && Validation.GetHasError(row))
            {
                row.BindingGroup?.CancelEdit();
            }
        }
    }
    

    It is not so elegant, but does literally the same.