Introduction
I know that many people already solved the problem of undo & redo well. Just count the articles publishes here on codeproject about this topic! But nonetheless I couldn't resist to build my own small undo-redo framework.
The framework powers a mid-sized application of mine that is built with WPF. but it should work as well with Windows Forms based applications. I have to disappoint you if you were hoping that this framework would support RDBMS-backed applications. It doesn't. It works well when files are used for persistence and the object model is loaded completely into memory.
Basic Design
The basic design of the framework is to integrate it at the layer of the entities where all changes in properties and collections are observed.
Recording Changes
Recording changes is split up into recording simple property changes and recording changes in a collection.
At first, let's have a look at the ModelBase
class:
public class ModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
public virtual ModelBase GetParent()
{
return null;
}
public ModelBase GetRoot()
{
var parent = GetParent();
if (parent == null)
{
return this;
}
return parent.GetRoot();
}
protected virtual void OnPropertyChanged(string propertyName)
{
var h = PropertyChanged;
if (h != null) h(this, new PropertyChangedEventArgs(propertyName));
}
protected virtual void OnPropertyChanged(string propertyName, object oldValue, object newValue)
{
var h = PropertyChanged;
if (h != null) h(this, new PropertyChangedEventArgs(propertyName));
var root = GetRoot() as IUndoRedoRootObject;
if (root != null)
{
root.GetJournal().AddChangeEntry(this, propertyName, oldValue, newValue);
}
}
}
Every entity of the application must inherit from this class and implement properties like this:
private string property1;
public string Property1
{
get { return this.property1; }
set
{
if (this.property1 != value)
{
var oldValue = property1;
this.property1 = value;
this.OnPropertyChanged("Property1", oldValue, value);
}
}
}
The journal is kept in the root object of the domain model, so every child object must override GetParent()
public class TestChildObject1 : ModelBase
{
internal TestRootObject parent;
public TestChildObject1(TestRootObject parent)
{
this.parent = parent;
}
public override ModelBase GetParent()
{
return parent;
}
}
Finally, the root object must implement the interface IUndoRedoRootObject
:
public class TestRootObject : ModelBase, IUndoRedoRootObject
{
private ChangeJournal changeJournal;
public TestRootObject()
{
this.changeJournal = new ChangeJournal();
}
public ChangeJournal GetJournal()
{
return changeJournal;
}
}
So whenever a property change occurs, the object hierarchy is traversed until the root object is found, then the change is added to the journal.
To record changes in the collections a special collection class must be used - the UndoRedoRecordingCollection
:
public class UndoRedoRecordingCollection<T> : ObservableCollection<T>
{
private ModelBase owner;
public UndoRedoRecordingCollection(ModelBase owner)
{
this.owner = owner;
}
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
base.OnCollectionChanged(e);
var root = owner.GetRoot() as IUndoRedoRootObject;
if (root != null)
{
root.GetJournal().AddChangeEntry(this, e);
}
}
}
This class adds all changes to the journal as well.
Change Sets
By default every single change will be added to the journal and thus represents a button click to the user. In many situations this is not the desired behavior. While a single action may contain hundreds of single changes the user should only be able to undo the action as a whole.
Therefore a ChangeScope
can be created:
using (var changeScope = rootObject.GetJournal().BeginScope())
{
}
The scope itself is just a small syntactic sugar class that utilizes the using
syntax for calling EndScope()
in the journal.
public class ChangeScope : IDisposable
{
private ChangeJournal journal;
internal ChangeScope(ChangeJournal journal)
{
this.journal = journal;
}
public void Dispose()
{
journal.EndScope();
}
}
Change scopes can be nested, so the changes will be added to the journal after the last scope has been left. A ChangeScope
should not be confused with a ChangeSet
. A ChangeScope
is for the definition of the scope, the ChangeSet
aggregates all single changes within that scope.
Journal (Record & Replay of Changes)
The Change<code>Journal
is used both for recoding the changes and for the replay logic and is thus the central class:
public class ChangeJournal
{
private bool executingUndoOrRedo;
private int currentUndoPos = -1;
private List<ChangeEntry> changes = new List<ChangeEntry>();
private ChangeSet changeSet;
public int ChangesCount
{
get { return this.changes.Count; }
}
public void AddChangeEntry(object entity, string property, object oldValue, object newValue)
{
if (this.executingUndoOrRedo) return;
if (currentUndoPos != -1)
{
changes = changes.GetRange(0, currentUndoPos);
currentUndoPos = -1;
}
if (this.changeSet == null)
{
this.changes.Add(new PropertyChangedEntry(entity, property, oldValue, newValue));
this.OnChanged();
}
else
{
this.changeSet.changes.Add(new PropertyChangedEntry(entity, property, oldValue, newValue));
}
}
public void AddChangeEntry(IList collection, NotifyCollectionChangedEventArgs rawEventArg)
{
if (this.executingUndoOrRedo) return;
if (currentUndoPos != -1)
{
changes = changes.GetRange(0, currentUndoPos);
currentUndoPos = -1;
}
if (this.changeSet == null)
{
this.changes.Add(new CollectionChangedEntry(collection, rawEventArg));
this.OnChanged();
}
else
{
this.changeSet.changes.Add(new CollectionChangedEntry(collection, rawEventArg));
}
}
public void Undo(int steps)
{
this.executingUndoOrRedo = true;
try
{
int done = 0;
int pos = currentUndoPos == -1 ? changes.Count : currentUndoPos;
while (done < steps)
{
if (pos - done - 1 < 0)
{
break;
}
changes[pos - done - 1].Undo();
done++;
}
this.currentUndoPos = pos - done;
}
finally
{
this.executingUndoOrRedo = false;
}
}
public void Redo(int steps)
{
if (this.currentUndoPos == -1)
{
return;
}
this.executingUndoOrRedo = true;
try
{
int i = currentUndoPos;
for (; i < currentUndoPos + steps && i < changes.Count; i++)
{
changes[i].Redo();
}
this.currentUndoPos = i;
}
finally
{
this.executingUndoOrRedo = false;
}
}
public void Reset()
{
this.changes.Clear();
this.executingUndoOrRedo = false;
this.currentUndoPos = -1;
this.OnChanged();
}
private Stack<ChangeScope> changeScopes = new Stack<ChangeScope>();
public ChangeScope BeginScope()
{
if (this.changeSet == null)
{
this.changeSet = new ChangeSet();
}
var scope = new ChangeScope(this);
changeScopes.Push(scope);
return scope;
}
public void EndScope()
{
if (changeScopes.Count > 0)
{
changeScopes.Pop();
}
if (this.changeScopes.Count == 0)
{
if (this.changeSet.changes.Count > 0)
{
this.changes.Add(this.changeSet);
this.OnChanged();
}
this.changeSet = null;
}
}
public int ExecutableUndoActions()
{
if (this.currentUndoPos == -1)
{
return this.changes.Count;
}
else
{
return this.currentUndoPos;
}
}
public int ExecutableRedoActions()
{
if (this.currentUndoPos == -1)
{
return -1;
}
return this.changes.Count - this.currentUndoPos;
}
public event EventHandler Changed;
protected void OnChanged()
{
var h = this.Changed;
if (h != null) h(this, EventArgs.Empty);
}
}
Replay in Change Entries
The journal contains change entries that implement the undo and redo function for each single entry:
abstract class ChangeEntry
{
public abstract void Undo();
public abstract void Redo();
}
For property changes this is almost trivial:
class PropertyChangedEntry : ChangeEntry
{
private object changedObject;
private string property;
private object oldValue;
private object newValue;
public PropertyChangedEntry(object obj, string property, object oldValue, object newValue)
{
this.changedObject = obj;
this.property = property;
this.oldValue = oldValue;
this.newValue = newValue;
}
public override void Undo()
{
changedObject.GetType().GetProperty(property).SetValue(changedObject, oldValue, null);
}
public override void Redo()
{
changedObject.GetType().GetProperty(property).SetValue(changedObject, newValue, null);
}
}
For collections this is only slightly more complex:
class CollectionChangedEntry : ChangeEntry
{
private NotifyCollectionChangedEventArgs rawEventArg;
private IList collection;
public CollectionChangedEntry(IList collection, NotifyCollectionChangedEventArgs rawEventArg)
{
this.collection = collection;
this.rawEventArg = rawEventArg;
}
public override void Undo()
{
switch (rawEventArg.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (var addedItem in rawEventArg.NewItems)
{
this.collection.Remove(addedItem);
}
break;
case NotifyCollectionChangedAction.Remove:
int a = rawEventArg.OldStartingIndex;
foreach (var movedItem in rawEventArg.OldItems)
{
this.collection.Remove(movedItem);
this.collection.Insert(a, movedItem);
a++;
}
break;
case NotifyCollectionChangedAction.Move:
int o = rawEventArg.OldStartingIndex;
foreach (var movedItem in rawEventArg.NewItems)
{
this.collection.Remove(movedItem);
this.collection.Insert(o, movedItem);
o++;
}
break;
}
}
public override void Redo()
{
switch (rawEventArg.Action)
{
case NotifyCollectionChangedAction.Add:
int a = rawEventArg.NewStartingIndex;
foreach (var movedItem in rawEventArg.NewItems)
{
this.collection.Remove(movedItem);
this.collection.Insert(a, movedItem);
a++;
}
break;
case NotifyCollectionChangedAction.Remove:
foreach (var removedItem in rawEventArg.OldItems)
{
this.collection.Remove(removedItem);
}
break;
case NotifyCollectionChangedAction.Move:
int o = rawEventArg.NewStartingIndex;
foreach (var movedItem in rawEventArg.OldItems)
{
this.collection.Remove(movedItem);
this.collection.Insert(o, movedItem);
o++;
}
break;
}
}
}
The ChangeSet
's implementation is trivial again:
class ChangeSet : ChangeEntry
{
internal List<ChangeEntry> changes = new List<ChangeEntry>();
public override void Undo()
{
for (int i = changes.Count - 1; i >= 0; i--)
{
changes[i].Undo();
}
}
public override void Redo()
{
foreach (var change in changes)
{
change.Redo();
}
}
}
Planned Improvements
This is the first version of the framework and I hope to improve it in the future:
- Automatic aggregation of changes (e.g. a 5-second rule), especially text fields
- Limit the size of the journal to lower memory consumption
- Allow a text description of the change (so that it can be shown in the UI)
- Memory consumption must be checked in depth and should be optimized
- Change of UI state (pages, selections) should be part of the undo-redo (this is especially an integration issue for my application)
Therefore I started a project on sourceforge where you can download the
latest version.
Integration Sample
I have integrated the framework into the application Quality Spy - you can find it at sourceforge, too.
History
Initial version - 2013/10/29
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.