Team LiB
Previous Section Next Section

Rolling Your Own Masked Edit Control

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:

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.

C#

    #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

VB

#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.

C#

    #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.

Note 

This control can handle only one literal character in a row. There is no checking for multiple literals in a row, and there is no looping to allow this. If you enter two literals in a row while testing this control, it will explode. The point of this example is to show you how it is done, not to do it all for you. You have the knowledge to enhance this control and make it robust if you so desire.

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:

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:

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);

VB

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.


Team LiB
Previous Section Next Section