Thursday, May 27, 2021

Solving DataGridColumn binding with custom markup

One of the things that bugs me still about WPF is the extra effort required to bind properties of DataGridColumns. I noticed that Infragistics has written a custom markup extension to simplify this on their XamDataGrid fields and field settings ie

AllowEdit="{igDP:FieldBinding IsVendorEditable}"

and I wondered how difficult it would be to do the same thing myself for Microsoft's DataGridColumns. The challenge here is to find the data context. The DataGridColumn is not part of the VisualTree so it doesn't have a DataContext. 

The markup extension only has a constructor and a ProvideValue method. We use the constructor to save off the parameters such as Path, and I planned on finding the DataContext and creating the binding in the ProvideValue method. But ProvideValue is called while the window is still being constructed. I get access to the DataGridColumn but it is also incomplete. In particular, the private DataGridOwner property is null.

So we have to wait until the DataGridColumn is initialized but, hold on, there's no Initialized event! The DataGrid has one, so does the Window but they are what we're trying to find. We have a dilemma here. The DataGridColumn doesn't have what we need and can't tell us when it will have it. We will have to approach this from another direction.

The Application object has a list of windows, one (I assume) of which is currently being initialized. If we add an Initialized event handler to all the windows currently being initialized we can look for our DataGridColumn in all the window's DataGrids and, if we find it, create the binding using that DataGrid's data context.

Time to code. Start a new WPF Framework project in VB and call it ColumnBinding. Add a class called ColumnBinding. Our binding will assume Mode=TwoWay and UpdateSourceTrigger=PropertyChanged. Its only parameter will be the path name. Here's the class and constructor. This code will show an error in the editor.

Option Strict On
Imports System.Windows.Markup
 
Public Class ColumnBindingExtension
    Inherits Markup.MarkupExtension
 
    Public Path As String
    Public dp As DependencyProperty
    Public dc As DataGridColumn = Nothing
 
    Public Sub New(Path As String)
        Me.Path = Path
    End Sub
 
End Class

Markup extensions must implement ProvideValue so let's write that. It grabs and validates the DependencyProperty and DependencyObject and then calls SetDataContext to find initializing windows and attach event handlers. It doesn't have a value to provide right now so it returns the UnsetValue.

Public Overrides Function ProvideValue(serviceProvider As IServiceProvider) As Object
    Dim pvt As IProvideValueTarget = DirectCast(serviceProvider.GetService(GetType(IProvideValueTarget)), IProvideValueTarget)
    dc = TryCast(pvt.TargetObject, DataGridColumn)
 
    If dc Is Nothing Then
        Throw New Exception("FieldBinding can only be used on DataGridColumn")
    End If
 
    dp = DirectCast(pvt.TargetProperty, DependencyProperty)
    SetDataContext()
    Return DependencyProperty.UnsetValue
End Function

Now is the time to add SetDataContext. It looks for Windows(s) that are currently being initialized and attaches an Initialized  event handler to them. 

Private Sub SetDataContext()
    Dim DataContext As Object = Nothing
    For Each window As Window In Application.Current.Windows.OfType(Of Window).Where(Function(w) Not w.IsInitialized)
        AddHandler window.Initialized, AddressOf CompleteBinding
    Next
End Sub

The CompleteBinding method creates the required binding in code. If the window that just initialized contains our target DataColumn then the DataColumn's private DataGridOwner will be populated and we can use the DataGrid's DataContext as our binding's Source.

Private Sub CompleteBinding(sender As Object, e As EventArgs)
    Dim dg As DataGrid = TryCast(dc.GetType().GetProperty("DataGridOwner", BindingFlags.Instance Or BindingFlags.NonPublic).GetValue(dc), DataGrid)
    If dg IsNot Nothing Then
        ' Create binding
        Dim b As New Binding(Path)
        b.Source = dg.DataContext
        b.Mode = BindingMode.TwoWay
        b.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
 
        BindingOperations.SetBinding(dc, dp, b)
    End If
    RemoveHandler DirectCast(sender, Window).Initialized, AddressOf CompleteBinding
End Sub

