Search code examples
c#wpfxamldatagridwpfdatagrid

How do I notify a row in a WPF DataGrid that one of its cells was programmatically modified?


I am creating a DataGridRadioButtonColumn for my WPF project. Here is what it looks like:

public class DataGridRadioButtonColumn : DataGridBoundColumn
{
    private Dictionary<DataGridCell, RadioButton> _buttons = new Dictionary<DataGridCell, RadioButton>();

    public string Group { get; set; }

    public static readonly DependencyProperty GroupProperty = RadioButton.GroupNameProperty.AddOwner(
        typeof(DataGridRadioButtonColumn), new FrameworkPropertyMetadata("DefaultGroup"));

    protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem)
    {
        // Only generate the buttons once, to preserve the Group.
        if (_buttons.ContainsKey(cell))
        {
            return (_buttons[cell]);
        }
        var radioButton = new RadioButton { GroupName = Group };
        BindingOperations.SetBinding(radioButton, ToggleButton.IsCheckedProperty, Binding);
        _buttons.Add(cell, radioButton);
        return radioButton;
    }

    protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem)
    {
       // Use the same one we generated before.
       return _buttons[cell];
    }

    protected override object PrepareCellForEdit(FrameworkElement editingElement, RoutedEventArgs editingEventArgs)
    {
        var radioButton = editingElement as RadioButton;
        if (radioButton == null) return null;
        return radioButton.IsChecked;
    }
}

And here is an example of its use:

<local:DataGridRadioButtonColumn
    Width="0.33*"
    Binding="{Binding PrimaryChecked, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
    Group="Group1"
    Header="PRI" />

Everything works as I expect it to, including that clicking on a radio button "unchecks" the one that was originally selected. This unchecking works by virtue of the Group dependency property.

The only problem I am having is that the unchecked radio button does not register as a row edit in the grid. Only the radio button that I clicked registers a row edit, and in order for the data to be saved correctly both rows (the one containing the radio button that I clicked, and the one containing the radio button that was unchecked) must be saved.

How do I tell the data row whose radio button was unchecked that it was edited, so that it properly updates the matching tuple in the DataGrid's bound collection?

Note that I implement the IEditableObject interface in the Model that's used in each row, so it's not as simple as just relying on INotifyPropertyChanged. It really needs to trigger a BeginEdit() in the DataGrid Row. I don't think that the programmatic clearing of the radio button fires PropertyChanged anyway, because the change is not reflected in the underlying Model object.


As requested, here is an MCVE (or the better part of one, anyway):

APP.XAML

<Application x:Class="WpfApp11.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfApp11"
             StartupUri="MainWindow.xaml">
    <Application.Resources>

    </Application.Resources>
</Application>

MainWindow.XAML

<Window
    x:Class="WpfApp11.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:local="clr-namespace:WpfApp11"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="MainWindow"
    Width="800"
    Height="450"
    mc:Ignorable="d">
    <Grid>
        <DataGrid ItemsSource="{Binding Items, UpdateSourceTrigger=PropertyChanged}">
            <DataGrid.Columns>
                <DataGridTextColumn
                    Width="0.7*"
                    Binding="{Binding Path=Name, Mode=TwoWay}"
                    Header="Name" />
                <local:DataGridRadioButtonColumn
                    Width="0.3*"
                    Binding="{Binding PrimaryChecked, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                    Group="Group1"
                    Header="PRI" />

            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

Model

using PropertyChanged; //Fody

namespace WpfApp11
{
    [AddINotifyPropertyChangedInterface]  // PropertyChanged.Fody
    public class Model
    {
        public string Name { get; set; }
        public bool? PrimaryChecked
        {
            get;
            set;
        }
    }
}

ViewModel

using System.Collections.ObjectModel;
using PropertyChanged; // Fody

namespace WpfApp11
{
    [AddINotifyPropertyChangedInterface] // PropertyChanged.Fody
    public class ViewModel
    {
        public ViewModel()
        {
            Items = new ObservableCollection<Model>
            {
                new Model {Name = "George"},
                new Model {Name = "Fred"},
                new Model {Name = "Tom"},
            };
        }
        public ObservableCollection<Model> Items { get; set; }
    }
}

As you can see, this code is unremarkable.

Here is where it gets interesting. Let's place an IEditableObject implementation on the Model. IEditableObject is recognized by the DataGrid; it allows you to provide things like change tracking and undo capability for each data row:

public class Model : EditableValidatableObject<Model>
{
    public string Name { get; set; }
    public bool? PrimaryChecked
    {
        get;
        set;
    }
}

EditableValidatableObject.cs

using PropertyChanged;
using System;
using System.ComponentModel;

namespace WpfApp11
{
    /// <summary>
    /// Provides an implementation of the IEditableObject and INotifyDataErrorInfo interfaces for data transfer objects.
    /// </summary><remarks>
    /// The IEditableObject interface is typically used to capture the BeginEdit, EndEdit, and CancelEdit semantics of a DataRowView.
    /// Making something an IEditableObject enables full editing and undo capabilities in a DataGrid.
    /// 
    /// The INotifyDataErrorInfo implementation uses Validation Attributes to validate the values of properties on the DTO.
    /// This information is used to indicate that a value entered by the user is invalid.
    /// 
    /// See T_Asset.cs and T_TestPoint.cs for usage examples.  
    /// </remarks>
    [AddINotifyPropertyChangedInterface]
    public abstract class EditableValidatableObject<T> : AnnotationValidationViewModel, IEditableObject
    {
        /// <summary>
        /// Constructor, sets up the INotifyDataErrorInfo implementation.
        /// </summary>
        private T Cache { get; set; }

