Tuesday, May 4, 2021

Implementing Undo and Redo via a base class

It occurred to me that I should be able to write a simple base class that adds Undo/Redo functionality to any MVVM page by hooking into the NotifyPropertyChanged event and using reflection to capture and undo data changes. Here's the result, written in Visual Basic.

Start a new Visual Basic WPF (framework) class in Visual Studio and call it UndoClass.

I have split the NotifyPropertyChanged and Undo/Redo functionality into two classes because not everything that needs NotifyPropertyChanged needs Undo logic, but everything that needs Undo logic needs NotifyPropertyChanged. 

Add a class called NotifyPropertyChanged. 


Imports System.ComponentModel
 
Public Class NotifyPropertyChanged
    Inherits Window
    Implements INotifyPropertyChanged
 
    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

The Undo/Redo class

Now we define our Undo/Redo class that inherits from NotifyPropertyChanged. It hooks into the NotifyPropertyEvent to detect changes to properties and stores them in a stack. When the user clicks the Undo button the last change is popped off the stack, undone, and pushed onto the Redo stack. When the user clicks the Redo button the last change is popped off the Redo stack, reapplied, and pushed onto the Undo stack.

As we Undo and Redo we don't want to trigger changes that add to the Undo stack so we use the IsUndoingOrRedoing flag to suppress that.

We need the Undo and Redo commands to be defined in XAML so the Undo and Redo buttons can reference them, but the creation of the command bindings can be done in code as soon as we get a reference to the window they are defined in. The code is quite small, considering how powerful it is.

Imports System.ComponentModel
Imports System.Reflection
 
Public Class UndoRedo
    Inherits NotifyPropertyChanged
 
    Private Class cChange
        Public PropertyName As String
        Public OldValue As Object
        Public NewValue As Object
    End Class
 
    Private UndoStack As New List(Of cChange)()
    Private RedoStack As New List(Of cChange)()
    Private IsUndoingOrRedoing As Boolean = False
    Private CommandBindingsResolved As Boolean = False
 
    Public Sub New()
        AddHandler MyBase.PropertyChanged, AddressOf TrackChange
    End Sub
 
    Private Sub TrackChange(sender As Object, e As PropertyChangedEventArgs)
 
        If Not CommandBindingsResolved Then
            ResolveCommandBindings()
        End If
 
        If IsUndoingOrRedoing 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 = UndoStack.LastOrDefault(Function(c) c.PropertyName = PropertyName)
 
        If PriorChange IsNot Nothing Then
            OldValue = PriorChange.NewValue
        End If
 
        UndoStack.Add(New cChange() With {.PropertyName = PropertyName, .OldValue = OldValue, .NewValue = NewValue})
        RedoStack.Clear()
    End Sub
 
    Private Sub ResolveCommandBindings()
 
        Dim UndoCommand As ICommand = TryFindResource("UndoCommand")
        Dim RedoCommand As ICommand = TryFindResource("RedoCommand")
 
        If UndoCommand IsNot Nothing Then
            CommandBindings.Add(New CommandBinding(UndoCommand, AddressOf Undo_Executed, AddressOf Undo_CanExecute))
        End If
        If RedoCommand IsNot Nothing Then
            CommandBindings.Add(New CommandBinding(RedoCommand, AddressOf Redo_Executed, AddressOf Redo_CanExecute))
        End If
 
        CommandBindingsResolved = True
    End Sub
 
    Friend Sub Undo_CanExecute(sender As Object, e As CanExecuteRoutedEventArgs)
        e.CanExecute = (UndoStack.Count > 0)
    End Sub
 
    Friend Sub Undo_Executed(sender As Object, e As ExecutedRoutedEventArgs)
 
        Dim Change As cChange = UndoStack(UndoStack.Count - 1)
 
        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
 
        IsUndoingOrRedoing = True
        pi.SetValue(sender, NewValue)
        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(RedoStack.Count - 1)
 
        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
 
        IsUndoingOrRedoing = True
        pi.SetValue(sender, NewValue)
        IsUndoingOrRedoing = False
    End Sub
 
End Class

Using the Undo/Redo class.

We will create two text boxes, one bound on PropertyChanged and one with LostFocus. We will add an Undo and a Redo button. The Undo and Redo commands are defined in the XAML so the buttons can bind to them although it would also be possible to do the same thing by creating an UndoButton and a RedoButton control or giving them special names.

<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="UndoCommand"/>
        <RoutedCommand x:Key="RedoCommand"/>
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <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}"/>
 
 
        <Button Grid.Row="2" Grid.Column="0" Content="Undo" Command="{StaticResource UndoCommand}"/>
        <Button Grid.Row="2" Grid.Column="1" Content="Redo" Command="{StaticResource RedoCommand}"/>
    </Grid>
</local:UndoRedo>

The code-behind merely defines the two properties we are using. It inherits the UndoRedo class. Other than that, it does nothing special.

Class MainWindow
    Inherits UndoRedo
 
    Private _theText1 As String
    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
End Class

Try typing in the two text boxes. You will see the [Undo] button becomes enabled as soon as there are entries in the Undo stack.


After typing

Now click the [Undo] button. The entire value in Field2 will be cleared because it only updates its source when it loses focus. the [Redo] button is now enabled.

After one Undo

Now click the [Redo] button. The value that was removed has been restored and the [Redo] button is disabled.

After Redo
Now click the [Undo] button twice. The first click will clear Field 2 and the second click will remove the last character from Field 1. This is because Field 2 is bound with UpdateSourceTrigger=LostFocus and Field 1 is bound with PropertyChanged. This causes their source properties to call NotifyPropertyChanged at different times.

After two Undos

Lastly, change some text while the [Redo] button is enabled. Modifying data invalidates the Redo stack so it gets cleared.

Typing invalidates the Redo stack

Some improvements...
1. Consolidate consecutive changes to the same property into one change to avoid character by character undoes
2. Group changes that occur between keystrokes and mouse clicks together so they can be undone as a unit. So if a keystroke or click updates several properties, they will be all undone together.
3. Create custom Undo and Redo button classes that can be found by the Undo class and tied to the commands. This reduces the amount of XAML required.







No comments:

Post a Comment