
Introduction
A common requirement for WinForms applications is to track whether or not a user has changed document values since a previous save. If a value has changed, the form is considered “dirty”; when the user attempts to close a dirty form, it is desirable to prompt the user to save changes first. This article describes two simple approaches to “dirty tracking” that are easy to code and reusable across forms and projects.
Approach #1: Handle the “changed” events for input controls
To conceptualize this approach, consider what may be done within a form to track changes in input controls. You might establish a form-level member variable called _isDirty and set it to true when an input control fires its appropriate Changed event. For example, if the form has a TextBox control called textBox1, the code to signal a change could look like this:
public partial class Form1 : Form
{
private bool _isDirty = false;
...
private void textBox1_TextChanged(object sender, EventArgs e)
{
_isDirty = true;
}
...
}
Whatever method the form uses to save the current document would set the dirty flag to false upon successful saving:
public partial class Form1 : Form
{
...
private void SaveMyDocument()
{
_isDirty = false;
}
...
}
Then, the FormClosing event for the form is handled to check the dirty flag and prompt to save if necessary:
public partial class Form1 : Form
{
...
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
if (_isDirty)
{
DialogResult result
= (MessageBox.Show(
"Would you like to save changes before closing?"
, "Save Changes"
, MessageBoxButtons.YesNoCancel
, MessageBoxIcon.Question));
switch (result)
{
case DialogResult.Yes:
SaveMyDocument();
break;
case DialogResult.No:
break;
case DialogResult.Cancel:
e.Cancel = true;
break;
}
}
}
...
}
The approach is very simple, but one would not want to code this way. Manually handling each input control’s xxxChanged event is cumbersome, and the solution is not reusable across forms, let alone projects.
It doesn’t take much, however, to improve on this approach, making it reusable across forms and less tedious to use. The same functionality may be coded in its own class that can be instantiated within a form; in other words, the form offloads its dirty tracking to a helper class.
To begin, we’ll define our helper class with an _isDirty flag and expose it as a property along with methods to force a dirty or clean state (the latter used when saving changes):
public class SimpleDirtyTracker
{
private Form _frmTracked;
private bool _isDirty;
public bool IsDirty
{
get { return _isDirty; }
set { _isDirty = value; }
}
public void SetAsDirty()
{
_isDirty = true;
}
public void SetAsClean()
{
_isDirty = false;
}
...
}
We’ll then establish event handlers for all input control types we wish to support. In this example, we’re only supporting TextBox and CheckBox controls, but it would be simple to add additional types.
public class SimpleDirtyTracker
{
...
private void SimpleDirtyTracker_TextChanged(object sender, EventArgs e)
{
_isDirty = true;
}
private void SimpleDirtyTracker_CheckedChanged(object sender, EventArgs e)
{
_isDirty = true;
}
...
}
Next, we will add a method to loop through the controls in a ControlCollection, determine if the control is of a supported type, and assign the appropriate event handler if so. If a control in the collection has child controls of its own, the method is recursively called on the child collection.
public class SimpleDirtyTracker
{
...
private void AssignHandlersForControlCollection(
Control.ControlCollection coll)
{
foreach (Control c in coll)
{
if (c is TextBox)
(c as TextBox).TextChanged
+= new EventHandler(SimpleDirtyTracker_TextChanged);
if (c is CheckBox)
(c as CheckBox).CheckedChanged
+= new EventHandler(SimpleDirtyTracker_CheckedChanged);
if (c.HasChildren)
AssignHandlersForControlCollection(c.Controls);
}
}
...
}
Finally, we’ll add a constructor that defines the form to track as an argument. The constructor starts the recursive assignment of handlers on the form’s Controls collection.
public class SimpleDirtyTracker
{
...
public SimpleDirtyTracker(Form frm)
{
_frmTracked = frm;
AssignHandlersForControlCollection(frm.Controls);
}
...
}
That’s it! Our simple approach is now usable in any form with minimal code. We simply instantiate the tracker in the form’s Load event:
public partial class Form1 : Form
{
private SimpleDirtyTracker _dirtyTracker;
...
private void Form1_Load(object sender, EventArgs e)
{
_dirtyTracker = new SimpleDirtyTracker(this);
_dirtyTracker.SetAsClean();
}
...
}
Then, in the form’s FormClosing event, check the status of _dirtyTracker.IsDirty to see if the user should be prompted to save changes:
public partial class Form1 : Form
{
...
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
if (_dirtyTracker.IsDirty)
{
DialogResult result
= (MessageBox.Show(
"Would you like to save changes before closing?"
, "Save Changes"
, MessageBoxButtons.YesNoCancel
, MessageBoxIcon.Question));
switch (result)
{
case DialogResult.Yes:
SaveMyDocument();
break;
case DialogResult.No:
break;
case DialogResult.Cancel:
e.Cancel = true;
break;
}
}
}
...
}
Approach #2: Track the “clean” values of controls
The previous approach works and is simple. It is, however, prone to false positives when considering a form dirty. For example, a user may check a CheckBox, change his or her mind, and uncheck it again. Our previous code would recognize the CheckedChanged event as triggered – twice! – and flag IsDirty as true. A (slightly) more sophisticated approach would recognize that, should the user close the form at this point, the value of the checkbox hasn’t actually changed since the last document save and therefore the user should not be prompted to save.
We can still code our dirty-tracking code in its own class, but now, instead of responding to a control’s xxxChanged event, we will need to track the control’s value as of the last save. We’ll call this the control’s “clean” value. When it is time to check if changes have been made to the form, we’ll compare the control’s current value to its remembered “clean” value; if the two are different, the control is considered “dirty” and thus, so is the form.
The bulk of the work will be handled in a class called ControlDirtyTracker. We’ll start its definition with a property for the tracked control and one to remember its clean value. For the purpose of this illustration, we will support TextBox, CheckBox, ComboBox, and ListBox controls. We’ll also define a static method to return whether a type for a given control is supported.
public class ControlDirtyTracker
{
private Control _control;
private string _cleanValue;
public Control Control { get { return _control; } }
public string CleanValue { get { return _cleanValue; } }
public static bool IsControlTypeSupported(Control ctl)
{
if (ctl is TextBox) return true;
if (ctl is CheckBox) return true;
if (ctl is ComboBox) return true;
if (ctl is ListBox) return true;
return false;
}
...
}
We then need a method to return the current value of the tracked control. For the sake of simplicity in comparison, we’ll standardize on a string value. Again, this method may be extended at the discretion of the developer to support additional types not present in the sample.
public class ControlDirtyTracker
{
...
private string GetControlCurrentValue()
{
if (_control is TextBox)
return (_control as TextBox).Text;
if (_control is CheckBox)
return (_control as CheckBox).Checked.ToString();
if (_control is ComboBox)
return (_control as ComboBox).Text;
if (_control is ListBox)
{
StringBuilder val = new StringBuilder();
ListBox lb = (_control as ListBox);
ListBox.SelectedIndexCollection coll = lb.SelectedIndices;
for (int i = 0; i < coll.Count; i++)
val.AppendFormat("{0};", coll[i]);
return val.ToString();
}
return "";
}
...
}
We code the constructor to pass the tracked control as an argument, capturing its current value as the clean value.
public class ControlDirtyTracker
{
...
public ControlDirtyTracker(Control ctl)
{
if (ControlDirtyTracker.IsControlTypeSupported(ctl))
{
_control = ctl;
_cleanValue = GetControlCurrentValue();
}
else
throw new NotSupportedException(
string.Format(
"The control type for '{0}' "
+ "is not supported by the ControlDirtyTracker class."
, ctl.Name)
);
}
...
}
Finally, our ControlDirtyTracker will expose two methods: one to establish the current control value as clean (to be called when saving the document), and one that tests if the control is dirty.
public class ControlDirtyTracker
{
...
public void EstablishValueAsClean()
{
_cleanValue = GetControlCurrentValue();
}
public bool DetermineIfDirty()
{
return (
string.Compare(
_cleanValue, GetControlCurrentValue(), false
) != 0
);
}
...
}
Most of the work for this approach is now done. As we will be tracking multiple controls, we’ll create a ControlDirtyTrackerCollection class. It is copied here in its entirety, with utility methods to add controls from a form, list all controls that are dirty in the collection, and mark all controls in the collection as clean.
public class ControlDirtyTrackerCollection: List<ControlDirtyTracker>
{
public ControlDirtyTrackerCollection() : base() { }
public ControlDirtyTrackerCollection(Form frm) : base()
{
AddControlsFromForm(frm);
}
public void AddControlsFromForm(Form frm)
{
AddControlsFromCollection(frm.Controls);
}
public void AddControlsFromCollection(Control.ControlCollection coll)
{
foreach (Control c in coll)
{
if (ControlDirtyTracker.IsControlTypeSupported(c))
this.Add(new ControlDirtyTracker(c));
if (c.HasChildren)
AddControlsFromCollection(c.Controls);
}
}
public List<Control> GetListOfDirtyControls()
{
List<Control> list = new List<Control>();
foreach (ControlDirtyTracker c in this)
{
if (c.DetermineIfDirty())
list.Add(c.Control);
}
return list;
}
public void MarkAllControlsAsClean()
{
foreach (ControlDirtyTracker c in this)
c.EstablishValueAsClean();
}
}
We could be done at this point with approach #2. To use on our form, we would simply instantiate a ControlDirtyTrackerCollection object. We could also wrap the collection object in a class that tracks the form and exposes the classic IsDirty property, which is what I have done below:
public class SlightlyMoreSophisticatedDirtyTracker
{
private Form _frmTracked;
private ControlDirtyTrackerCollection _controlsTracked;
public bool IsDirty
{
get
{
List<Control> dirtyControls
= _controlsTracked.GetListOfDirtyControls();
return (dirtyControls.Count > 0);
}
}
public List<Control> GetListOfDirtyControls()
{
return _controlsTracked.GetListOfDirtyControls();
}
public void MarkAsClean()
{
_controlsTracked.MarkAllControlsAsClean();
}
public SlightlyMoreSophisticatedDirtyTracker(Form frm)
{
_frmTracked = frm;
_controlsTracked = new ControlDirtyTrackerCollection(frm);
}
}
To apply this SlightlyMoreSophisticatedDirtyTracker on the form is as easy as as it was with our previous SimpleDirtyTracker. The sample download contains a project demonstrating its use.
Summary
Tracking whether or not a user has changed document values is a common requirement for WinForms applications, but fortunately, one that doesn’t have to be difficult to code. For a simple approach, one may assign handlers to the appropriate Changed events on the form’s input controls. For a slightly more sophisticated approach, one may track the values of controls and compare against a remembered “clean” value. Either approach may be encapsulated in a helper class, making it reusable across forms and projects and simple to apply.
Acknowledgements
The inspiration for this article came from a CodeProject forum question[^] and was initially addressed in parts 1[^] and 2[^] on my blog: www.MishaInTheCloud.com[^].