Now let's look at how we would consume this markup extension. We will create a small DataGrid and a combobox. The font size of one of the columns will be bound to the selected item in the combobox using our markup extension.

Change MainWindow.xaml to look like this. Our markup extension is in bold.

<Window x:Class="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:FieldBinding"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
       
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <ComboBox ItemsSource="{Binding FontSizes}" SelectedItem="{Binding MyFontSize}" Width="100"/>
 
        <DataGrid ItemsSource="{Binding Items}" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Pet" Binding="{Binding Description}" FontSize="{local:ColumnBinding MyFontSize}"/>
            </DataGrid.Columns>
        </DataGrid>
    </StackPanel>
</Window>

The code in MainWindow.xaml.vb is trivial. It defines some classes and properties and implements INotifyPropertyChanged.

Imports System.ComponentModel
 
Class MainWindow
    Implements INotifyPropertyChanged
 
    Public Class cItem
        Public Property Description As String
    End Class
 
    Private _Items As New List(Of cItem) From
        {
            New cItem() With {.Description = "Dog"},
            New cItem() With {.Description = "Cat"}
        }
 
    Public ReadOnly Property Items As List(Of cItem)
        Get
            Return _Items
        End Get
    End Property
 
    Private _FontSizes As New List(Of Double) From {10, 12, 14, 16, 20}
    Public ReadOnly Property FontSizes As List(Of Double)
        Get
            Return _FontSizes
        End Get
    End Property
 
    Private _MyFontSize As Double = 10
    Public Property MyFontSize As Double
        Get
            Return _MyFontSize
        End Get
        Set(value As Double)
            SetProperty(_MyFontSize, value)
        End Set
    End Property
 
    Public Event PropertyChanged As System.ComponentModel.PropertyChangedEventHandler Implements System.ComponentModel.INotifyPropertyChanged.PropertyChanged
 
    Public Function SetProperty(Of T)(ByRef storage As T, value As T, <System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing) As Boolean
        If Object.Equals(storage, value) Then Return False
        storage = value
        NotifyPropertyChanged(PropertyName)
        Return True
    End Function
 
    Public Sub NotifyPropertyChanged(<System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing)
        If PropertyName.StartsWith("set_", System.StringComparison.OrdinalIgnoreCase) Then
            PropertyName = PropertyName.Substring(4)
        End If
        RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs(PropertyName))
    End Sub
 
End Class

When you first run the application you see this.
  
Text is displayed in 10pt font


After selecting 16, the font size changes

As you can see, the ColumnBinding custom markup extension was able to create the required binding without using proxy elements etc.

Friday, May 7, 2021

Fully Functional Undo/Redo base class

 After my last blog I had some people ask me for details on how to implement some of my suggested enhancements. I also wanted to investigate how an editable data table could utilize the same logic. This is what I came up with. It supports...

  • Undo/Redo functionality with minimal wire-up in the inherited classes
  • Control of undo stack size
  • Ability to exclude properties from undo/redo tracking
  • Undo/redo properties in the DataContext
  • Ability to undo and redo add, delete, and updates to data table and data set properties
  • Grouping of consecutive changes to the same property into a single undo
  • Grouping of changes to multiple properties due to same user action into a single undo
  • Custom Undo and Redo buttons that are detected and wired-up by the base class
Start a new Visual Studio VB WPF (Framework) project and call it UndoClass.

Add a NotifyPropertyChanged class. This is a base class that implements INotifyPropertyChanged.

Imports System.ComponentModel
 
Public Class NotifyPropertyChanged
    Inherits Window
    Implements INotifyPropertyChanged
 
    Public Event PropertyChanged As System.ComponentModel.PropertyChangedEventHandler Implements System.ComponentModel.INotifyPropertyChanged.PropertyChanged
 
    ' When we target 4.5 we can add <CallerMemberName> here :-)
    Public Function SetProperty(Of T)(ByRef storage As T, value As T, <System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing) As Boolean
        If Object.Equals(storage, value) Then Return False
        storage = value
        NotifyPropertyChanged(PropertyName)
        Return True
    End Function
 
    Public Sub NotifyPropertyChanged(<System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing)
        If PropertyName.StartsWith("set_", System.StringComparison.OrdinalIgnoreCase) Then
            PropertyName = PropertyName.Substring(4)
        End If
        RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs(PropertyName))
    End Sub
