Click here to Skip to main content
15,867,488 members
Articles / Programming Languages / C#
Article

Nullable Masked Edit, and a Better Masked Edit Also!

Rate me:
Please Sign up or sign in to vote.
4.85/5 (70 votes)
22 Apr 200610 min read 166.2K   957   123   37
A nullable masked edit control based on the .NET 2.0 MaskedTextBox.

Image 1

Introduction

There are several problems with the .NET 2.0 MaskedTextBox.

  • It doesn't support null text values
  • It doesn't recognize delimiter characters and advance the cursor to the next prompt character
  • It doesn't select all the values within a delimited prompt area
  • Cursor doesn't automatically advance past the literals
  • Entering text into the mask puts the next character immediately after a literal, but backspacing requires an extra backspace keystroke to position to the left of the literal
  • Deleting a character in the middle of the text "pulls" all the characters across the delimiters to the right, rather than just being localized to the characters inside the current delimiter

For my requirements, these factors make the MaskedTextBox rather useless for working with nullable data and for making an easy to use control for data entry purposes. The following article provides a solution to these problems.

Implementation

There were a few options here. I could base the code on the nullable date-time picker, roll my own, or base it on the MaskedTextBox in .NET 2.0. I chose the latter, as this also provides the programmer with all the other features of the MaskedTextBox. Like with any implementation, the whole idea seems fairly simple, and I had a pretty clear idea how things would work, but when it came down to the actual implementation, the usual happened--90% of the solution was easy and got done in a couple hours, and the remaining 10% took another whole day. What was that remaining 10%? Basically, coming up with a good algorithm to deal with non-delimiter characters in the mask, such as '<', '>', and '|' (I ended up creating a physical to virtual mapping, which became very useful throughout the implementation), getting the grouping feature to work correctly, and figuring out how to coerce the MaskedTextBox to behave when updating the Text property.

In discussing the implementation, I'm going to demonstrate each of the problems and the code that solves the problem.

Supporting Null Value

My idea of a control that supports null values is that it should have three properties:

  • The object value returned when then control is in the null state. This value can be null, DBNull.Value, or any other object.
  • The string value the control displays in the textbox when the control is in the null state.
  • The string value the Text property returns when the control is in the null state.

As you can see from this illustration, that's what I've implemented:

Image 2

There are three properties, NullTextDisplayValue, NullTextReturnValue, and NullValue. These determine, respectively, the text displayed in the control, the string returned from the Text property's getter, and the object returned from the Value property's getter. All properties implement an xxxChanged event, making them suitable for two-way data binding.

NullTextReturnValue Property

This property implements a simple getter/setter with an event trigger.

C#
/// <summary>
/// Gets/sets the value that the Text property returns for null values. 
/// This ensures
/// that some known string value is returned when Value is the nullValue.
/// </summary>
[Category("Nullable Masked Edit")]
public string NullTextReturnValue
{
  get { return nullTextReturnValue; }
  set
  {
    if (nullTextReturnValue != value)
    {
      nullTextReturnValue = value;
      OnNullTextReturnValueChanged();
    }
  }
}

NullTextDisplayValue Property

This property updates the nullTextDisplayValue field (and triggers the associated event). It also updates the control's Text property if the control is in the null state.

C#
/// <summary>
/// Gets/sets the text to display in the textbox when the control does not 
/// have focus.
/// </summary>
[Category("Nullable Masked Edit")]
public string NullTextDisplayValue
{
  get { return nullTextDisplayValue; }
  set
  {
    if (nullTextDisplayValue != value)
    {
      // Save the current unfocused is-null state.
      bool isNull = IsNull;
      nullTextDisplayValue = value;

      // If the control was null (and unfocused), then update
      // the currently displayed text with the new null text display
      // value. This will not change the control's Text property if the
      // null display text value is changed while the control has focus,
      // unless the null display text value is the same as the unedited mask
      // value.
      if (isNull)
      {
        Text = nullTextDisplayValue;
      }

      OnNullTextDisplayValueChanged();
    }
  }
}

IsNull Property

