C#

C#: A Better Date-Masked Text Box


Let’s face it. Managing date information within the .net framework (or any framework, really . . . Java is not much better) is a pain the the ass. Really. What makes it even worse is managing user data entry of date information. If that isn’t bad enough, there is a definite data type mismatch between the manner in which the .net framework represents date information, and the way relational databases handle dates.

The for this project (with a silly demo) is available on Github as a VS 2010 solution. Please feel free to fork, and if you make happy improvements, hit me with a pull request. There is plenty of room for improvement.

The Date Time Picker is Not Appropriate for All Situations . . . Because Sometimes, the Date is Unknown . . .

Sometimes we need to provide a means for users to enter a date if they have the information, and/or leave the date empty (null, if you will) until such time as they do. For example, in entering form data for a person, we may or may not know their Date of Birth. Do we really want to require some date, if we don’t know the correct birthdate? If we use the .Net DateTimePicker control, we have to. While there are hacks and workarounds for this, most require some sort of painful validation checking in our code

Never mind that the DateTimePicker is not the preferred data-entry choice for people who know how to tab through fields. Folks who are good, tab through fields, and enter data. When they come to a date field for which they have no data, they skip past it. They do NOT leave the default date there. And the DateTimePicker requires some date to be present. Not to mention the temptation to stop the tab-type-tab workflow by making you pick from a popup calendar.

The .Net/Winforms Masked Textbox Sucks for Date Entry

That’s right. You heard me. Once upon a time, way back in MS Access, there existed a decent masking approach for entering date values into a textbox. MS seems to have tossed this aside, and delivered the lame control we have at our disposal in the .Net Framework. There are probably reasons for this, but I don’t know what they are. If you have tried using the MaskedTextbox control in a .net application for the purpose of masking date entry, you know what I mean. If you haven’t, go try it out. Then come back here, and see if my solution might be of help.

What I Needed for a Project at Work

I have been stuck working with a rather dull database application at work, and what I needed was a means to perform date entry with the following requirements:

  • Null Values are allowed and desirable in the Database backend.
  • There will be many places where date-entry is performed, across many forms (it is a date-heavy application related to property management), so the date entry control must be easy to toss onto a form and build around, without a bunch of bs validation and string parsing every time.
  • Null values are allowed, but other invalid entries are not.
  • All date entry will be performed using the USA-centric mm/dd/yyyy format.
  • The time component is irrelevant, or will be handled as a separate entry using a different control
  • Given entry of a valid date string, a .net DateTime object should be retrieved from the control.
  • Only dates between 1900 and 2099 will be recognized as valid.

What I wanted, for this project, and for general use in whatever other context pops up, was a means to allow the typing in of a date into a text box, validation of the result as a valid date, and the ability for the client code to simply retrieve a nullable datetime object.

For the purposes of my project, I have achieved these requirements. The control has flaws to this point, in terms of general use (limiting the acceptable centuries comes immediately to mind), but it is a starting point.

My Solution: The MaskedDateTextbox Control

I set out to replicate the user-facing aspects of the venerable VBA Masking approach found in the MS Access Textbox, and join it with the .net type system such that a text string date representation could be validated, and then returned to the client code in a useful form, even if null was present.

Inheriting From MaskedTextbox

I began by inheriting from the crusty .net MaskedTextBox control. First order of business was to define the mask we would be using for date entry. For my purposes, I needed to get this done fast, and the project I am working on will only ever require dates in the US-style mm/dd/yyyy format, so I opted to basically fix this as the only mask available.

But what about globalization?

As you can see, for this work-specific implementation, I thwart attempts to change to a different mask, because the code to this point depends upon a final output format of mm/dd/yyyy Anything else will require more work. That said, it would be a small issue to adapt the code to utilize a different date format. I didn’t have time to build in the kind of flexibility which would allow the mask, and the required validations and text manipulations, to be variable. But adapting the code to recognize and work with some other format should be a small problem.