End Class
 
Add the UndoButton and RedoButton classes. These custom buttons are automatically wired up by the base class. They have no special features other than their class.

Public Class UndoButton
    Inherits Button
 
End Class
 
Public Class RedoButton
    Inherits Button
 
End Class

Now for the fun bit. The UndoRedo class inherits NotifyPropertyChanged. All changes to properties are held in a stack of cChange. This class contains enough information to back-out a change or to re-apply it. The ChangeID is incremented whenever the user presses a key or mouse button. If multiple properties are created while ChangeID is the same, they need to be undone and redone as a group.

RowID, RowIndex, Table, and RowPriorState are all used for tracking changes to data tables. A data table must have a unique primary key defined for us to track changes. The value of this primary key is held in RowID. RowIndex is used to put deleted rows back where we found them. RowPriorState is used when we undelete a row so we can set it to Added or Modified as appropriate. For rows, the OldValue and NewValue are not the row objects but their ItemArrays.

Imports System.ComponentModel
Imports System.Data
Imports System.Reflection
 
Public Class UndoRedo
    Inherits NotifyPropertyChanged
 
    Private Class cChange
        Public ChangeID As Integer
        Public PropertyName As String
        Public RowID As Object
        Public RowIndex As Integer
        Public Table As DataTable
        Public PriorRowState As DataRowState
        Public OldValue As Object
        Public NewValue As Object
    End Class
 
End Class

Let's add a few fields. IsUndoingOrRedoing is used to prevent us from tracking changes due to an undo or redo operation. SetsAndProperty names is used to map from datasets to the properties they are bound to. Ditto TablesAndPropertyNames.

    Private UndoStack As New List(Of cChange)()
    Private RedoStack As New List(Of cChange)()
    Private IsUndoingOrRedoing As Boolean = False
    Private ChangeID As Integer = 0
    Private InitialValues As New Dictionary(Of String, Object)
    Private SetsAndPropertyNames As New Dictionary(Of DataSet, String)
    Private TablesAndPropertyNames As New Dictionary(Of DataTable, String)
    Private UndoCommand As ICommand
    Private RedoCommand As ICommand
 
    Public DontTrackProperties As String()
    Public MaxUndoCount As Integer = 100

We have a default constructor that wires up some event handlers. Note how we attach an event handler to the PropertyChanged event to track all changes to properties. We remove these handlers in our finalize method.

    Public Sub New()
        AddHandler MyBase.PropertyChanged, AddressOf TrackChange
        AddHandler MyBase.PreviewKeyDown, AddressOf TrackKeyDown
        AddHandler MyBase.PreviewMouseDown, AddressOf TrackMouseDown
        AddHandler MyBase.Loaded, AddressOf InitializeProperties
    End Sub
    Protected Overrides Sub Finalize()
        RemoveHandler MyBase.PropertyChanged, AddressOf TrackChange
        RemoveHandler MyBase.PreviewKeyDown, AddressOf TrackKeyDown
        RemoveHandler MyBase.PreviewMouseDown, AddressOf TrackMouseDown
        RemoveHandler MyBase.Loaded, AddressOf InitializeProperties
        MyBase.Finalize()
    End Sub

The TrackKeyDown and TrackMouseDown event handlers simply increment ChangeID. Multiple property changes that occur while the value of ChangeID is the same will be undone and redone as a group.

    Private Sub TrackMouseDown(sender As Object, e As MouseButtonEventArgs)
        ChangeID += 1
    End Sub
 
    Private Sub TrackKeyDown(sender As Object, e As KeyEventArgs)
        ChangeID += 1
    End Sub

TrackChange is called whenever a bound property is changed. Nothing happens if the change was the result of an undo or redo operation, if the property is not being tracked, or if the property name is empty.

We call AddDataEventHandlers to see if the property is a data table or data set. If it is, we attach RowModified event handlers but we don't need to continue treating it as a simple property.