This property implements a simple getter determining whether the control is in the null state. This is determined, not by inspecting the Value property, but by inspecting the real-time Text value of the base control. If the base.Text value is displaying the null text display value or is empty, then the control is assumed to be in the null state. The TextMaskFormat property cannot be temporarily changed here to ExcludePromptAndLiterals because this causes a change to the Text property, leading to infinite recursion if you hook the Text event and access the Text property. Instead, I have implemented a simple function that strips out prompt chars and delimiters from the base.Text value to determine if the resulting string is empty.

This brings up a point worth mentioning--the control is in the null state if the text value is an empty string. It's pretty hard to figure out the user's intent; for example, if they clear a field, does that mean the text value should be empty or null? Since this is a nullable edit control, it implies that an empty field should be considered as a null. If you don't want this behavior, then a nullable edit control isn't the right control. Alternatively, you can implement a property that lets you select this behavior, but I specifically avoided that because it doesn't really solve the ambiguity of the user's intent when a field is cleared.

C#
/// <summary>
/// Returns true of the control is displaying the null text display value 
/// (which it will do
/// when the control doesn't have focus) or is an empty string
/// (which indicates, when it has 
/// focus, that it is an unedited text value).
/// </summary>
[Browsable(false)]
public bool IsNull
{
  get
  {
    return (base.Text == nullTextDisplayValue) ||
           (base.Text == null) ||
           (RemovePromptAndDelimiters(base.Text) == String.Empty);
  }
}

Value Property

The Value property implements a simple getter, returning the nullValue value or the base.Text value. The setter is a bit more complicated, updating the Text value to the null text display value if the Value property is set to null or DBNull.Value.

C#
/// <summary>
/// Gets/sets the nullable text value of the control. If the control's Text 
// value is
/// empty or the equal to the NullTextDisplayValue value, this property 
/// will return the
/// value assigned to the NullValue property.
/// </summary>
[Category("Nullable Masked Edit")]
public object Value
{
  get {return IsNull ? nullValue : base.Text;}
  set
  {
    if (val != value)
    {
      val = value;

      // Update the Text property according to the val state.
      if ((val == null) || (val == DBNull.Value))
      {
        Text = nullTextDisplayValue;
      }
      else
      {
        Text = val.ToString();
      }

      OnValueChanged();
    }
  }
}

NullValue Property

The NullValue property implements a simple getter and "evented" setter.

C#
/// <summary>
/// Gets/sets the NullValue value.
/// </summary>
[Category("Nullable Masked Edit")]
public object NullValue
{
  get { return nullValue; }
  set
  {
    if (nullValue != value)
    {
      nullValue = value;
      OnNullValueChanged();
    }
  }
}

Text Property

The Text property overrides the MaskedTextBox Text property, returning the null text return value if the control is in the null state, otherwise the base.Text value. The setter simply passes through to the base class.

C#
/// <summary>
/// If the Text value is the null text display value (which it will be when
/// the control doesn't have focus) or an empty string (which it may be 
/// when the
/// control does have focus) then return the null text return value.
/// </summary>
public override string Text
{
  get {return IsNull ? nullTextReturnValue : base.Text;}
  set {base.Text = value;}
}

Review

The primary difficulty in supporting null values is determining when the control is in the null value state.

Advancing the Cursor to the Next Prompt Character When the User Types a Delimiter

The .NET 2.0 MaskedTextBox does not automatically advance the cursor to the next prompt character if the user types in a delimiter (literal). For example, in a masked date control with the mask "90/90/9900", if I type in "1" followed by a "/", nothing happens:

Image 3

In my control, the cursor is advanced:

Image 4

This behavior is implemented by hooking the MaskInputRejected event to trap bad inputs and overriding the OnKeyPress method. In the OnKeyPress method, the badMaskChar flag is cleared, and if it's set after the base.OnkeyPress method is called, then the code attempts to find the delimiter matching the keystroke the user just entered. If found, the cursor is advanced to the next prompt character (allowing for multiple delimiters). If you look at the code, you'll see I'm using an array, posToMaskIndex, that encodes the position of the prompt character in the mask (positive values) and non-prompt characters (negative values). This allows the algorithm to compensate for the three non-literal tokens that the masked edit control supports: ">", "<", and "|". It makes the code a bit harder to understand though.

C#
/// <summary>
/// Sets the badMaskChar to true.
/// </summary>
protected void OnMaskInputRejected(object sender, 
                         MaskInputRejectedEventArgs e)
{
  badMaskChar = true;
}

protected override void OnKeyPress(KeyPressEventArgs e)
{
  ...

  // Clear flag, which may be set
  // in the OnMaskInputRejected event handler.
  badMaskChar = false;
  base.OnKeyPress(e);

  // If the character doesn't meet the mask requirements...
  if (badMaskChar)
  {
    // then see if the char is the next delimiter. If so, position
    // to the next non-delimiter prompt field.
    int n = SelectionStart;

    while (n < posToMaskIndex.Length)
    {
      // If there's a delimiter at the physical position...
      if (posToMaskIndex[n] < 0)
      {
        // ... and it's the char the user pressed...
        if (Mask[(-posToMaskIndex[n])-1] == e.KeyChar)
        {
          // ... go to the first prompt field position.
          SelectionStart = Skip(n + 1, SkipMode.Literal);

          // And if group option is set, select the group.
          if (selectGroup)
          {
            SelectFrom(SelectionStart);
          }
        }

        break;
     }

     ++n;
  }
}

Automatically Advancing Past the Literals

Another feature I want is to automatically advance the cursor past a literal. For example, the MaskedEditControl behaves like this:

Image 5

Note how the cursor is in between the '2' and the ':' after typing in "12". What I want is this:

Image 6

Where the cursor has been advanced! Similarly, when backspacing, the cursor should automatically move to the left of the literal. In the .NET control, you have to backspace over the literal. How does that make sense?

This feature is also implemented on the OnKeyPress method, but is optional, based on the AutoAdvance property value:

C#
...
// If not at the end of the mask and not the backspace key...
if ((SelectionStart < Mask.Length) && (e.KeyChar != '\b'))
{
  // and positioned on a delimiter...
  if (posToMaskIndex[SelectionStart] < 0)
  {
    // ...advance the selection if flag is set.
    if (autoAdvance)
    {
      SelectionStart = Skip(SelectionStart + 1, SkipMode.Literal);
    }
...

To handle the backspace correctly, the OnKeyDown method must be overridden:

C#
protected override void OnKeyDown(KeyEventArgs e)
{
  ...
  // Automatically decrement the cursor position if deleting up to 
  // a delimeter.
  // This is visually more correct, but is done only if the programmer 
  // sets the
  // AutoAdvance property to true (so that delimiters are skipped in 
  // the forward
  // direction as well.
  if (autoAdvance)
  {
    while ((SelectionStart - 1 >= 0) && (posToMaskIndex[SelectionStart - 1] 
       < 0))
    {
      --SelectionStart;
    }
  }
  ...

There's a lot more that this method does though.

Selecting All Values Within a Delimited Prompt Area

Another feature I like is that the prompt characters are automatically selected when moving from one prompt group to another. This makes it easier for the user to simply overwrite the entire grouped area. For example:

Image 7

This is implemented in the OnKeyPress method in two places, and simply checks the selectGroup field, as this is an option selectable through the SelectGroup property:

C#
if (selectGroup)
{
  SelectFrom(SelectionStart);
}

I've also overridden the OnGotFocus method to automatically select the first group whenever the control receives focus. I personally like this behavior, as the user is typically tabbing into the control and wants to begin editing at the start of the control rather than wherever the cursor was last when they left the control. There's also an alternative implementation that I've commented out:

C#
protected override void OnGotFocus(EventArgs e)
{
  base.OnGotFocus(e);

  // If AutoSelect is set, manage the implementation here.
  // We have to do this in this event, because it's overridden if we
  // do it earlier, such as in the OnEnter event.
  if (selectGroup)
  {
    SelectionStart = 0;
    SelectFrom(0);

    // Optionally, instead of the above code, you could use this code, which
    // selects the group if the cursor is currently adjacent to a delimiter.

    // Any chars to the right of the current position?
    //if (SelectionStart < posToMaskIndex.Length)
    //{
    // // If the cursor is position at the start of the textbox, on, 
    // // or immediately
    // // to the right of a delimiter, then select the delimited fields.
    // if ((SelectionStart == 0) ||
    // (posToMaskIndex[SelectionStart] < 0) ||
    // (posToMaskIndex[SelectionStart - 1] < 0))
    // {
    // SelectFrom(SelectionStart);
    // }
    //}
  }
}

And, of course, if the selection is highlighted and the user presses a key, then the selection should be cleared. Similarly, if the user presses the DEL or backspace key, the entire selection should be cleared. This leads to the next discussion--insert vs. overwrite mode.

Deleting a Character in Overwrite Mode

In the .NET control, let's say I have a phone number:

Image 8

and I want to change the "123". I cursor to the "3" and delete it. Look what happens:

Image 9

This is not the behavior I want, especially if the control is in overwrite mode! In my version, when the control is in overwrite mode, the values in other delimited groups are left alone, so you get:

Image 10

which I feel is a much better implementation. Now, when I type the new digit, it doesn't overwrite the 4, as it would in the .NET control, but instead properly fills in the blank character field. I specifically implemented this behavior only in overwrite mode as insert mode will take care of itself--delete the character, type the new one, and everything is re-aligned correctly. To implement this though, I had to fight with the .NET control, because if there are whitespaces in the text that you are setting, it changes the alignment of the characters, even when the mask itself has a whitespace in the corresponding position. So, the code looks like this, in the OnKeyDown method, when the keystroke is a backspace:

C#
...
if (IsOverwriteMode)
{
  // Move left of all delimiters. If the cursor is immediately to 
  // the right of a 
  // delimiter, this means we will delete the first non-delimiter 
  // character to the 
  // left.
  while ((SelectionStart > 0) && (posToMaskIndex[SelectionStart - 1] < 0))
  {
    --SelectionStart;
  }

  if (SelectionStart > 0)
  {
    --SelectionStart;
    SelectionLength = 1;
    ClearSelection();
    e.Handled = true;
  }
}
...

But the real magic is handled in the ClearSelection method, which finagles the TextMaskFormat so that we temporarily get all the prompts and literals, then it replaces the selected area with prompt characters, deals with the MaskedTextBox issue of having whitespace in the text, and resets the TextMaskFormat value. It took a while to figure this all out.

C#
protected void ClearSelection()
{
  MaskFormat maskFormat = TextMaskFormat;
  // Set to include prompts and literals;
  TextMaskFormat = MaskFormat.IncludePromptAndLiterals;
  char[] newText = base.Text.ToCharArray();
  int savePos = SelectionStart;

  // Clear the selected non-delimiter fields.
  for (int i = SelectionStart; i < SelectionStart + SelectionLength; i++)
  {
    if (posToMaskIndex[i] >= 0)
    {
      newText[i] = PromptChar;
    }
  }

  // Handle MaskedTextBox quirk when the text has whitespace.
  Text = StripSpaces(new String(newText));
  TextMaskFormat = maskFormat;
  SelectionStart = savePos;
  SelectionLength = 0;
}

Oddly enough, dealing with the delete key is a lot simpler:

C#
if ( (e.KeyCode == Keys.Delete) && (IsOverwriteMode) )
{
  if (SelectionLength == 0)
  {
    SelectionLength = 1;
  }

  ClearSelection();
  e.Handled = true;
}

In this case, my custom behavior is again active only when the control is in overwrite mode, and then the character or characters are simply cleared rather than pulling everything to the right of the selected area over.

Conclusion

All of these changes result in what I feel is finally a usable, nullable, masked edit control. I've tried to document the code thoroughly, as there are many interactions and complexities to managing a nullable masked edit control. The complexities primarily involve issues of focus, the TextMaskFormat value, and setting/getting property values under different focus and null state conditions. Hopefully, the comments will make it easier to find problems, which frankly, even though I've tested the control a lot, I suspect there may be gotcha's still lurking due to the complexity of the control. If you find a problem, I'd appreciate it if you could post the fix.

Note that if you don't specify a mask, the control acts like a normal TextBox, but capable of handling null values, which is useful too.

The demo implements a property grid for my control, so you can tweak the property values of both the base class and my exposed properties to see how the control responds to different settings.

History

4/22/06: Updated code to fix a problem with using the control in the Visual Studio designer. Added some descriptions for the properties. Also note in the constructor, the SkipLiterals=false;. Not only does SkipLiterals appear not to do what it says it does (try it in the .NET version), but if set to true, it also displays the mask. In the designer, the property is set to true by default, so my control has to set it to false.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here


Written By
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
QuestionHandling numeric values that have less digits than the mask. Pin
mlochem20-Jan-12 6:13
mlochem20-Jan-12 6:13 
GeneralBUG Pin
reflex@codeproject3-Jan-09 15:51
reflex@codeproject3-Jan-09 15:51 
Generalthis control drop the stored value when it get focus Pin
Shakalama12-Nov-08 17:00
Shakalama12-Nov-08 17:00 
GeneralPlz need some help, Text disapear when contol is clicked on [modified] Pin
[Neno]13-Jul-07 23:32
[Neno]13-Jul-07 23:32 
QuestionMaskedTextBox in DataGridView Pin
Alain De Cock17-Jan-07 4:16
Alain De Cock17-Jan-07 4:16 
GeneralThx for that control Pin
Fabian Deitelhoff5-Oct-06 3:39
Fabian Deitelhoff5-Oct-06 3:39 
QuestionHow can this component be used in Visual Basic 2005 Pin
bobishkindaguy2-Oct-06 9:44
bobishkindaguy2-Oct-06 9:44 
I have read in various places that a c# class can be compiled and referenced in a visual basic project.

I tried doing that with the nullable masked edit control, and the control would not appear in my toolbox.

Can you give me a hint on how to do this correctly?
GeneralSelect &quot;reverse&quot; problem [modifed] Pin
Mark Carranza19-May-06 17:10
Mark Carranza19-May-06 17:10 
GeneralGreat help, thanks Pin
Mark Carranza19-May-06 1:23
Mark Carranza19-May-06 1:23 
GeneralRe: Great help, thanks Pin
Marc Clifton19-May-06 2:23
mvaMarc Clifton19-May-06 2:23 
GeneralRe: Great help, thanks Pin
Mark Carranza19-May-06 3:15
Mark Carranza19-May-06 3:15 
QuestionWhen bound to a date column? Pin
mejojo1-May-06 12:54
mejojo1-May-06 12:54 
AnswerRe: When bound to a date column? Pin
Marc Clifton1-May-06 14:15
mvaMarc Clifton1-May-06 14:15 
GeneralRe: When bound to a date column? Pin
mejojo22-Jun-06 9:52
mejojo22-Jun-06 9:52 
GeneralRe: When bound to a date column? Pin
Oldgamer11-Apr-07 12:53
Oldgamer11-Apr-07 12:53 
AnswerRe: When bound to a date column? Pin
uecasm6-May-07 19:02
uecasm6-May-07 19:02 
GeneralRe: When bound to a date column? Pin
Oldgamer7-May-07 5:45
Oldgamer7-May-07 5:45 
GeneralRe: When bound to a date column? Pin
uecasm8-May-07 15:36
uecasm8-May-07 15:36 
GeneralFor numeric masks - it is not working as desired Pin
Tushar Bhatt23-Apr-06 20:46
Tushar Bhatt23-Apr-06 20:46 
GeneralRe: For numeric masks - it is not working as desired Pin
Marc Clifton24-Apr-06 1:28
mvaMarc Clifton24-Apr-06 1:28 
GeneralRe: For numeric masks - it is not working as desired Pin
robrich24-Apr-06 7:27
robrich24-Apr-06 7:27 
GeneralRe: For numeric masks - it is not working as desired Pin
HMUE24-Apr-06 23:41
HMUE24-Apr-06 23:41 
GeneralRe: For numeric masks - it is not working as desired [modified] Pin
bluerutas15-May-07 20:50
bluerutas15-May-07 20:50 
QuestionError Pin
MAP Tiger23-Apr-06 6:27
MAP Tiger23-Apr-06 6:27 
AnswerRe: Error Pin
Marc Clifton23-Apr-06 10:05
mvaMarc Clifton23-Apr-06 10:05 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.