Introduction
So, this is an approach that I've developed, through some research in other topics, to implement Undo/Redo functionality for Windows Forms applications using Databound objects.
Background
I explored both the Command and Memento based structures, but neither, at least for me, seemed to fit the bill for what I needed. What this approach does is implement an event
for any change in the object, that first passes the current state of the object into the event args before making the change. These state objects are then stored in both an Undo and Redo
Stack<T>
collection. Undo and Redo functions pop off the last state, and bind it to the controls of the form.
Using the code
There are three change types that I am interested in capturing in my Undo/Redo stacks for this application:
- Property value changes.
- Additions or subtractions to
List<T>
collections nested in my data objects.
- Property value changes in the data objects kept in the nested
List<T>
collections.
First, let me lay out the structure of my example:
Restaurant
- this is the main data object, it will be the single instance for each form
Name
, City
, State
- all String properties, of which changes need to be capturedHealthScores
- List<HealthScore>
collection,
of which two types of changes are captured
- Addition or removal of
HealthScore
objects into the collection HealthScore
data object has one property, of type
int
- Value
- we need
to capture changes to this value
So first of all, we have the Restaurant data object, for which we need the three properties above, and an event to capture their changes:
using System;
namespace RestaurantScrores
{
public delegate void RestaurantChangedEventHandler(object sender, RestaurantChangedEventArgs e);
public class RestaurantChangedEventArgs : EventArgs
{
private Restaurant _oldvalue;
public RestaurantChangedEventArgs(Restaurant oldvalue) { _oldvalue = oldvalue.DeepClone(); }
public Restaurant OldValue { get { return _oldvalue; } }
}
public class Restaurant
{
public String _name, _city, _state;
public event RestaurantChangeEventHandler Changed;
public Restaurant() { _name = _city = _state = String.Empty; }
public String Name
{
get { return _name; }
set { OnChanged(new RestaurantChangedEventArgs(this)); _name = value; }
}
public String NameSilent { set { _name = value; } }
public String City
{
get { return _city; }
set { OnChanged(new RestaurantChangedEventArgs(this)); _city = value; }
}
public String CitySilent { set { _city = value; } }
public String State
{
get { return _state; }
set { OnChanged(new RestaurantChangedEventArgs(this)); _state = value; }
}
public String State { set { _state = value; } }
private void OnChanged(RestaurantChangedEventArgs e)
{
if (Changed != null) Changed(this, e);
}
}
}
Notice that we are having the EventArgs
create a clone for us using the DeepClone()
method in our
Restaurant
object. Storing a clone is vital so that we do
not continue to edit the same referenced object that gets stored in the Undo and Redo stacks. This is why its important to have the Silent setter method,
because we use that call when creating the clone, so that each clone creation for the Undo and Redo stacks doesn't cause a loop in the normal setter.
Here is an example of our DeepClone()
method, which is a public method we need to put in any data object we need to store in an Undo/Redo state:
public Restaurant DeepClone()
{
Restaurant clone = new Restaurant();
clone.NameSilent = this.Name;
clone.CitySilent = this.City;
clone.StateSilent = this.State;
return clone;
}
This is a very crude example, and on a more sophisticated model, storing the actual object may prove less effective. In this case, you may chose to serialize each state
into an XML string, and store that string into the Undo and Redo stacks, deserializing them when needed. If this is the case, then its not necessary to have the Silent setter.
You can also choose to create your clone by serializing it into a memory stream, and then deserializing it back into an object, this will break the reference link,
creating a clone. Here I only have a few properties, so the setting method is not too bad, but should you have a large complex data object,
serialization is probably the best method for cloning.
So, now we need to declare a data object in our form, and hookup its events:
private Stack<Restaurant> UndoStack;
private Stack<Restaurant> RedoStack;
private Restaurant _dataobject;
public event RestaurantChangedEventHandler Changed;
public RestaurantForm()
{
UndoStack = new Stack<Restaurant>();
RedoStack = new Stack<Restaurant>();
_dataobject = new Restaurant();
_dataobject.Changed += new RestaurantChangedEventHandler(_dataobject_Changed);
InitializeComponents();
this.restaurantBindingSource.DataSource = _dataobject;
}
private void _dataobject_Changed(object sender, RestaurantChangedEventArgs e)
{
UndoStack.Push(e.OldValue);
RedoStack.Clear();
undobutton.Enabled = true;
redobutton.Enabled = false;
savebutton.Enabled = tre
}
Next, the Undo and Redo methods, have to do the following:
- Pop the latest state off of the Undo or Redo stack
- Store a clone of this state into the opposite stack, either Undo or Redo stack by Push
- Set the
_dataobject
to this new state
- Set the
DataSource
of all related BindingSource
objects - Hookup the changed event for the new object state
private void undobutton_Click(object sender, EventArgs e)
{
_dataobject = UndoStack.Pop();
RedoStack.Push(_dataobject.DeepClone());
this.restaurantBindingSource.DataSource = _dataobject;
this.restaurantBindingSource.ResetBindings(false);
_dataobject.Changed += new RestaurantChangedEventHandler(_dataobject_Changed);
undobutton.Enabled = UndoStack.Count > 0;
redostack.Enabled = RedoStack.Count > 0;
savebutton.Enabled = true;
}
private void redobutton_Click(object sender, EventArgs e)
{
_dataobject = RedoStack.Pop();
UndoStack.Push(_dataobject.DeepClone());
this.restaurantBindingSource.DataSource = _dataobject;
this.restaurantBindingSource.ResetBindings(false);
_dataobject.Changed += new RestaurantChangedEventHandler(_dataobject_Changed);
undobutton.Enabled = UndoStack.Count > 0;
redostack.Enabled = RedoStack.Count > 0;
savebutton.Enabled = true;
}
Nested Object Events
So far, we've handled the basic property changes for our main data bound object. But let's say we want to have a nested object in our main object,
in this case, a List<HealthScore>
collection, to hold HealthScores
for this restaurant. For simplicity sake I've only put the one int property in this class,
but realistically this nested object would have much more detail, perhaps even its own nested objects. Seeing how we handle this one nested object, you can extrapolate how to handle
further levels of nesting. Basically, we will be bubbling up our change events. Since our form is doing the Undo/Redo functions, and they are doing so by capturing
our Restaurant.Changed
event, we have to be sure to bubble nested change events up into calls for the
Restaurant.Changed
event trigger.
So first, lets describe our HealthScore
object:
public delegate void HealthScoreChangedEventHandler(object sender, HealthScoreChangedEventArgs e);
public class HealthScoreChangedEventArgs : EventArgs
{
private HealthScore _oldvalue;
public HealthScoreChangedEventArgs(HealthScore oldvalue) { _oldvalue = oldvalue.DeepClone(); }
public HealthScore OldValue { get { return _oldvalue; } }
}
public class HealthScore
{
private int _value;
public event HealthScoreChangedEventHandler Changed;
public HealthScore() { _value = 0; }
public HealthScore(int value) { _value = value; }
public int Value
{
get { return _value; }
set { OnChanged(new HealthScoreChangedEventArgs(this)); _value = value; }
}
public int ValueSilent { set { _value = value; } }
private void OnChanged(HealthScoreChangedEventArgs e)
{
if ( Changed != null ) Changed(this, e);
}
public HealthScore DeepClone()
{
HealthScore clone = new HealthScore();
clone.ValueSilent = this.Value;
return clone;
}
}
Now, we have to bubble up this Changed
event to our Restaurant
object. We need to make some changes to our Restaurant
class.
private List<HealthScore> _scores;
public Restaurant()
{
_scores = new List<HealthScore>();
}
public List<HealthScore> Scores
{
get { return _scores; }
set { OnChanged(new RestaurantChangedEventArgs(this)); _scores = value; }
}
public List<HealthScore> ScoresSilent { set { _scores = value; } }
public void Add(HealthScore score)
{
OnChanged(new RestaurantChangedEventArgs(this));
score.Changed += new HealthScoreChangedEventHandler(score_Changed);
_scores.Add(score);
}
private void score_Changed(object sender, HealthScoreChangedEventArgs e)
{
OnChanged(new RestaurantChangedEventArgs(this));
}
Now whatever functionality you use in your form for adding the new score, just have it call this Add
method, and the change bubbles up.
Now, if the reference on our data object changes, ie, in the Undo and Redo methods, the events have to be set back up in the new reference.
We already covered that for our main data object. We need one final method in our Restaurant
class that will hookup our HealthScore
events.
public void HookupHealthScoreEvents()
{
foreach (HealthScore hs in _scores)
hs.Changed += new HealthScoreChangedEventHandler(score_Changed);
}
Now back to our Undo and Redo methods, we need to account for changes in our nested object.
private void undobutton_Click(object sender, EventArgs e)
{
_dataobject = UndoStack.Pop();
RedoStack.Push(_dataobject.DeepClone());
this.restaurantBindingSource.DataSource = _dataobject;
this.restaurantBindingSource.ResetBindings(false);
_dataobject.Changed += new RestaurantChangedEventHandler(_dataobject_Changed);
this.healthScoresBindingSource.DataSource = _dataobject.Scores;
this.healthScoresBindingSource.ResetBindings(false);
_dataobject.HookupHealthScoreEvents();
undobutton.Enabled = UndoStack.Count > 0;
redostack.Enabled = RedoStack.Count > 0;
savebutton.Enabled = true;
}
private void redobutton_Click(object sender, EventArgs e)
{
_dataobject = RedoStack.Pop();
UndoStack.Push(_dataobject.DeepClone());
this.restaurantBindingSource.DataSource = _dataobject;
this.restaurantBindingSource.ResetBindings(false);
_dataobject.Changed += new RestaurantChangedEventHandler(_dataobject_Changed);
this.healthScoresBindingSource.DataSource = _dataobject.Scores;
this.healthScoresBindingSource.ResetBindings(false);
_dataobject.HookupHealthScoreEvents();
undobutton.Enabled = UndoStack.Count > 0;
redostack.Enabled = RedoStack.Count > 0;
savebutton.Enabled = true;
}
Points of Interest
This method has worked well for me, in my applications. I am not sure how deep the nesting can go before this method becomes inefficient, if it does.
Again, this is my approach to this functionality, given the need to capture and store states
before data is bound to the data object, so that the undo state can be properly reset.
It may be that a better method exists, and I am totally open to learning what it is if anyone has any feedback.
Update
I have updated the Demo Form to include a tool bar, with a SplitButton
that accumulates Undo and Redo steps so that the user can roll back to certain states. Again very crude in the examples, but you can see how to extrapolate the example into your own application.
First, I have added a property to both RestaurantChangedEventArgs
, and HealthScoreChangedEventArgs
, called Description, which will hold a String describing the change made. This description we will use as the Text
component of the ToolStripItems
we add to the SplitButtons
public String Description { get { return _description; } set { _description = value; } }
You can customize this Message anytime you call OnChanged(); Just pass into the EventArgs a string specific to the call you are making, such as the property name thats changing here, or if you're adding or removing, or whatever. For example, when we have a new value set to Restaurant.Name, in the setter we could have:
public String Name
{
get { return _name; }
set { OnChanged(new RestaurantChangedEventArgs(this, "Restaurant.Name"); _name = value; }
}
So, I added a tool bar, with an Undo and Redo SplitButton
. We need to handle two events from each of these. The Click
event, and DropDownItemClick
event. I have attached the click event to the same event as the Undo and Redo buttons on the form. Just clicking the button portion of the SplitButton
will do the same thing as the Undo and Redo Buttons
. If the user chooses a particular roll back step, then we handle that with the DropDownItemClick
events:
First we need to manage our SplitButtons
in our already existing events, RestaurantChanged
, UndoButtonClick
, and RedoButtonClick
:
First the Restaurant.Changed
event, which in our example is _dataobject_Changed
:
this.redolevelbutton.DropDownItems.Clear();
this.undolevelbutton.DropDownItems.Add("Undo change in: " + e.Message);
this.undolevelbutton.Enabled = _undostack.Count > 0;
this.redolevelbutton.Enabled = _redostack.Count > 0;
Anytime we undo or redo, we want to roll the most current change drop down item from one split button to the other, from undo to redo whenever we undo, and from redo to undo whenever we redo. To do this, add the following to our undobutton_Click
and redobutton_Click
events:
ToolStripItem redoitem = this.undolevelbutton.DropDownItems[this.undolevelbutton.DropDownItems.Count - 1];
redoitem.Text = redoitem.Text.Replace("Redo", "Undo");
this.undolevelbutton.DropDownItems.Remove(redoitem);
this.redolevelbutton.DropDownItems.Add(redoitem);
ToolStripItem undoitem = this.redolevelbutton.DropDownItems[this.redolevelbutton.DropDownItems.Count - 1];
undoitem.Text = undoitem.Text.Replace("Redo", "Undo");
this.redolevelbutton.DropDownItems.Remove(undoitem);
this.undolevelbutton.DropDownItems.Add(undoitem);
Now for the meat of this change, the DropDownItemClick
event handlers:
private void undolevelbutton_DropDownItemClicked(object sender, ToolStripItemClickedEventArgs e)
{
Restaurant restorestate;
ToolStripItem redoitem;
while (this.undolevelbutton.DropDownItems[this.undolevelbutton.DropDownItems.Count - 1] != e.ClickedItem)
{
restorestate = _undostack.Pop().DeepClone();
_redostack.Push(_dataobject.DeepClone());
_dataobject = restorestate.DeepClone();
redoitem = this.undolevelbutton.DropDownItems[this.undolevelbutton.DropDownItems.Count - 1];
redoitem.Text = redoitem.Text.Replace("Redo", "Undo");
this.undolevelbutton.DropDownItems.Remove(redoitem);
this.redolevelbutton.DropDownItems.Add(redoitem);
}
restorestate = _undostack.Pop().DeepClone();
_redostack.Push(_dataobject.DeepClone());
_dataobject = restorestate.DeepClone();
_dataobject.Changed += new RestaurantChangedEventHandler(_dataobject_Changed);
this.restaurantBindingSource.DataSource = _dataobject;
this.restaurantBindingSource.ResetBindings(false);
_dataobject.HookupHealthScoreEvents();
this.scoresBindingSource.DataSource = _dataobject.Scores;
this.scoresBindingSource.ResetBindings(false);
redoitem = this.undolevelbutton.DropDownItems[this.undolevelbutton.DropDownItems.Count - 1];
redoitem.Text = redoitem.Text.Replace("Redo", "Undo");
this.undolevelbutton.DropDownItems.Remove(redoitem);
this.redolevelbutton.DropDownItems.Add(redoitem);
undobutton.Enabled = _undostack.Count > 0;
undolevelbutton.Enabled = _undostack.Count > 0;
redobutton.Enabled = _redostack.Count > 0;
redolevelbutton.Enabled = _redostack.Count > 0;
savebutton.Enabled = true;
}
private void redolevelbutton_DropDownItemClicked(object sender, ToolStripItemClickedEventArgs e)
{
ToolStripItem undoitem;
Restaurant restorestate;
while (this.redolevelbutton.DropDownItems[this.redolevelbutton.DropDownItems.Count - 1] != e.ClickedItem)
{
restorestate = _redostack.Pop().DeepClone();
_undostack.Push(_dataobject.DeepClone());
_dataobject = restorestate.DeepClone();
undoitem = this.redolevelbutton.DropDownItems[this.redolevelbutton.DropDownItems.Count - 1];
undoitem.Text = undoitem.Text.Replace("Redo", "Undo");
this.redolevelbutton.DropDownItems.Remove(undoitem);
this.undolevelbutton.DropDownItems.Add(undoitem);
}
restorestate = _redostack.Pop().DeepClone();
_undostack.Push(_dataobject.DeepClone());
_dataobject = restorestate.DeepClone();
_dataobject.Changed += new RestaurantChangedEventHandler(_dataobject_Changed);
this.restaurantBindingSource.DataSource = _dataobject;
this.restaurantBindingSource.ResetBindings(false);
_dataobject.HookupHealthScoreEvents();
this.scoresBindingSource.DataSource = _dataobject.Scores;
this.scoresBindingSource.ResetBindings(false);
undoitem = this.redolevelbutton.DropDownItems[this.redolevelbutton.DropDownItems.Count - 1];
undoitem.Text = undoitem.Text.Replace("Redo", "Undo");
this.redolevelbutton.DropDownItems.Remove(undoitem);
this.undolevelbutton.DropDownItems.Add(undoitem);
undobutton.Enabled = _undostack.Count > 0;
undolevelbutton.Enabled = _undostack.Count > 0;
redobutton.Enabled = _redostack.Count > 0;
redolevelbutton.Enabled = _redostack.Count > 0;
savebutton.Enabled = true;
}
So as you can see, undo and redo actions can be rolled back beyond one call. You can handle this many different ways within your form, perhaps a ListBox with each step allowing you to choose the level to roll to. And if you undo to a certain level, you can redo step by step back to the original state.