In any case, the following code defines a class, MaskedDateTextBox, which inherits from the .net MaskedTextBox. As you can see, I have defined a few private members, a couple event handlers, a constructor overload, and overridden the OnMaskChanged method. Most importantly, I have set the mask and the prompt character to the standard format, using private constants. The mask format 00/00/0000 requires integers where the zero placeholders are, and ignores non-integer entries.

The Beginnings of the MaskedDateTextBox Class:
public class MaskedDateTextBox : MaskedTextBox
{
    // Default setting is to require a valid date string before allowing
    // the user to navigate away from the control:
    public bool _RequireValidEntry = true;
 
    // The default mask is traditional, USA-centric mm/dd/yyyy format.
    private const string DEFAULT_MASK = "00/00/0000";
    private const char DEFAULT_PROMPT = '_';
 
    // A flag is set when control initialization is complete. This
    // will be used to determine if the Mask property of the control
    // (inherited from the Base class) can be changed.
    private bool _Initialized = false;
 
    public MaskedDateTextBox() : this(true) { }
 
    public MaskedDateTextBox(bool RequireValidEntry = true) : base()
    {
        // This is the only mask that will work in the current implementation:
        this.Mask = DEFAULT_MASK;
        this.PromptChar = DEFAULT_PROMPT;
 
        // Handle Events:
        this.Enter +=new EventHandler(MaskedDateTextBox_SelectAllOnEnter);
        this.PreviewKeyDown +=new PreviewKeyDownEventHandler(MaskedDateBox_PreviewKeyDown);
 
        // prevent further changes to the mask:
        _Initialized = true;
    }
 
    protected override void OnMaskChanged(EventArgs e)
    {
        if (_Initialized)
        {
            throw new NotImplementedException("The Mask is not chageable in this control");
        }
    }
}

Note the boolean member _IsInitialized. This is set to true immediately after the mask is set in the constructor. From this point forward, attempts by client code to change the mask will fail, and throw a not implemented exception when the OnMaskChanged method is called by the base.

Also note the optional Constructor parameter RequireValidEntry, which is used to set the local member _RequireValidEntry. This matters a little further along. As it is, the constructor parameter defaults to true, even when the default constructor is used. However, there are cases in which one might prefer to handle invalid date entry from the client code, and this parameter (and the member it sets) come into play at that point. More on this in a minute.

Straightening Out The Date

The core of this control, and the reason I needed to build it, are evidenced in the following logic, which examines user input, and attempts to get it into the proper mm/dd/yyyy format. The trick here is that some people may enter 1/1/2012, others may enter 01/01/2012, and still others may try to use 1-1-12. In my mind, all of these should resolve to the same date.

Add this code to the MaskedDateTextBox class:

Correcting Date Text Entry to Match the Standard Format:
void CorrectDateText(MaskedTextBox dateTextBox)
{
    // Replace any odd date separators with the mm/dd/yyyy Standard:
    Regex rgx = new Regex(@"(\\|-|\.)");
    string FormattedDate = rgx.Replace(dateTextBox.Text, @"/");
 
    // Separate the date components as delimited by standard mm/dd/yyyy formatting:
    string[] dateComponents = FormattedDate.Split('/');
    string month = dateComponents[0].Trim(); ;
    string day = dateComponents[1].Trim();
    string year = dateComponents[2].Trim();
 
    // We require a two-digit month. If there is only one digit, add a leading zero:
    if (month.Length == 1)
        month = "0" + month;
 
    // We require a two-digit day. If there is only one digit, add a leading zero:
    if (day.Length == 1)
        day = "0" + day;
 
    // We require a four-digit year. If there are only two digits, add
    // two digits denoting the current century as leading numerals:
    if (year.Length == 2)
        year = "20" + year;
 
    // Put the date back together again with proper delimiters, and
    dateTextBox.Text = month + "/" + day + "/" + year;
}

Note that we pass this method a reference to a MaskedTextBox. I could have accessed the properties of the containing MaskedTextBox class directly, but it seemed cleaner this way. Also, I may decide to extract this method out into its own class (DateStringFormatter?).

OK. So, we will use the previous method from a number of locations. First off, directly, and the user is entering text. We want to force the user’s input into the proper format as they type (for example, if they separate their date parts with dashes instead of slashes). For this, we will handle the PreviewKeyDown Event. Remember, in our constructor, we added a handler for the PreviewKeyDown Event? Now we’re going to handle that event:

Handle User Input as it Happens with PreviewKeyDown Event:
protected virtual void MaskedDateBox_PreviewKeyDown(object sender,
                                        PreviewKeyDownEventArgs e)
{
    MaskedTextBox txt = (MaskedTextBox)sender;
 
    // Check for common date delimiting characters. When encountered,
    // adjust the text entry for proper date formatting:
    if (e.KeyCode == Keys.Divide
        || e.KeyCode == Keys.Oem5
        || e.KeyCode == Keys.OemQuestion
        || e.KeyCode == Keys.OemPeriod
        || e.KeyValue == 190
        || e.KeyValue == 110)
 
        // If any of the above key values are encountered, apply a formatting
        // check to the text entered so far, and make adjustments as needed.
        this.CorrectDateText(txt);
}

In the above, we test for the various keys which might indicate the wrong sorts of date delimiter inputs. If any of these undesirable characters are found, we make a quick call to our CorrectDateText method, and straighten things out on the fly, so to speak.

Validate the User Input

Next, we want to perform a check when the user navigates away from the control, to be sure that what they have entered is, in fact, a valid date, as well as to perform any additional re-formatting required. We need three methods to do this. The OnLeave method, which overrides the same method on the base class, uses the boolean function IsValidDate to see if the string represented in the control is a valid date. If so, the overridden method calls the OnLeave method on the base and allows the user to navigate away from the control. If the date is not valid, then the OnInvalidDateEntry method is executed, which raises the InvalidDateEntered event, and depending upon the state of _RequireValidEntry, returns the user to the control to correct the issue.

Testing for a Valid Date Entry Before Leaving the Control
bool IsValidDate(MaskedTextBox dateTextBox)
{
    // Remove delimiters from the text contained in the control.
    string DateContents = dateTextBox.Text.Replace("/", "").Trim();
 
    // if no date was entered, we will be left with an empty string
    // or whitespace.
    if (!string.IsNullOrEmpty(DateContents) && DateContents != "")
    {
        // Split the original date into components:
        string[] dateSoFar = dateTextBox.Text.Split('/');
        string month = dateSoFar[0].Trim(); ;
        string day = dateSoFar[1].Trim();
        string year = dateSoFar[2].Trim();
 
        // If the component values are of the proper length for mm/dd/yyyy formatting:
        if (month.Length == 2
            && day.Length == 2
            && year.Length == 4
            && (year.StartsWith("19") || year.StartsWith("20")))
        {
            // Check to see if the string resolves to a valid date:
            DateTime d;
            if (!DateTime.TryParse(dateTextBox.Text, out d))
            {
                // The string did NOT resolve to a valid date:
                return false;
            }
            else
                // The string resolved to a valid date:
                return true;
        }
        else
        {
            // The Components are not of the correct size, and automatic adjustment
            // is unsuccessful:
            return false;
 
        } // End if Components are correctly sized
    }
    else
        // The date string is empty or whitespace - no date is a valid return:
        return true;
}
 
 
protected override void OnLeave(EventArgs e)
{
    // Perform a final adjustment of the text entry to fit the mm/dd/yyyy format:
    this.CorrectDateText(this);
 
    // If the entry is a valid date, fire the leave event. We are done here.
    if (this.IsValidDate(this))
    {
        base.OnLeave(e);
    }
    else
    {
        this.OnInvalidDateEntry(this, new InvalidDateTextEventArgs(this.Text.Trim()));
 
        // if a valid date entry is not required, the user is free to navigate away
        // from the control:
        if (!_RequireValidEntry)
        {
            base.OnLeave(e);
        }
    }
}
 
 
protected virtual void OnInvalidDateEntry(object sender, InvalidDateTextEventArgs e)
{
    if (_RequireValidEntry)
    {
        // Force the user to address the problem before
        // navigating away from the control:
        MessageBox.Show(e.Message);
        this.Focus();
        this.MaskedDateTextBox_SelectAllOnEnter(this, new EventArgs());
    }
 
    // Raise the invalid entry event either way. Client code can determine
    // if and how invalid entry should be dealt with:
    if (InvalidDateEntered != null)
    {
        InvalidDateEntered(this, e);
    }
}

Define a Custom Event Handler: InvalidDateTextEventArgs

We hooked up the InvalideDateEntered event in our constructor. However, I defined a custom EventArgs class which is required by the InvalidDateEntered Event. Define the following class in a separate code file:

A Custom EventArgs Class: InvalidDateTextEventArgs
using System;


namespace MaskedDateEntryControl
{
    public class InvalidDateTextEventArgs : EventArgs
    {

        private string _Message = ""
            + "Text does not resolve to a valid date. "
            + "Enter a date in mm/dd/yyyy format, "
            + "or clear the text to represent an empty date.";

        private string _InvalidDateString = "";


        public InvalidDateTextEventArgs(string InvalidDateString) : base()
        {
            _InvalidDateString = InvalidDateString;
        }


        public InvalidDateTextEventArgs(string InvalidDateString, string Message)
            : this(InvalidDateString)
            {
                _Message = Message;
            }


        public String Message
        {
            get { return _Message; }
            set { _Message = value; }
        }


        public String InvalidDateString
        {
            get { return _InvalidDateString; }
        }
    }
}

Return a Nullable DateTime Object

Ok. Remember one of my requirements was the ability to retrieve a DateTime object (or null) directly from the MaskedDateTextBox control? This next bit of code is where we do that. Add this code to the MaskedDateTextBox control right after the OnInvalidDateEntry method:

A Property Which Returns a Nullable DateTime Object Based on User Input:
public DateTime? DateValue
{
    get
    {
        DateTime d;
        DateTime? Result = null;
        if (DateTime.TryParse(this.Text, out d))
        {
            Result = d;
        }
        return Result;
    }
    set
    {
        string DateString = "";
        if (value.HasValue)
            DateString = value.Value.ToString("MM/dd/yyyy");
        this.Text = DateString;
    }
}

Using BeginInvoke to Overcome a Deficiency in the .Net MaskedTextBox Control:

So, the thing which was driving me crazy about this control (and the .net MaskedTextBox from which it derives) was that there did NOT seem to be a way to cause the text in the control to be selected upon entry, such as when an invalid string was entered, and the user is returned to the control to fix it. I wanted the complete text existing in the control to be selected at such time as the user enters the control. Unfortunately, there is apparently a bug in the implementation of the MaskedTextBox control which prevents this from happening using the familiar Textbox.Select() method. As it turns out, there is a workaround,which requires the following code:

Use BeginInvoke to Select the Text contained in the MaskedTextBox Control (and derived controls):
void MaskedDateTextBox_SelectAllOnEnter(object sender, EventArgs e)
{
    MaskedTextBox m = (MaskedTextBox)sender;
 this.BeginInvoke((MethodInvoker)delegate()
    {
        m.SelectAll();
    });
}

In the end, the MaskedDateTextBox as described here provided the solution for the moment. Is this a great control? No. It needs work. There are some limitations resulting from the need to get it done NOW which some design improvements would correct. What could be made better?

  • Not restricted to s single date format
  • Not restricted to years beginning with 19 and 20
  • Probably some refactoring could be done with some of the nested conditionals

You can find the source code for this at my Clone the VS2010 project if you want to see the complete code. If you see ways to improve it, please, feel free. If you succeed, hit me up with a pull request – I will happily merge your changes.

C#
Extending C# Listview with Collapsible Groups (Part I)
C#
Extending C# Listview with Collapsible Groups (Part II)
CodeProject
Installing and Using SQLite on Windows
There are currently no comments.