Because you can't use the VB 6.0–supplied Masked Edit control, how about making your own? This is such a useful control that it is well worth taking the time to cover it here.
I liked the idea of the Masked Edit control and its features. Here is what you will be programming as features in this control:
Ability to have predefined mask formats
Ability to have user-defined mask formats (limited)
Ability to have no mask and accept anything
Ability to have a validation error event that fires before the control loses focus
Ability to suppress the validation error event (which fires only during validation) by setting the TextBox.CausesValidation property to false
Ability to have visual cues so the user knows how many characters to input
Ability to allow literals that cannot be deleted
All this can be yours for only $19.95! And if you call now, I will throw in another control for free!
I must warn you about writing this control and its associated test program. It is not easy, and you have to take care to do everything perfectly or your control will crash. Here we go.
Start a new C# or VB Windows control library. This is not a normal Windows project—it is a user control that you will modify heavily. Mine is called "CustomMask."
| Note |
In the code for this chapter (you can download the code for this book from the Downloads section of the Apress Web site at http://www.apress.com), you will see that the VB project is called CustomMask-vb and the C# project is called CustomMask-c. I tell you this because you may see two of the same controls in the .NET control library after you write the test program. |
There is no user interface to this control. What you see on the screen is a small borderless window. I show you how to get rid of it shortly. For now, look at the code generated for you.
| Caution |
Whatever you do, don't double-click the form to get to the code. You will get the Form_Load event delegate definition and the delegate code as well. You will be making some changes to the code, which will cause some errors in the wizard-generated code, and you will then need to get rid of this code by hand. |
This class derives from System.Windows.Forms.UserControl. This is fine for most user controls, but what I want to do here is derive from the TextBox. This allows me to extend the TextBox properties. Here are the before and after class definitions. (Be sure to add System.Text and System.Text.RegularExpressions namespace references.)
C# Class Definition Before the Change
public class UserControl1 : System.Windows.Forms.UserControl
{
...
}
C# Class Definition After the Change
public class MaskedTextBox_C : System.Windows.Forms.TextBox
{
...
}
VB Class Definition Before the Change
Public Class UserControl1 Inherits System.Windows.Forms.UserControl ... End Class
VB Class Definition After the Change
Public Class MaskedTextBox_VB Inherits System.Windows.Forms.TextBox ... End Class
As you can see, the code now inherits from the TextBox class. If you try to view the form, you will not be able to because it does not exist anymore.
Now it is time to add some class local variables. Some of these will be public so you will be able to see them in your test form. Here they are.
C#
#region local variables
public event ValidationErrorEventHandler ValidationError;
public enum FormatType
{
None,
Date,
Numbers,
Alpha
};
string mUserMask;
FormatType mFmt = FormatType.None;
char mNumberPlace = '#';
char mCue = '_';
string mRegNum = "[0-9]";
char mAlphaPlace = '?';
string mRegAlpha = "[A-Za-z]";
string mAnything = ".*";
string mRegExpression = string.Empty;
StringBuilder mText = new StringBuilder();
int mValidationErrors;
#endregion
VB
#Region "local variables"
Public Event ValidationError As ValidationErrorEventHandler
Public Enum FormatType
None
DateFormat
Numbers
Alpha
End Enum
Dim mUserMask As String
Dim mFmt As FormatType = FormatType.None
Dim mNumberPlace As Char = "#"c
Dim mCue As Char = "_"c
Dim mRegNum As String = "[0-9]"
Dim mAlphaPlace As Char = "?"c
Dim mRegAlpha As String = "[A-Za-z]"
Dim mAnything As String = ".*"
Dim mRegExpression As String = String.Empty
Dim mText As StringBuilder = New StringBuilder()
Dim mValidationErrors As Int32
#End Region
The Format enumeration is used for the Format property you will code next. This enumeration allows anyone using this control to choose an easy-to-see format rather than entering in a number. You will see this more clearly when you get to the client portion of this project.
Remember the regular expressions? As you can see from this variable list, you will use them extensively in this control.
Now it is time to add two new properties, Format and Mask. Here is the code.
#region New properties
/// <summary>
/// Sets one of three formats
/// Date must be ##/##/####
/// Numbers must be 0-9 exactly 8 digits.
/// Alpha must be A-Z or a-z, exactly 8 digits
/// </summary>
public FormatType Format
{
get{return mFmt;}
set
{
mFmt = value;
mText = new StringBuilder();
if(mFmt == FormatType.None)
{
mUserMask = "";
mRegExpression = mAnything;
}
else if(mFmt == FormatType.Date)
{
mUserMask = "##/##/####";
mText.Append("__/__/____");
mRegExpression = "\\d{2}/\\d{2}/\\d{4}";
}
else if(mFmt == FormatType.Alpha)
{
mUserMask = "????????";
mRegExpression = mRegAlpha + "{8}";
mText.Append("________");
}
else if(mFmt == FormatType.Numbers)
{
mUserMask = "########";
mRegExpression = mRegNum + "{8}";
mText.Append("________");
}
Mask = mUserMask;
}
}
/// <summary>
/// If the Format property is set then
/// this property is invalid
/// Mask properties recognized are:
/// # for number
/// ? for alpha (upper or lowercase)
/// all other characters are literals
/// </summary>
public string Mask
{
get{return mUserMask;}
set
{
if(mFmt == FormatType.None)
{
mRegExpression = string.Empty;
mText = new StringBuilder();
mUserMask = value;
char[] chars = mUserMask.ToCharArray();
foreach(char c in chars)
{
if(c == mNumberPlace)
{
mRegExpression += mRegNum;
mText.Append(mCue);
}
else if(c == mAlphaPlace)
{
mRegExpression += mRegAlpha;
mText.Append(mCue);
}
else
{
mRegExpression += c.ToString();
mText.Append(c);
}
}
}
this.Text = mText.ToString();
}
}
#endregion
#Region "New properties"
Public Property Format() As FormatType
Get
Return mFmt
End Get
Set(ByVal Value As FormatType)
mFmt = Value
mText = New StringBuilder()
If mFmt = FormatType.None Then
mUserMask = ""
mRegExpression = mAnything
ElseIf mFmt = FormatType.DateFormat Then
mUserMask = "##/##/####"
mText.Append("__/__/____")
mRegExpression = "\\d{2}/\\d{2}/\\d{4}"
ElseIf mFmt = FormatType.Alpha Then
mUserMask = "????????"
mRegExpression = mRegAlpha + "{8}"
mText.Append("________")
ElseIf mFmt = FormatType.Numbers Then
mUserMask = "########"
mRegExpression = mRegNum + "{8}"
mText.Append("________")
End If
Mask = mUserMask
End Set
End Property
Public Property Mask() As String
Get
Return mUserMask
End Get
Set(ByVal Value As String)
If mFmt = FormatType.None Then
mRegExpression = String.Empty
mText = New StringBuilder()
mUserMask = Value
Dim chars() As Char = mUserMask.ToCharArray()
Dim c As Char
For Each c In chars
If c = mNumberPlace Then
mRegExpression += mRegNum
mText.Append(mCue)
ElseIf c = mAlphaPlace Then
mRegExpression += mRegAlpha
mText.Append(mCue)
Else
mRegExpression += c.ToString()
mText.Append(c)
End If
Next
Me.Text = mText.ToString()
End If
End Set
End Property
#End Region
Let's look at these properties in some detail. The Format property builds a regular expression based on the type of data allowed in. At the same time, it builds the string to be shown in the TextBox. I am using the underscore (_) character as a visual cue for the user. If you want, you can add another property to allow the user to choose his or her cue.
In the interest of limiting the code I need, I limited the alpha and numeric formats to only eight characters. Also note that I am not allowing any optional numbers or optional alpha characters.
The Mask property is dependent upon what the user chooses for the Format property. If the user chooses a format of None, then this property accepts the user format. I allow only # and ? as replacement characters. All other characters entered are treated as literal and cannot be changed by the user during runtime. A literal is defined as a character that appears at its correct position in the control, and it cannot be deleted or overwritten.
Notice that this Mask property builds the correct regular expression string, as it looks at each character in the input mask. At the same time, I am also building the text that is to be displayed in the control during runtime.
The next bit of code you will need takes care of the user entering in characters during runtime. I also have a delete routine in here.
#region Helper stuff
public string EnterLetter(ref int position, char c)
{
//User pressed delete key
if((int)c == 8)
{
position--;
return DeleteLetter(ref position);
}
//User trying to go beyond bounds
if(position >= mText.Length)
return mText.ToString();
//If we have hit a literal then advance one
//Do this in a loop in case there are more literals.
if(mText[position] != mCue)
position++;
//check to see if the character is ok
if(mUserMask[position] == mNumberPlace)
{
if(Regex.IsMatch(c.ToString(), mRegNum))
{
mText[position] = c;
position ++;
}
}
else if(mUserMask[position] == mAlphaPlace)
{
if(Regex.IsMatch(c.ToString(), mRegAlpha))
{
mText[position] = c;
position ++;
}
}
return mText.ToString();
}
public string DeleteLetter(ref int pos)
{
//If the character to be deleted is a cue then bail out
if(mText[pos] != mCue)
{
//If the character to be deleted is valid then change it back to a cue
if(mUserMask[pos] == mNumberPlace || mUserMask[pos] == mAlphaPlace)
mText[pos] = mCue;
}
return mText.ToString();
}
#endregion
VB
#Region "Helper stuff"
Public Function EnterLetter(ByRef position As Int32, _
ByVal c As Char) As String
'User pressed delete key
If Val(c) = 8 Then
position -= 1
Return DeleteLetter(position)
End If
'User trying to go beyond bounds
If position >= mText.Length Then
Return mText.ToString()
End If
'If we have hit a literal then advance one
'Do this in a loop in case there are more literals.
If mText.Chars(position) <> mCue Then
position += 1
End If
'check to see if the character is ok
If mUserMask.Chars(position) = mNumberPlace Then
If Regex.IsMatch(c.ToString(), mRegNum) Then
mText.Chars(position) = c
position += 1
End If
ElseIf mUserMask.Chars(position) = mAlphaPlace Then
If Regex.IsMatch(c.ToString(), mRegAlpha) Then
mText.Chars(position) = c
position += 1
End If
End If
Return mText.ToString()
End Function
Public Function DeleteLetter(ByRef pos As Int32) As String
'If the character to be deleted is a cue then bail out
If mText.Chars(pos) <> mCue Then
'If the character to be deleted is valid then change it back to a cue
If mUserMask.Chars(pos) = mNumberPlace Or _
mUserMask.Chars(pos) = mAlphaPlace Then
mText.Chars(pos) = mCue
End If
End If
Return mText.ToString()
End Function
#End Region
Let's look at the EnterLetter routine first. The first thing I do here is check for a backspace. If I find it, I decrement the position pointer and call the Delete function, which returns the corrected string.
If the character puts me over the allowed length, I disallow the character and return the original string. If the character is about to overwrite a cue character, I advance the pointer and then add the character to the string. If the character is in a spot where I have a regular expression character, I use the Regex object to decide if it is a match. If so, I replace the character in the internal string and return that string. If this character does not match, I ignore it and return the original string.
This EnterLetter routine is called from the KeyPress event handler. The only way for me to ignore any character is to set the KeyPressEventArgs.Handled property to true. As you will see, I do this for every character that comes in.
The DeleteLetter routine replaces the deleted character with a cue character. It will not replace a literal with a cue character. This prevents the control from deleting literals.
The next region of code to be added contains the various event handlers for the TextBox. You will be adding handlers for the following events:
TextBox.Enter
TextBox.Leave
TextBox.KeyDown
TextBox.KeyPress
Here is the code for these delegates.
C#
#region hooked events
private void MaskBoxEnter(object sender, EventArgs e)
{
this.Text = mText.ToString();
this.SelectionLength=0;
}
private void MaskBoxLeave(object sender, EventArgs e)
{
if(mUserMask == string.Empty)
{
mText = new StringBuilder(this.Text);
}
}
private void MaskBoxKeyDown(object sender, KeyEventArgs e)
{
//No mask so let in any character
if(mUserMask == string.Empty)
return;
int pos = this.SelectionStart;
if(e.KeyData == Keys.Delete)
{
this.Text = DeleteLetter(ref pos);
this.SelectionStart = pos;
}
e.Handled = true;
}
private void MaskBoxKeyPress(object sender, KeyPressEventArgs e)
{
//No mask so let in any character
if(mUserMask == string.Empty)
return;
int pos = this.SelectionStart;
this.Text = EnterLetter(ref pos, e.KeyChar);
e.Handled = true;
this.SelectionStart = pos;
}
#endregion
VB
#Region "hooked events"
Private Sub MaskBoxEnter(ByVal sender As Object, ByVal e As EventArgs)
Me.Text = mText.ToString()
Me.SelectionLength = 0
End Sub
Private Sub MaskBoxLeave(ByVal sender As Object, ByVal e As EventArgs)
If mUserMask = String.Empty Then
mText = New StringBuilder(Me.Text)
End If
End Sub
Private Sub MaskBoxKeyDown(ByVal sender As Object, ByVal e As KeyEventArgs)
'No mask so let in any character
If mUserMask = String.Empty Then
Return
End If
Dim pos As Int32 = Me.SelectionStart
If e.KeyData = Keys.Delete Then
Me.Text = DeleteLetter(pos)
Me.SelectionStart = pos
End If
e.Handled = True
End Sub
Private Sub MaskBoxKeyPress(ByVal sender As Object, _
ByVal e As KeyPressEventArgs)
'No mask so let in any character
If mUserMask = String.Empty Then
Return
End If
Dim pos As Int32 = Me.SelectionStart
Me.Text = EnterLetter(pos, e.KeyChar)
e.Handled = True
Me.SelectionStart = pos
End Sub
#End Region
I hook into the KeyDown event for the sole purpose of trapping the Delete key. I use the SelectionStart property to determine where the cursor is in the control. I set the text of the control to the string that gets returned from the Delete function. I then set the Handled property to true to disallow the multiple keystrokes bug.
The Enter function sets the selection length to zero to prevent the whole string from being selected. It also sets the text of the box to the current version of text held in memory.
The KeyPress handler lets in any character if no mask or format is provided. This lets the control act as a normal TextBox. The rest of this function gets the new string from the EnterLetter function and resets the position of the cursor in the control.
| Tip |
There is no explicit property to retrieve the cursor position in a TextBox control. Instead, use the SelectionStart property, which gives you the point at which the next character gets entered. |
So, now you have all the new properties and event handlers necessary for a Masked Edit control. Just wire up the delegates in the class constructor.
C#
public MaskedTextBox_C()
{
InitializeComponent();
mValidationErrors = 0;
this.Enter += new EventHandler(MaskBoxEnter);
this.Leave += new EventHandler(MaskBoxLeave);
this.KeyDown += new KeyEventHandler(MaskBoxKeyDown);
this.KeyPress += new KeyPressEventHandler(MaskBoxKeyPress);
}
VB
Public Sub New()
MyBase.New()
InitializeComponent()
mValidationErrors = 0
AddHandler Me.Enter, New EventHandler(AddressOf MaskBoxEnter)
AddHandler Me.Leave, New EventHandler(AddressOf MaskBoxLeave)
AddHandler Me.KeyDown, New KeyEventHandler(AddressOf MaskBoxKeyDown)
AddHandler Me.KeyPress, New KeyPressEventHandler(AddressOf MaskBoxKeyPress)
End Sub
There it is. You now have a control that extends the properties of the TextBox with two extra ones allowing for masking. If you enter a mask, this control will validate each character entered against that mask. In a little while, you will be testing this control. There is one thing missing, however.
What happens when the user tabs out of this control before everything is entered? You get no warning that a problem has occurred. After all, there are missing characters and you need to know this. The solution is to add an event.
The event that you will add is called ValidationErrorEvent. This event will get fired if two conditions are met: if the user leaves a control and if that controls text does not match the supplied mask. I will be firing this event from within the Validated event that comes with the TextBox control. I do this for two reasons:
The Validated event can be suppressed by setting the CausesValidation property of the TextBox to false. This also suppresses my new event.
The Validated event is called before the Leave event to allow the user to stop focus from transferring to another control if he or she so wishes.
Before I have you enter the new event code, I suggest that you look over the numerous examples in the online help. Like I keep saying, you can program away happily without knowing what really goes on, but to understand things like events and delegates you need to dig in. The effort is well worth it.
I could have written this event using the .NET-supplied EventHandler and the accompanying EventArgs. This is boring, though, not to mention uninformative. So in order to make some new type of event arguments, you will need to generate a new class that is derived from the class EventArgs. Put this class code above the class code for your control. Along with this class, you will need to define a delegate for a client to hook into.
C#
public class ValidationErrorEventArgs : EventArgs
{
private string mMask;
private string mText;
public ValidationErrorEventArgs(string mask, string OffendingText)
{
mMask = mask;
mText = OffendingText;
}
public string Mask{get{return mMask;}}
public string Text{get{return mText;}}
}
public delegate void ValidationErrorEventHandler(object sender,
ValidationErrorEventArgs e);
Public Class ValidationErrorEventArgs
Inherits EventArgs
Private mMask As String
Private mText As String
Public Sub New(ByVal mask As String, ByVal OffendingText As String)
mMask = mask
mText = OffendingText
End Sub
Public ReadOnly Property Mask() As String
Get
Return mMask
End Get
End Property
Public ReadOnly Property Text() As String
Get
Return mText
End Get
End Property
End Class
Public Delegate Sub ValidationErrorEventHandler(ByVal sender As Object, _
ByVal e As _
ValidationErrorEventArgs)
Now that you have this class, you will need to provide an Onxxx method that goes with the event. Enter the following region of code.
C#
#region OnXXX event stuff
public void RaiseError()
{
ValidationErrorEventArgs e = new ValidationErrorEventArgs(mUserMask,
mText.ToString());
OnValidationError(e);
}
protected virtual void OnValidationError(ValidationErrorEventArgs e)
{
ValidationError(this, e);
}
#endregion
VB
#Region "OnXXX event stuff"
Public Sub RaiseError()
Dim e As ValidationErrorEventArgs = _
New ValidationErrorEventArgs(mUserMask, mText.ToString())
OnValidationError(e)
End Sub
Protected Sub OnValidationError(ByVal e As ValidationErrorEventArgs)
RaiseEvent ValidationError(Me, e)
End Sub
#End Region
You must call the OnValidationError method to raise the event. This is required and you will be flogged if you do not do it this way (see the online help).
I include the RaiseEvent method for ease of use. Notice that I pass in the current mask and text so the client can determine the problem from ValidationErrorEventArgs.
Because I need to call this from inside the Validation event, I need to wire this event up. At the same time, I also wire up a delegate to my new event so I can keep track of how many times matching failed in this control.
Add these two delegates to your "hooked events" region.
C#
private void MaskBoxValidated(object sender, EventArgs e)
{
if(!Regex.IsMatch(mText.ToString(), mRegExpression))
RaiseError();
}
//Special event to handle case of no one connecting to my delegate
//avoids a null reference
private void DefaultHandler(object sender, ValidationErrorEventArgs e)
{
mValidationErrors++;
}
VB
Private Sub MaskBoxValidated(ByVal sender As Object, ByVal e As EventArgs)
If Not Regex.IsMatch(mText.ToString(), mRegExpression) Then
RaiseError()
End If
End Sub
'Special event to handle case of no one connecting to my delegate
'avoids a null reference
Private Sub DefaultHandler(ByVal sender As Object, _
ByVal e As ValidationErrorEventArgs)
mValidationErrors += 1
End Sub
By the way, connecting to my own event avoids a nasty null reference bug in case the client decides not to use this event.
The MaskBoxValidated event handler uses the regular expression engine to evaluate the whole text to the mask. If this match fails, then I start the ball rolling and fire the event.
Of course, in order to catch the Validated event you need to connect to it. Add the following lines to your constructor.
C#
this.Validated += new EventHandler(MaskBoxValidated);
this.ValidationError += new ValidationErrorEventHandler(DefaultHandler);
VB
AddHandler Me.Validated, New EventHandler(AddressOf MaskBoxValidated)
AddHandler Me.ValidationError, _
New ValidationErrorEventHandler(AddressOf DefaultHandler)
There you are. A shiny new Masked Edit control. Compile it and make sure you have no syntax bugs. Because this is part of a control library, you will end up with a DLL after compilation. You will bring this DLL into your Toolbox when you make the client.