Welcome to CSharp Labs

Globalized Numeric TextBox Control for WinForms

Sunday, July 28, 2013

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.

Source Code

Download GlobalizedNumericTextBox and Supporting Classes

Comments