If it is a second change to the same property we consolidate the change otherwise we get the prior value and add a cChange to the undo stack.

    Private Sub TrackChange(sender As Object, e As PropertyChangedEventArgs)
 
        If IsUndoingOrRedoing Then Exit Sub
        If DontTrackProperties.Contains(e.PropertyName) Then Exit Sub
        If String.IsNullOrEmpty(e.PropertyName) Then Exit Sub
 
        Dim PropertyName As String = e.PropertyName
        Dim type As Type = sender.GetType()
        Dim pi As PropertyInfo = type.GetProperty(PropertyName, BindingFlags.Public Or BindingFlags.Instance)
        Dim NewValue As Object = pi.GetValue(sender)
        Dim OldValue As Object = Nothing
        Dim PriorChange As cChange
 
        ' Did a dataset or datatable property change?
        If AddDataEventHandlers(sender, pi) Then Exit Sub
 
        ' No it was a simple property. Is this a second change to the same property
        If UndoStack.Count > 0 AndAlso UndoStack.Last().PropertyName = PropertyName Then
            UndoStack.Last().NewValue = NewValue
        Else
            ' It needs to be a new cChange - get the prior value
            PriorChange = UndoStack.LastOrDefault(Function(c) c.PropertyName = PropertyName)
            If PriorChange Is Nothing Then
                OldValue = InitialValues(PropertyName)
            End If
            ' If something really changed, add it to the stack
            If Not Object.Equals(OldValue, NewValue) Then
                TrimUndoStack()
                UndoStack.Add(New cChange() With {.ChangeID = ChangeID, .PropertyName = PropertyName, .OldValue = OldValue, .NewValue = NewValue})
            End If
        End If
 
        ' Any changes invalidate the redo stack
        RedoStack.Clear()
    End Sub

Here is the undo and redo logic.

    Friend Sub Undo_CanExecute(sender As Object, e As CanExecuteRoutedEventArgs)
        
