There are several .NET numeric TextBox controls available online. Most function either by restricting user input, using Regular Expressions or manually validating characters. These strategies may results in usability issues or validation errors and all are unable to handle culturally specific number formatting. I have created the GlobalizedNumericTextBox control for .NET Forms to restrict and validate numeric values in any culturally appropriate format.
How it Works
The GlobalizedNumericTextBox uses an underlying decimal value to safely parse and accurately format user input. To determine if user input is valid, an decimal.TryParse overload is leveraged with NumberFormatInfo and NumberStyles representing specific culture and style options.
The TextBox.OnValidating method is overridden to prevent the control from losing focus when an invalid value is entered. If validation fails, the ValidationFailed event is raised to allow a subscriber to override an invalid value. If there is no suitable value, the event is canceled and focus is retained:
/// <summary> /// The OnValidating method is overridden to validate the underlying Text property /// to determine if focus can be lost. /// </summary> /// <param name="e"></param> protected override void OnValidating(System.ComponentModel.CancelEventArgs e) { if (base.Text.Length > 0) { decimal candidate; //check if the Text can by parsed into a decimal with //the specified NumberStyles and NumberFormatInfo and //the resulting value is not greater-than or less-than //the maximum and minimum values: if (!decimal.TryParse(base.Text, _NumberStyle, NumberFormat, out candidate) || candidate > _Maximum || candidate < _Minimum) { //if validation failed, raise the ValidationFailed //event to allow a substitute value if (ValidationFailed != null) { //create event args GlobalizedNumericTextBoxValueValidationFailed args = new GlobalizedNumericTextBoxValueValidationFailed(base.Text, Minimum, Maximum); //raise event ValidationFailed(this, args); //check if a replacement value was supplied if (args.ReplacementValue.HasValue) _Value = args.ReplacementValue.Value; //update _Value else e.Cancel = true; //validation failed } else e.Cancel = true; //validation failed } else //validation succeeded _Value = candidate; //update _Value } else if (_Maximum >= 0 && _Minimum <= 0) //attempt to infer meaning of empty Text _Value = 0; //update _Value else //could not infer e.Cancel = true; //validation failed //if validation failed, play system beep if applicable if (e.Cancel && SystemBeepOnValidationFailure) SystemSounds.Beep.Play(); base.OnValidating(e); }
When user input is successfully validated, the Control class calls the OnValidated method, which is overridden to format the Text property:
/// <summary> /// After the value is validated or changes are canceled, /// formats the decimal and updates the Text property. /// </summary> /// <param name="e"></param> protected override void OnValidated(EventArgs e) { //formats the decimal as a string, updates the Text property, //raises events: FormatTextUpdateValue(); base.OnValidated(e); } /// <summary> /// This method formats the underlying decimal and immediately updates /// the _Value field to the numerical equivalent. This is done to ensure /// both the <see cref="Text"/> and <see cref="Value"/> properties /// are equivalent. /// </summary> private void FormatTextUpdateValue() { //format decimal as string string text = FormatText(); //check if Text property should be updated if (text != base.Text) { //update decimal value to ensure equality _Value = decimal.Parse(text, _NumberStyle, NumberFormat); //update Text property to new value base.Text = text; //raise ValueValidated event to notify changes if (ValueValidated != null) ValueValidated(this, new GlobalizedNumericTextBoxValueValidated(base.Text, _Value)); } } /// <summary> /// Formats the underlying decimal value to a string. /// </summary> /// <returns>The value formatted as a string.</returns> private string FormatText() { switch (DisplayFormat) { case GlobalizedNumericTextBoxDisplayFormat.Currency: //format decimal as currency return _Value.ToString("C", NumberFormat); case GlobalizedNumericTextBoxDisplayFormat.Exponent: //format decimal as exponent return _Value.ToString("E", NumberFormat); case GlobalizedNumericTextBoxDisplayFormat.Normal: //format decimal default return _Value.ToString("N", NumberFormat); case GlobalizedNumericTextBoxDisplayFormat.CustomFormat: //format decimal custom return _Value.ToString(CustomFormat, NumberFormat); default: throw new InvalidEnumArgumentException(string.Format("The System.Enum Type: '{0}' Value: '{1}' is not defined.", DisplayFormat.GetType(), DisplayFormat)); } }
To make the control more user friendly, the Control.PreProcessMessage method has been overridden to determine if a character could be used to form a valid value. This is used to supplement regular validation by limiting user input to only potentially valid strings. The number format and number styles are used to determine if a character is allowed:
/// <summary> /// This method will pre-process the WM_CHAR message for the purpose /// of consuming only valid characters. /// </summary> /// <param name="msg"></param> /// <returns></returns> public override bool PreProcessMessage(ref Message msg) { //look for the WM_CHAR message if ((WindowsMessages)msg.Msg == WindowsMessages.WM_CHAR) //determines if the character is valid and the message should be consumed //returns indicating if the message has been processed return !ConsumeCharacter((char)msg.WParam); else return base.PreProcessMessage(ref msg); } /// <summary> /// Determines if the specified character is part /// of a valid decimal. /// </summary> /// <param name="c">The character to validate.</param> /// <returns>true if the character should be consumed; otherwise, false.</returns> private bool ConsumeCharacter(char c) { //if escape is pressed, reset text and cancel key press if (ResetOnEscape && c == (char)27) //escape char : 27 { //resets Text property CancelChanges(); //select all text to allow easy changes and make the update apparent SelectAll(); //cancel character return false; } if (char.IsControl(c)) //allow control characters return true; if (char.IsNumber(c)) //allow number characters return true; //when validating decimal separators, or group separators, the string.IndexOf //method is used instead of comparing character due to the possibility of //more then one character being used as a separator if (DisplayFormat == GlobalizedNumericTextBoxDisplayFormat.Currency) { //validate currency symbol if (NumberFormat.CurrencySymbol.IndexOf(c) > -1) //allow currency symbol e.g. $1000 return true; //validate currency decimal separator if (_NumberStyle.HasFlag(NumberStyles.AllowDecimalPoint) && NumberFormat.CurrencyDecimalDigits > 0 && NumberFormat.CurrencyDecimalSeparator.IndexOf(c) > -1) //allow decimal separator $1000.00 return true; //validate currency group separator if (_NumberStyle.HasFlag(NumberStyles.AllowThousands) && NumberFormat.CurrencyGroupSeparator.IndexOf(c) > -1) //allow currency group separator e.g. $1,000 return true; } else { //validate decimal separator if (_NumberStyle.HasFlag(NumberStyles.AllowDecimalPoint) && NumberFormat.NumberDecimalDigits > 0 && NumberFormat.NumberDecimalSeparator.IndexOf(c) > -1) //allow decimal separator e.g. 1000.00 return true; //validate group separator if (_NumberStyle.HasFlag(NumberStyles.AllowThousands) && NumberFormat.NumberGroupSeparator.IndexOf(c) > -1) //allow group separator e.g. 1,000 return true; } //validate whitespace if ((_NumberStyle.HasFlag(NumberStyles.AllowLeadingWhite) || _NumberStyle.HasFlag(NumberStyles.AllowTrailingWhite)) && char.IsWhiteSpace(c)) //allows whitespace e.g. ' 1000 ' || '$ 1000' return true; //validate number sign if ((_NumberStyle.HasFlag(NumberStyles.AllowLeadingSign) || _NumberStyle.HasFlag(NumberStyles.AllowTrailingSign)) && (NumberFormat.PositiveSign.IndexOf(c) > -1 || NumberFormat.NegativeSign.IndexOf(c) > -1)) return true; //validate parentheses which are allowed to indicate //a negative number if (_NumberStyle.HasFlag(NumberStyles.AllowParentheses)) { if (c == '(' && base.Text.IndexOf('(') == -1) return true; if (c == ')' && base.Text.IndexOf(')') == -1) return true; } //validate exponential notation E/e character if (_NumberStyle.HasFlag(NumberStyles.AllowExponent) && (c == 'E' || c == 'e')) return true; return false; }
The ConsumeCharacter method also can allow a user to cancel changes by pressing the Escape key to revert the value back to the last validated value.
There are numerous properties which control how numeric data is formatted, parsed and validated:
- CurrencyDecimalDigits - Defines the number of decimal places to use in currency values.
- CurrencyDecimalSeparator - Defines the string to use as the decimal separator in currency values.
- CurrencyGroupSeparator - Defines the string that separates groups of digits to the left of the decimal in currency values.
- CurrencyGroupSizes - Defines the number of digits in each group to the left of the decimal in currency values.
- CurrencyNegativePattern - Defines the format pattern for negative currency values.
- CurrencyPositivePattern - Defines the format pattern for positive currency values.
- CurrencySymbol - Defines the string to use as the currency symbol.
- NegativeSign - Defines the string that denotes that the associated number is negative.
- NumberDecimalDigits - Defines the number of decimal places to use in numeric values.
- NumberDecimalSeparator - Defines the string to use as the decimal separator in numeric values.
- NumberGroupSeparator - Defines the string the separates groups of digits to the left of the decimal in numeric values.
- NumberGroupSizes - Defines the number of digits in each group to the left of the decimal in numeric values.
- NumberNegativePattern - Defines the format pattern for negative numeric values.
- PositiveSign - Defines the string that denotes the associated number is positive.
- DisplayFormat - Defines the type of formatting used to display the decimal value.
When the GlobalizedNumericTextBox is added to a form, these properties will be populated and cached from the current culture. The NumberFormat property can be used at run time to populate these values with a user's number format.
Caveats
If an invalid value is entered into the GlobalizedNumericTextBox, any attempts to close the form will fail due to the OnValidating method canceling the FormClosing event. There are two simple ways to allow the form to close:
- Subscribe to the Form.FormClosing event and set the FormClosingEventArgs.Cancel property to false.
- Include a Button with the CausesValidation property set to false that calls GlobalizedNumericTextBox.CancelChanges and closes the form.
Using
Add the GlobalizedNumericTextBox to a Form and set formatting properties and behavior settings to your required specification.