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.