        private object CurrentModel { get { return this; } }

        public RelayCommand CancelEditCommand
        {
            get { return new RelayCommand(CancelEdit); }
        }

        private bool IsDirty
        {
            get
            {
                if (Cache == null) return false;
                foreach (var info in CurrentModel.GetType().GetProperties())
                {
                    if (!info.CanRead || !info.CanWrite)
                        continue;

                    var oldValue = info.GetValue(Cache, null);
                    var currentValue = info.GetValue(CurrentModel, null);

                    if (oldValue == null && currentValue != null)
                        return true;

                    if (oldValue != null && !oldValue.Equals(currentValue))
                        return true;
                }
                return false;
            }
        }

        #region IEditableObject Implementation
        public bool Added { get; set; }
        public bool Edited { get; set; }
        public bool Deleted { get; set; }

        public void BeginEdit()
        {
            Cache = Activator.CreateInstance<T>();
            var type = CurrentModel.GetType();

            //Set Properties of Cache
            foreach (var info in type.GetProperties())
            {
                if (!info.CanRead || !info.CanWrite) continue;
                var oldValue = info.GetValue(CurrentModel, null);
                Cache.GetType().GetProperty(info.Name).SetValue(Cache, oldValue, null);
            }

            if (!Added && !Deleted && IsDirty)
            {
                Edited = true;
            }
        }

        public virtual void EndEdit()
        {
            if (!Added && !Deleted && IsDirty)
            {
                Edited = true;
            }
            Cache = default(T);
        }

        public void CancelEdit()
        {
            if (Cache == null) return;

            foreach (var info in CurrentModel.GetType().GetProperties())
            {
                if (!info.CanRead || !info.CanWrite) continue;
                var oldValue = info.GetValue(Cache, null);
                CurrentModel.GetType().GetProperty(info.Name).SetValue(CurrentModel, oldValue, null);
            }
        }
        #endregion
    }
}

AnnotationValidationViewModel is unremarkable; it's just an implementation of INotifyDataErrorInfo that uses data annotations for validation.

The critical part of the IEditableObject implementation above is the BeginEdit() method, which the data grid row uses to signal the underlying model that an edit has occurred. This method gets called when a Radio Button is clicked, but not when the other radio button is automatically unchecked.

Since BeginEdit() never gets called on the unchecked row, the Edited property never gets set. I rely on the Edited property to know which records I need to save back to the database.


Solution

  • After giving it some thought, I've decided to make some changes to my DataGridRadioButtonColumn implementation. It now looks like this:

    public class DataGridRadioButtonColumn : DataGridBoundColumn
    {
        private Dictionary<DataGridCell, RadioButton> _buttons = new Dictionary<DataGridCell, RadioButton>();
        private Dictionary<RadioButton, dynamic> _models = new Dictionary<RadioButton, dynamic>();
    
        public string Group { get; set; }
    
        public static readonly DependencyProperty GroupProperty = RadioButton.GroupNameProperty.AddOwner(
            typeof(DataGridRadioButtonColumn), new FrameworkPropertyMetadata("Group1"));
    
        protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem)
        {
            if (_buttons.ContainsKey(cell))
            {
                return (_buttons[cell]);
            }
            var radioButton = new RadioButton { GroupName = Group };
            radioButton.Unchecked += RadioButton_Unchecked;
    
            BindingOperations.SetBinding(radioButton, ToggleButton.IsCheckedProperty, Binding);
            _buttons.Add(cell, radioButton);
            _models.Add(radioButton, dataItem);
            return radioButton;
        }
    
        private void RadioButton_Unchecked(object sender, RoutedEventArgs e)
        {
            var button = sender as RadioButton;
            dynamic model = _models[button];
            try
            {
                model.Edited = true;
            }
            catch { }
        }
    
        protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem)
        {
           return _buttons[cell];
        }
    
        protected override object PrepareCellForEdit(FrameworkElement editingElement, RoutedEventArgs editingEventArgs)
        {
            var radioButton = editingElement as RadioButton;
            if (radioButton == null) return null;
            return radioButton.IsChecked;
        }
    }
    

    Here's how it works. I added a dictionary that captures the model for each of the Radio Buttons, and when a Radio Button is created, I add the model to the new dictionary and hook the Unchecked event on the Radio button:

        protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem)
        {
            if (_buttons.ContainsKey(cell))
            {
                return (_buttons[cell]);
            }
            var radioButton = new RadioButton { GroupName = Group };
            radioButton.Unchecked += RadioButton_Unchecked; // Added
    
            BindingOperations.SetBinding(radioButton, ToggleButton.IsCheckedProperty, Binding);
            _buttons.Add(cell, radioButton);
            _models.Add(radioButton, dataItem); // Added
            return radioButton;
        }
    

    Then, I just set the Edited property in the Model when the event fires:

    private void RadioButton_Unchecked(object sender, RoutedEventArgs e)
    {
        var button = sender as RadioButton;
        dynamic model = _models[button];
        try
        {
            // Notify IEditableObject implementation, if it exists.
            model.Edited = true;
        }
        catch { }
    }