Saturday, August 1, 2009

Slightly More Sophisticated WinForms Dirty Tracking in C#

The C# programming tip this week is a follow-up to last week's Simple Dirty Tracking for WinForms in which I demonstrated a simple technique for coding the functionality of tracking whether or not a user has changed a document since its last save. This is useful for prompting the user to "save changes" upon closing a "dirty" document. The technique involved creating a controller class that assigns event handlers to the "changed" events for tracked input controls, and exposes an IsDirty property.

This controller can tell if a user has changed the value in a text box, but it can't tell if the user changed the value back. The document wouldn't need saving in this case, but the simple controller would still report that the form is dirty based on the simple condition of text changing (in any way) in the TextBox control.

So this week's programming exercise is to create a slightly more sophisticated Dirty Tracker for WinForms - one that determines if the document is dirty by comparing values in input controls to the values remembered at the time of the last save (or initialization). This approach does not require event handling, but instead requires tracking a collection of controls and their "clean" values for later comparison.

To start, we create a ControlDirtyTracker class that performs the ... um... dirty work. It is within this class that we'll establish which control types are trackable, and record the clean control values as a private string. A slightly, slightly more sophisticated class might store control values as the more generic object type, but we'll use a string here for simplicity.

public class ControlDirtyTracker                                                               
{
private Control _control;
private string _cleanValue;

// read only properties
public Control Control { get { return _control; } }
public string CleanValue { get { return _cleanValue; } }

...

}

We'll also decide which control types to support, and how to obtain a given input control's current value:
public class ControlDirtyTracker                                                                   
{
...

// static class utility method; return whether or not the control type
// of the given control is supported by this class;
// developers may modify this to extend support for other types
public static bool IsControlTypeSupported(Control ctl)
{
// list of types supported
if (ctl is TextBox) return true;
if (ctl is CheckBox) return true;
if (ctl is ComboBox) return true;
if (ctl is ListBox) return true;

// ... add additional types as desired ...

// not a supported type
return false;
}


// private method to determine the current value (as a string) of the control;
// developers may modify this to extend support for other types
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)
{
// for a listbox, create a list of the selected indexes
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();
}

// ... add additional types as desired ...

return "";
}

Finally we add the constructor, passing the control to track, a method to establish the current control value as the "clean" value, and a method to determine if the control value has changed since the remembered "clean" value.
public class ControlDirtyTracker                                                                   
{
...

// constructor establishes the control and uses its current value as "clean"
public ControlDirtyTracker(Control ctl)
{
// if the control type is not one that is supported, throw an exception
if (ControlDirtyTracker.IsControlTypeSupported(ctl))
_control = ctl;
else
throw new NotSupportedException(
string.Format("The control type for '{0}' is not supported by the ControlDirtyTracker class."
, ctl.Name)
);

}


// method to establish the the current control value as "clean"
public void EstablishValueAsClean()
{
_cleanValue = GetControlCurrentValue();
}


// determine if the current control value is considered "dirty";
// i.e. if the current control value is different than the one
// remembered as "clean"
public bool DetermineIfDirty()
{
// compare the remembered "clean value" to the current value;
// if they are the same, the control is still clean;
// if they are different, the control is considered dirty.
return (string.Compare(_cleanValue, GetControlCurrentValue(), false) != 0);
}


// end of the class
}

Since we'll be tracking multiple input controls, we'll create the collection class ControlDirtyTrackerCollection. In it we'll define methods to add controls from a form, to list all the tracked controls that are currently dirty, and to establish all tracked controls as clean.
public class ControlDirtyTrackerCollection: List<ControlDirtyTracker>
{

// constructors
public ControlDirtyTrackerCollection() : base() { }
public ControlDirtyTrackerCollection(Form frm) : base()
{
// initialize to the controls on the passed in form
AddControlsFromForm(frm);
}


// utility method to add the controls from a Form to this collection
public void AddControlsFromForm(Form frm)
{
AddControlsFromCollection(frm.Controls);
}

// recursive routine to inspect each control and add to the collection accordingly
public void AddControlsFromCollection(Control.ControlCollection coll)
{
foreach (Control c in coll)
{
// if the control is supported for dirty tracking, add it
if (ControlDirtyTracker.IsControlTypeSupported(c))
this.Add(new ControlDirtyTracker(c));

// recurively apply to inner collections
if (c.HasChildren)
AddControlsFromCollection(c.Controls);
}
}

// loop through all controls and return a list of those that are dirty
public List<Control> GetListOfDirtyControls()
{
List<Control> list = new List<Control>();

foreach (ControlDirtyTracker c in this)
{
if (c.DetermineIfDirty())
list.Add(c.Control);
}

return list;
}


// mark all the tracked controls as clean
public void MarkAllControlsAsClean()
{
foreach (ControlDirtyTracker c in this)
c.EstablishValueAsClean();
}

}

At this point, we now have in our collection class the means to track input controls on the form with little additional work. We instantiate the collection in the Load event of the form:
// form private member
private ControlDirtyTrackerCollection _trackedControls;

private void Form1_Load(object sender, EventArgs e)
{
// in the Load event initialize our tracking object
_trackedControls = new ControlDirtyTrackerCollection(this);
_trackedControls.MarkAllControlsAsClean();
}

We call _trackedControls.MarkAllControlsAsClean() whenever the document is saved. Then, when the form is closing, we can prompt the user to save again if any of the values in the tracked controls have changed.
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
// in the closing event, we should prompt to save if there are any dirty controls
if (_trackedControls.GetListOfDirtyControls().Count > 0)
{
// prompt the user
if (MessageBox.Show("Would you like to save changes before closing?"
, "Save Changes"
, MessageBoxButtons.YesNoCancel
, MessageBoxIcon.Question)
== DialogResult.Yes)
{
// if the user says Yes to save...
SaveTheDocumentHoweverYouUsuallyDo();
_trackedControls.MarkAllControlsAsClean();
}

}
}

And that's it. It is a relatively small amount of code to add to the form for tracking input control changes between saves, and the tracking objects are reusable. There's a little more work to do to create the controller classes than our previous attempt, but it is still simple to code and doesn't turn out the false positives we had before.

4 comments:

  1. This is an excellent way to handle tracking form edits. Thanks for putting this out! I found this code to be extremely useful.

    ReplyDelete
  2. Great. Just what I needed! Thanks a lot.

    ReplyDelete
  3. thanks alot for this code.. it helps me alot..

    ReplyDelete
  4. Great work indeed. Thanks

    ReplyDelete

Submit a comment?