' We don't undo changes that were stored before the user started typing

        e.CanExecute = (UndoStack.Any(Function(u) u.ChangeID > 0))
    End Sub
 
    Friend Sub Undo_Executed(sender As Object, e As ExecutedRoutedEventArgs)
 
        Dim Change As cChange = UndoStack.Last()
        Dim ChangeID As Integer = Change.ChangeID
 
        If Change.ChangeID = 0 Then Exit Sub
 
        IsUndoingOrRedoing = True
        Do
            UndoStack.Remove(Change)
            RedoStack.Add(Change)
 
            Dim PropertyName As String = Change.PropertyName
            Dim type As Type = sender.GetType()
            Dim pi As PropertyInfo = type.GetProperty(PropertyName, BindingFlags.Public Or BindingFlags.Instance)
            Dim NewValue As Object = Change.OldValue
 
            If Change.RowID Is Nothing Then
                ' It's a simple property
                pi.SetValue(sender, NewValue)
            Else
                Dim DT As DataTable = Change.Table
                If Change.OldValue Is Nothing AndAlso Change.NewValue IsNot Nothing Then
                    ' was added - need to delete
                    DT.Rows.Remove(DT.Rows.Find(Change.RowID))
                End If
                If Change.OldValue IsNot Nothing And Change.NewValue Is Nothing Then
                    ' was deleted - need to put back. Was it removed or flagged for deletion
                    Dim DR As DataRow = DT.Select(DT.PrimaryKey(0).ColumnName & "=" & Change.RowID, "", DataViewRowState.Deleted).FirstOrDefault()
                    If DR Is Nothing Then
                        ' It was removed, need to add row
                        Dim row As DataRow = DT.NewRow()
                        row.ItemArray = Change.OldValue
                        DT.Rows.InsertAt(row, Change.RowIndex)
                    Else
                        ' It was flagged for deletion, need to return to prior state
                        If Change.PriorRowState = DataRowState.Added Then
                            DR.SetAdded()
                        Else
                            DR.SetModified()
                        End If
                    End If
                End If
                If Change.OldValue IsNot Nothing AndAlso Change.NewValue IsNot Nothing Then
                    ' Row was updated - undo the update
                    DT.Rows.Find(Change.RowID).ItemArray = Change.OldValue
                End If
            End If
 
            Change = UndoStack.LastOrDefault()
            ' Keep undoing changes until we run out or the next change has a different change id
        Loop While Change IsNot Nothing AndAlso Change.ChangeID = ChangeID
        IsUndoingOrRedoing = False
    End Sub

     Friend Sub Redo_CanExecute(sender As Object, e As CanExecuteRoutedEventArgs)
        e.CanExecute = (RedoStack.Count > 0)
    End Sub
 
    Friend Sub Redo_Executed(sender As Object, e As ExecutedRoutedEventArgs)
 
        Dim Change As cChange = RedoStack.Last()
        Dim ChangeID As Integer = Change.ChangeID
 
        IsUndoingOrRedoing = True
        Do
            TrimUndoStack()
            RedoStack.Remove(Change)
            UndoStack.Add(Change)
 
 
            Dim PropertyName As String = Change.PropertyName
            Dim type As Type = sender.GetType()
            Dim pi As PropertyInfo = type.GetProperty(PropertyName, BindingFlags.Public Or BindingFlags.Instance)
            Dim NewValue As Object = Change.NewValue
 
            If Change.RowID Is Nothing Then
                pi.SetValue(sender, NewValue)
            Else
                Dim DT As DataTable = Change.Table
                If Change.OldValue Is Nothing And Change.NewValue IsNot Nothing Then
                    ' Insert row
                    Dim row As DataRow = DT.NewRow()
                    row.ItemArray = Change.NewValue
                    DT.Rows.InsertAt(row, Change.RowIndex)
                End If
                If Change.OldValue IsNot Nothing And Change.NewValue Is Nothing Then
                    ' Delete
                    DT.Rows.Remove(DT.Rows.Find(Change.RowID))
                End If
                If Change.OldValue IsNot Nothing And Change.NewValue IsNot Nothing Then
                    DT.Rows.Find(Change.RowID).ItemArray = Change.NewValue
                End If
            End If
 
            Change = RedoStack.LastOrDefault()
        Loop While Change IsNot Nothing AndAlso Change.ChangeID = ChangeID
        IsUndoingOrRedoing = False
 
    End Sub

When we have loaded, we need to do our initial wire-up. This involves creating the Undo and Redo commands and wiring them up the Undo and Redo code above and to any UndoButtons and RedoButtons that may be on the page. Then we loop through all the properties looking for data sets and data tables that we need monitor for changes.

    Private Sub InitializeProperties(sender As Object, e As RoutedEventArgs)

        Dim type As Type = sender.GetType()
 
        BindUndoAndRedoButtons(sender)
 
        For Each PI As PropertyInfo In type.GetProperties(BindingFlags.Public Or BindingFlags.Instance)
            AddDataEventHandlers(sender, PI)
            InitialValues.Add(PI.Name, PI.GetValue(sender))
        Next
    End Sub

Here is how we detect data tables being added or removed from data sets, data rows being added, removed or modified, and also store the initial values in data rows.

    Private Function AddDataEventHandlers(sender As Object, PI As PropertyInfo) As Boolean

        Select Case PI.PropertyType.Name
            Case "DataSet"
                Dim DS As DataSet = DirectCast(PI.GetValue(sender), DataSet)
                If DS IsNot Nothing Then
                    SetsAndPropertyNames.Add(DS, PI.Name)
                    AddHandler DS.Tables.CollectionChanged, AddressOf DataSet_CollectionChanged
                    LoadDataSetHandlers(DS, PI.Name)
                End If
                Return True
            Case "DataTable"
                Dim DT As DataTable = DirectCast(PI.GetValue(sender), DataTable)
                LoadDataTableHandlers(DT, PI.Name)
                Return True
        End Select
        Return False
    End Function
 
    Private Sub DataSet_CollectionChanged(sender As Object, e As CollectionChangeEventArgs)
        If e.Action = CollectionChangeAction.Add AndAlso TypeOf e.Element Is DataTable Then
            Dim DT As DataTable = DirectCast(e.Element, DataTable)
            Dim PropertyName As String = SetsAndPropertyNames(DT.DataSet)
            LoadDataTableHandlers(DT, PropertyName)
        End If
    End Sub
 
    Private Sub LoadDataSetHandlers(DS As DataSet, PropertyName As String)
        If DS Is Nothing Then Exit Sub
 
        For Each DT As DataTable In DS.Tables
            LoadDataTableHandlers(DT, PropertyName)
        Next
    End Sub
 
    Private Sub LoadDataTableHandlers(DT As DataTable, PropertyName As String)
        If DT Is Nothing Then Exit Sub
 
        TablesAndPropertyNames.Add(DT, PropertyName)
        AddHandler DT.RowChanged, AddressOf RowChanged
        AddHandler DT.RowDeleting, AddressOf RowDeleting
        SaveRowValues(DT, PropertyName)
 
    End Sub
 
    Private Sub SaveRowValues(DT As DataTable, PropertyName As String)
        Dim ID As Object
        Dim Index As Integer
        For Each row As DataRow In DT.Select()
            ID = row(DT.PrimaryKey(0))
            Index = DT.Select().ToList().IndexOf(row)
            TrimUndoStack()
            UndoStack.Add(New cChange() With {.ChangeID = ChangeID, .PropertyName = PropertyName, .PriorRowState = row.RowState, .RowID = ID, .RowIndex = Index, .Table = DT, .OldValue = Nothing, .NewValue = row.ItemArray})
        Next
    End Sub

Here is how we track data table add, modify, and delete changes.

    Private Sub RowDeleting(sender As Object, e As DataRowChangeEventArgs)
        Dim DT As DataTable = e.Row.Table
 
        If IsUndoingOrRedoing Then Exit Sub
        If DT.PrimaryKey Is Nothing OrElse DT.PrimaryKey.Count = 0 Then Exit Sub
 
        Dim PropertyName As String = TablesAndPropertyNames(DT)
        Dim ID As Object = e.Row(e.Row.Table.PrimaryKey(0))
        Dim Index As Integer = e.Row.Table.Rows.IndexOf(e.Row)
        Dim priorChange As cChange = UndoStack.LastOrDefault(Function(c) DT.Equals(c.Table) AndAlso TypeOf c.NewValue Is Object() AndAlso c.RowID = ID)
        Dim priorItemArray As Object() = Nothing
 
        If priorChange IsNot Nothing Then
            priorItemArray = priorChange.NewValue
        End If
 
        TrimUndoStack()
        UndoStack.Add(New cChange() With {.ChangeID = ChangeID, .PropertyName = TablesAndPropertyNames(e.Row.Table), .RowID = ID, .RowIndex = Index, .Table = DT, .PriorRowState = e.Row.RowState, .OldValue = priorItemArray, .NewValue = Nothing})
 
    End Sub
 
    Private Sub RowChanged(sender As Object, e As DataRowChangeEventArgs)
        Dim DT As DataTable = e.Row.Table
 
        If IsUndoingOrRedoing Then Exit Sub
        If DT.PrimaryKey Is Nothing OrElse DT.PrimaryKey.Count = 0 Then Exit Sub
 
        Dim PropertyName As String = TablesAndPropertyNames(DT)
        Dim ID As Object = e.Row(e.Row.Table.PrimaryKey(0))
        Dim Index As Integer = DT.Rows.IndexOf(e.Row)
        Dim priorChange As cChange = UndoStack.LastOrDefault(Function(c) c.PropertyName = PropertyName AndAlso TypeOf c.NewValue Is Object() AndAlso c.RowID = ID)
        Dim priorItemArray As Object() = Nothing
 
        If priorChange IsNot Nothing Then
            priorItemArray = priorChange.NewValue
        End If
 
        TrimUndoStack()
        Select Case e.Action
            Case DataRowAction.Add
                UndoStack.Add(New cChange() With {.ChangeID = ChangeID, .PropertyName = TablesAndPropertyNames(e.Row.Table), .RowID = ID, .RowIndex = Index, .Table = DT, .OldValue = Nothing, .NewValue = e.Row.ItemArray})
            Case DataRowAction.Change
                UndoStack.Add(New cChange() With {.ChangeID = ChangeID, .PropertyName = TablesAndPropertyNames(e.Row.Table), .RowID = ID, .RowIndex = Index, .Table = DT, .OldValue = priorItemArray, .NewValue = e.Row.ItemArray})
        End Select
    End Sub

