Click here to Skip to main content
Click here to Skip to main content
Go to top

Nullable Masked Edit, and a Better Masked Edit Also!

, 22 Apr 2006
Rate this:
Please Sign up or sign in to vote.
A nullable masked edit control based on the .NET 2.0 MaskedTextBox.

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:

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.

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

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

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

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

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

/// <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:

In my control, the cursor is advanced:

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.

/// <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:

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

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:

...
// 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:

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:

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:

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:

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:

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

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:

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:

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

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:

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

Share

About the Author

Marc Clifton

United States United States
Marc is the creator of two open source projets, MyXaml, a declarative (XML) instantiation engine and the Advanced Unit Testing framework, and Interacx, a commercial n-tier RAD application suite.  Visit his website, www.marcclifton.com, where you will find many of his articles and his blog.
 
Marc lives in Philmont, NY.

Comments and Discussions

 
QuestionHandling numeric values that have less digits than the mask. Pinmembermlochem20-Jan-12 6:13 
GeneralBUG Pinmemberreflex@codeproject3-Jan-09 15:51 
Generalthis control drop the stored value when it get focus PinmemberShakalama12-Nov-08 17:00 
GeneralPlz need some help, Text disapear when contol is clicked on [modified] Pinmember[Neno]13-Jul-07 23:32 
QuestionMaskedTextBox in DataGridView PinmemberAlain De Cock17-Jan-07 4:16 
GeneralThx for that control PinmemberFabian Deitelhoff5-Oct-06 3:39 
QuestionHow can this component be used in Visual Basic 2005 PinmemberBob-ish2-Oct-06 9:44 
GeneralSelect &quot;reverse&quot; problem [modifed] PinmemberMark Carranza19-May-06 17:10 
GeneralGreat help, thanks PinmemberMark Carranza19-May-06 1:23 
GeneralRe: Great help, thanks PinprotectorMarc Clifton19-May-06 2:23 
GeneralRe: Great help, thanks PinmemberMark Carranza19-May-06 3:15 
QuestionWhen bound to a date column? Pinmembermejojo1-May-06 12:54 
AnswerRe: When bound to a date column? PinprotectorMarc Clifton1-May-06 14:15 
GeneralRe: When bound to a date column? Pinmembermejojo22-Jun-06 9:52 
GeneralRe: When bound to a date column? PinmemberOldgamer11-Apr-07 12:53 
AnswerRe: When bound to a date column? Pinmemberuecasm6-May-07 19:02 
GeneralRe: When bound to a date column? PinmemberOldgamer7-May-07 5:45 
GeneralRe: When bound to a date column? Pinmemberuecasm8-May-07 15:36 
GeneralFor numeric masks - it is not working as desired PinmemberTushar Bhatt23-Apr-06 20:46 
GeneralRe: For numeric masks - it is not working as desired PinprotectorMarc Clifton24-Apr-06 1:28 
GeneralRe: For numeric masks - it is not working as desired Pinmemberrobrich24-Apr-06 7:27 
GeneralRe: For numeric masks - it is not working as desired PinmemberHMUE24-Apr-06 23:41 
GeneralRe: For numeric masks - it is not working as desired [modified] PinmemberHazz Polo15-May-07 20:50 
QuestionError PinmemberMAP Tiger23-Apr-06 6:27 
AnswerRe: Error PinprotectorMarc Clifton23-Apr-06 10:05 

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

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

| Advertise | Privacy | Mobile
Web01 | 2.8.140916.1 | Last Updated 22 Apr 2006
Article Copyright 2006 by Marc Clifton
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid