In recent code, I've been trying to avoid displaying validation errors as message boxes, but display something in-line. The .NET Framework provides an ErrorProvider
component which does just this. One of the disadvantages of this control is that it displays an icon indicating error state - which means you need a chunk of white space somewhere around your control, which may not always be very desirable.
This article describes how to create a custom error provider component that uses background colours and tool tips to indicate error state.
Note: I don't use data binding, so the provider implementation I demonstrate below currently has no support for this.
Getting Started
Create a new Component
class and implement the IExtenderProvider
interface. This interface is used to add custom properties to other controls - it has a single method CanExtend
that must return true
for a given source object if it can extend itself to the said object.
In this example, we'll offer our properties to any control. However, you can always customize this to work only with certain control types such as TextBoxBase
, ListBoxControl
, etc.
bool IExtenderProvider.CanExtend(object extendee)
{
return extendee is Control;
}
Implementing Custom Properties
Unlike how properties are normally defined, you need to create get
and set
methods for each property you wish to expose. In our case, we'll be offering Error
and ErrorBackColor
properties. Using Error
as an example, the methods would be GetError
and SetError
. Both methods need to have a parameter for the source object, and the set also needs a parameter for the property value.
Note: I named this property Error
so I could drop in replace the new component for the .NET Framework one without changing any code bar the control declaration. If you don't plan on doing this, you may wish to name it ErrorText
or something more descriptive!
In this example, we'll store all our properties in dictionaries, keyed on the source control. If you want to be more efficient, rather than using multiple dictionaries, you could use one tied to a backing class/structure but we'll keep this example nice and simple.
Below is the implementation for getting the value:
[Category("Appearance"), DefaultValue("")]
public string GetError(Control control)
{
string result;
if(control == null)
throw new ArgumentNullException("control");
if(!_errorTexts.TryGetValue(control, out result))
result = string.Empty;
return result;
}
Getting the value is straightforward, we attempt to get a custom value from our backing dictionary, if one does not exist, then we return a default value.
It's also a good idea to decorate your get
methods with Category
and DefaultValue
attributes. The Category
attribute allows you to place the property in the PropertyGrid
(otherwise it will end up in the Misc group), while the DefaultValue
attribute does two things. Firstly, in designers such as the PropertyGrid
, default values appear in a normal type face whilst custom values appear in bold. Secondly, it avoids cluttering up auto generated code files with assignment statements. If the default value is an empty string
, and the property is set to that value, no serialization code will be generated. (Which is also helpful if you decide to change default values, such as the default error colour later on)
Next, we have our set method code.
public void SetError(Control control, string value)
{
if(control == null)
throw new ArgumentNullException("control");
if(value == null)
value = string.Empty;
if(!string.IsNullOrEmpty(value))
{
_errorTexts[control] = value;
this.ShowError(control);
}
else
this.ClearError(control);
}
As we want "unset" values to be the empty string
, we have a quick null
check in place to convert null
s to empty string
s. If a non-empty string
is passed in, we update the source control to be in its "error
" state. If it's blank, then we clear the error
.
protected virtual void ShowError(Control control)
{
if(control == null)
throw new ArgumentNullException("control");
if(!_originalColors.ContainsKey(control))
_originalColors.Add(control, control.BackColor);
control.BackColor = this.GetErrorBackColor(control);
_toolTip.SetToolTip(control, this.GetError(control));
if (!_erroredControls.Contains(control))
_erroredControls.Add(control);
}
Above, you can see the code to display an error
. First, we store the original background colour of the control if we haven't previously saved it, and then apply the error colour. And because users still need to know what the actual error is, we add a tool tip with the error text. Finally, we store the control in an internal list - we'll use that later on.
Clearing the error state is more or less the reverse. First, we try and set the background colour back it's original value, and we remove the tool tip.
public void ClearError(Control control)
{
Color originalColor;
if (_originalColors.TryGetValue(control, out originalColor))
control.BackColor = originalColor;
_errorTexts.Remove(control);
_toolTip.SetToolTip(control, null);
_erroredControls.Remove(control);
}
Checking If Errors Are Present
Personally speaking, I don't like the built in Validating
event as it prevents focus from shifting until you resolve the error. That is a pretty horrible user experience in my view which is why my validation runs from change events. But then, how do you know if validation errors are present when submitting data? You could keep track of this separately, but we might as well get our component to do this.
When an error is shown, we store that control in a list, and then remove it from the list when the error is cleared. So we can add a very simple property to the control to check if errors are present:
public bool HasErrors
{
get { return _erroredControls.Count != 0; }
}
At present, the error list isn't exposed, but that would be easy enough to do if required.
Designer Support
If you now drop this component onto a form and try and use it, you'll find nothing happens. In order to get your new properties to appear on other controls, you need to add some attributes to the component.
For each new property you are exposing, you have to add a ProviderProperty
declaration to the top of the class containing the name of the property, and the type of the objects that can get the new properties.
[ProvideProperty("ErrorBackColor", typeof(Control)), ProvideProperty("Error", typeof(Control))]
public partial class ErrorProvider : Component, IExtenderProvider
{
...
With these attributes in place (and assuming you have correctly created <PropertyName>Get
and <PropertyName>Set
methods, your new component should now start adding properties to other controls in the designer.
Example Usage
In this component validation is done from event handlers - you can either use the built in Control.Validating
event, or use the most appropriate change event of your source control. For example, the demo project uses the following code to validate integer inputs:
private void integerTextBox_TextChanged(object sender, EventArgs e)
{
Control control;
string errorText;
int value;
control = (Control)sender;
errorText = !int.TryParse(control.Text, out value) ? "Please enter a valid integer" : null;
errorProvider.SetError(control, errorText);
}
private void okButton_Click(object sender, EventArgs e)
{
if (!errorProvider.HasErrors)
{
this.DialogResult = DialogResult.OK;
this.Close();
}
else
this.DialogResult = DialogResult.None;
}
The only thing you need to remember is to clear errors as well as display them!
Limitations
As mentioned at the start of the article, the sample class doesn't support data binding.
Also, while you can happily set custom error background colours at design time, it probably won't work so well if you try and set the error text at design time. Not sure if the original ErrorProvider
supports this either, but it hasn't been specifically coded for in this sample as my requirements are to use it via change events of the controls. For this reason, when clearing an error (or all errors), the text dictionary is always updated, but the background colour dictionaries are left alone.
Final Words
As usual, this code should be tested before being used in a production application - while we are currently using this in almost-live code, it hasn't been thoroughly tested and may contain bugs or omissions.
The sample project includes the full source for this example class, and a basic demonstration project.