We expose a property called MaxUndoCount that limits the size of the Undo stack. Before we add a cChange to the undo stack, we call this method to make sure there is enough room.

    Private Sub TrimUndoStack()

 
        'Make room for new change
        Dim ChangeID As Integer = -1
        Do While UndoStack.Count > 0 AndAlso UndoStack.Count > MaxUndoCount - 1
            ChangeID = UndoStack.First().ChangeID
            UndoStack.Remove(UndoStack.First())
        Loop
 
        ' Don't leave partial changeid on stack
        Do While UndoStack.Count > 0 AndAlso UndoStack.First().ChangeID = ChangeID
            UndoStack.Remove(UndoStack.First())
        Loop
    End Sub

We also expose a method that allows the inherited window to explicitly clear the stack, such as when the page has been refreshed from the database.

    Public Sub ClearUndo()
        UndoStack.Clear()
        RedoStack.Clear()
    End Sub

This method creates the Undo and Redo commands, wires them up to the Undo and Redo methods and binds them to the Undo and Redo buttons. It needs a helper function called FindChildrenOfType.

    Private Sub BindUndoAndRedoButtons(sender As Object)
        UndoCommand = New RoutedCommand("UndoCommand", sender.GetType())
        RedoCommand = New RoutedCommand("RedoCommand", sender.GetType())
 
        CommandBindings.Add(New CommandBinding(UndoCommand, AddressOf Undo_Executed, AddressOf Undo_CanExecute))
        CommandBindings.Add(New CommandBinding(RedoCommand, AddressOf Redo_Executed, AddressOf Redo_CanExecute))
 
        For Each UndoButton As UndoButton In FindChildrenOfType(Of UndoButton)(sender)
            UndoButton.Command = UndoCommand
        Next
 
        For Each RedoButton As RedoButton In FindChildrenOfType(Of RedoButton)(sender)
            RedoButton.Command = RedoCommand
        Next
    End Sub
 
    Public Shared Function FindChildrenOfType(Of T As Windows.DependencyObject)(depObj As Windows.DependencyObject) As Collections.Generic.List(Of T)
        Dim Children As New Collections.Generic.List(Of T)
        If depObj IsNot Nothing Then
            For i As Integer = 0 To Windows.Media.VisualTreeHelper.GetChildrenCount(depObj) - 1
                Dim child As Windows.DependencyObject = Windows.Media.VisualTreeHelper.GetChild(depObj, i)
                If child IsNot Nothing AndAlso TypeOf child Is T Then
                    Children.Add(DirectCast(child, T))
                End If
                Children.AddRange(FindChildrenOfType(Of T)(child))
            Next
        End If
        Return Children
    End Function 

Now we have to consume this cool new feature. Change MainWindow.xaml to look like this. We are defining some text boxes, a checkbox, and a data grid. We have buttons to add and remove rows from the grid, an Undo button, and a Redo button. Other than inheriting UndoRedo and adding the last two buttons, we don't have to modify our window at all.

