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
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)
PropertyName = PropertyName.Substring(4)
End If
RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs(PropertyName))
End Class
Inherits Button
End Class
Inherits Button
End Class
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.
Inherits NotifyPropertyChanged
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
Private ChangeID As Integer = 0
Private RedoCommand As ICommand
Public DontTrackProperties As String()
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.
End Sub
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.
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 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)
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
' 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
Friend Sub Undo_Executed(sender As Object, e As ExecutedRoutedEventArgs)
Dim Change As cChange = UndoStack.Last()
If Change.ChangeID = 0 Then Exit Sub
IsUndoingOrRedoing = True
Do
UndoStack.Remove(Change)
RedoStack.Add(Change)
Dim PropertyName As String = Change.PropertyName
If Change.RowID Is Nothing Then
' It's a simple property
pi.SetValue(sender, NewValue)
Else
Dim DT As DataTable = Change.Table
' 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()
' It was removed, need to add row
Dim row As DataRow = DT.NewRow()
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
End Sub
e.CanExecute = (RedoStack.Count > 0)
End Sub
Friend Sub Redo_Executed(sender As Object, e As ExecutedRoutedEventArgs)
Dim Change As cChange = RedoStack.Last()
IsUndoingOrRedoing = True
Do
TrimUndoStack()
RedoStack.Remove(Change)
UndoStack.Add(Change)
Dim PropertyName As String = Change.PropertyName
If Change.RowID Is Nothing Then
pi.SetValue(sender, NewValue)
Else
Dim DT As DataTable = Change.Table
' Insert row
Dim row As DataRow = DT.NewRow()
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
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)
BindUndoAndRedoButtons(sender)
For Each PI As PropertyInfo In type.GetProperties(BindingFlags.Public Or BindingFlags.Instance)
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
Dim DS As DataSet = DirectCast(PI.GetValue(sender), DataSet)
SetsAndPropertyNames.Add(DS, PI.Name)
AddHandler DS.Tables.CollectionChanged, AddressOf DataSet_CollectionChanged
End If
Return True
Case "DataTable"
Dim DT As DataTable = DirectCast(PI.GetValue(sender), DataTable)
Return True
End Select
Return False
End Function
Private Sub DataSet_CollectionChanged(sender As Object, e As CollectionChangeEventArgs)
Dim DT As DataTable = DirectCast(e.Element, DataTable)
End If
End Sub
Private Sub LoadDataSetHandlers(DS As DataSet, PropertyName As String)
For Each DT As DataTable In DS.Tables
Next
End Sub
Private Sub LoadDataTableHandlers(DT As DataTable, PropertyName As String)
TablesAndPropertyNames.Add(DT, PropertyName)
AddHandler DT.RowChanged, AddressOf RowChanged
End Sub
Private Sub SaveRowValues(DT As DataTable, PropertyName As String)
Dim Index As Integer
For Each row As DataRow In DT.Select()
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})
End Sub
If IsUndoingOrRedoing Then Exit Sub
If DT.PrimaryKey Is Nothing OrElse DT.PrimaryKey.Count = 0 Then Exit Sub
Dim PropertyName As String = TablesAndPropertyNames(DT)
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)
If IsUndoingOrRedoing Then Exit Sub
If DT.PrimaryKey Is Nothing OrElse DT.PrimaryKey.Count = 0 Then Exit Sub
Dim PropertyName As String = TablesAndPropertyNames(DT)
If priorChange IsNot Nothing Then
priorItemArray = priorChange.NewValue
End If
TrimUndoStack()
Select Case e.Action
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
UndoStack.Remove(UndoStack.First())
Loop
' Don't leave partial changeid on stack
Do While UndoStack.Count > 0 AndAlso UndoStack.First().ChangeID = ChangeID
Loop
End Sub
RedoStack.Clear()
End Sub
CommandBindings.Add(New CommandBinding(UndoCommand, AddressOf Undo_Executed, AddressOf Undo_CanExecute))
For Each UndoButton As UndoButton In FindChildrenOfType(Of UndoButton)(sender)
Next
For Each RedoButton As RedoButton In FindChildrenOfType(Of RedoButton)(sender)
Next
End Sub
Public Shared Function FindChildrenOfType(Of T As Windows.DependencyObject)(depObj As Windows.DependencyObject) As Collections.Generic.List(Of T)
For i As Integer = 0 To Windows.Media.VisualTreeHelper.GetChildrenCount(depObj) - 1
Children.Add(DirectCast(child, T))
Children.AddRange(FindChildrenOfType(Of T)(child))
End If
Return Children
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.
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>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding theText1, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding theText2, UpdateSourceTrigger=PropertyChanged}"/>
<CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding theBoolean}"/>
<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:RedoButton Grid.Row="6" Grid.Column="1" Content="Redo"/>
</Grid>
</local:UndoRedo>
Class MainWindow
Inherits UndoRedo
Private _theText1 As String = "ABC"
Public Property theText1 As String
Get
Return _theText1
Set(value As String)
End Set
End Property
Private _theText2 As String
Public Property theText2 As String
Get
Return _theText2
Set(value As String)
End Set
End Property
Private _theBoolean As Boolean = False
Public Property theBoolean As Boolean
Get
Return _theBoolean
Set(value As Boolean)
End Set
End Property
Private _DS As New DataSet()
Get
Return _DS
Set(value As DataSet)
End Set
End Property
Private _SelectedDRV As DataRowView
Public Property SelectedDRV As DataRowView
Get
Return _SelectedDRV
Set(value As DataRowView)
End Set
End Property
Public Sub New()
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)))
For i As Integer = 1 To 5
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)
End Sub
Private Sub DeleteRow_Executed(sender As Object, e As ExecutedRoutedEventArgs)
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.
No comments:
Post a Comment