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 C
hanged
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 xxx
Changed
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[^].
With a background in education, music, application development, institutional research, data governance, and business intelligence, I work for the University of Nevada, Las Vegas helping to derive useful information from institutional data. It's an old picture, but one of my favorites.