<local:UndoRedo x:Class="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:UndoClass"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <RoutedCommand x:Key="DeleteRowCommand"/>
        <RoutedCommand x:Key="InsertRowCommand"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource DeleteRowCommand}" Executed="DeleteRow_Executed"/>
        <CommandBinding Command="{StaticResource InsertRowCommand}" Executed="InsertRow_Executed"/>
    </Window.CommandBindings>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="auto"/>
            <ColumnDefinition Width="auto"/>
        </Grid.ColumnDefinitions>
 
        <TextBlock Grid.Row="0" Grid.Column="0" Text="Field 1: "/>
        <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding theText1, UpdateSourceTrigger=PropertyChanged}"/>
 
        <TextBlock Grid.Row="1" Grid.Column="0" Text="Field 2: "/>
        <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding theText2, UpdateSourceTrigger=PropertyChanged}"/>
 
        <TextBlock Grid.Row="2" Grid.Column="0" Text="Checkbox"/>
        <CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding theBoolean}"/>
 
        <Button Grid.Row="3" Grid.Column="0" Content="Delete row" Command="{StaticResource DeleteRowCommand}"/>
        <Button Grid.Row="3" Grid.Column="1" Content="Insert row" Command="{StaticResource InsertRowCommand}"/>
       
        <DataGrid Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2" ItemsSource="{Binding DS.Tables[Values]}" AutoGenerateColumns="False" SelectedItem="{Binding SelectedDRV}" CanUserAddRows="False" CanUserDeleteRows="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="ID" Binding="{Binding ID}"/>
                <DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="100"/>
            </DataGrid.Columns>
        </DataGrid>
 
        <local:UndoButton Grid.Row="6" Grid.Column="0" Content="Undo"/>
        <local:RedoButton Grid.Row="6" Grid.Column="1" Content="Redo"/>
    </Grid>
</local:UndoRedo>

The code behind looks like this. Again, other than inherting UndoRedo and specifying DontTrackProperties, we haven't done anything special.

Imports System.Data
 
Class MainWindow
    Inherits UndoRedo
 
    Private _theText1 As String = "ABC"
    Public Property theText1 As String
        Get
            Return _theText1
        End Get
        Set(value As String)
            SetProperty(_theText1, value)
        End Set
    End Property
 
    Private _theText2 As String
    Public Property theText2 As String
        Get
            Return _theText2
        End Get
        Set(value As String)
            SetProperty(_theText2, value)
        End Set
    End Property
 
    Private _theBoolean As Boolean = False
    Public Property theBoolean As Boolean
        Get
            Return _theBoolean
        End Get
        Set(value As Boolean)
            SetProperty(_theBoolean, value)
        End Set
    End Property
 
    Private _DS As New DataSet()
    Public Property DS As DataSet
        Get
            Return _DS
        End Get
        Set(value As DataSet)
            SetProperty(_DS, value)
        End Set
    End Property
 
    Private _SelectedDRV As DataRowView
    Public Property SelectedDRV As DataRowView
        Get
            Return _SelectedDRV
        End Get
        Set(value As DataRowView)
            SetProperty(_SelectedDRV, value)
        End Set
    End Property
 
    Public Sub New()
        DontTrackProperties = {NameOf(SelectedDRV)}
        InitializeComponent()
    End Sub
 
    Private Sub MainWindow_Loaded(sender As Object, e As RoutedEventArgs) Handles Me.Loaded
 
        Dim DT As New DataTable("Values")
 
        DT.Columns.Add(New DataColumn("ID", GetType(Integer)))
        DT.Columns.Add(New DataColumn("Value", GetType(String)))
        DT.PrimaryKey = {DT.Columns(0)}
 
        For i As Integer = 1 To 5
            Dim row As DataRow = DT.NewRow()
            row("ID") = i
            row("Value") = "Value " & i
            DT.Rows.Add(row)
        Next
 
        DS.Tables.Add(DT)
 
        NotifyPropertyChanged("")
 
        SelectedDRV = DS.Tables(0).DefaultView.Item(0)
 
    End Sub
 
    Private Sub InsertRow_Executed(sender As Object, e As ExecutedRoutedEventArgs)
        Dim DT As DataTable = DS.Tables("Values")
        Dim row As DataRow = DT.NewRow()
        row("ID") = DT.Select().Max(Function(r) r(DT.PrimaryKey(0))) + 1
        DT.Rows.Add(row)
    End Sub
 
    Private Sub DeleteRow_Executed(sender As Object, e As ExecutedRoutedEventArgs)
        SelectedDRV.Row.Delete()
    End Sub
End Class

Let's try this out. Run the program, alter a textbox, check the checkbox, delete a row, add a row and enter some data in it. Like this.

Make some changes

Now click [Undo] three times. You see the individual changes you made to the grid being undone.



Click [Undo] two more times to see the checkbox and the textbox being undone too.