3

I know this is an age-old question with many an answer, but I haven't found any good, robust answers.

The requirement is a textbox that will always contain a string that Double.TryParse will return true on.

Most of the implementations I have seen do not guard against input such as: "10.45.8". This is a problem.

The preferable way of doing this is entirely with events, such as TextInput and KeyDown (for spaces). The problem with these is that it is quite complicated to get a string representing the new Text before it is changed (or the old Text after it is changed). The problem with TextChanged is that it doesn't provide a way to get the old Text.

If you could somehow get the new Text before it changes, that would be the most helpful, since you could test it against Double.TryParse. There may be a better solution though.

What is the best way to do this?

The best answer to this question is one that has several approaches and compares them.

H.B.
  • 166,899
  • 29
  • 327
  • 400
Kendall Frey
  • 43,130
  • 20
  • 110
  • 148

3 Answers3

3

Approach 1

Use a combination of the TextChanged and KeyDown events for a TextBox. On KeyDown you could save the current text in the textbox and then do your Double.TryParse in the TextChanged event. If the text entered is not valid, then you would revert to the old text value. This would look like:

private int oldIndex = 0;
private string oldText = String.Empty;

private void textBox1_TextChanged(object sender, TextChangedEventArgs e)
{
    double val;
    if (!Double.TryParse(textBox1.Text, out val))
    {
        textBox1.TextChanged -= textBox1_TextChanged;
        textBox1.Text = oldText;
        textBox1.CaretIndex = oldIndex;
        textBox1.TextChanged += textBox1_TextChanged;
    }
}

private void textBox1_KeyDown(object sender, KeyEventArgs e)
{
    oldIndex = textBox1.CaretIndex;
    oldText = textBox1.Text;
}

The CaratIndex is useful in not annoying your user to death with moving the cursor to the first position on failed validation. However, this method doesn't catch the SpaceBar key press. It will allow text to be entered like this "1234.56 ". Also, pasting text will not be properly validated. Beyond this, I don't like messing with the event handlers during text updating.

Approach 2

This approach should meet your needs.

Use the PreviewKeyDown and PreviewTextInput event handlers. By watching these events and handling accordingly, you don't need to worry about reverting to a previous text value in your text box. PreviewKeyDown can be used to watch for and ignore your SpaceBar key press and PreviewTextInput can be used to test your new textbox value before it is assigned.

private void textBox1_PreviewKeyDown(object sender, KeyEventArgs e)
{
    if (e.Key == Key.Space)
    {
        e.Handled = true;
    }
}

private void textBox1_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
    //Create a string combining the text to be entered with what is already there.
    //Being careful of new text positioning here, though it isn't truly necessary for validation of number format.
    int cursorPos = textBox1.CaretIndex;
    string nextText;
    if (cursorPos > 0)
    {
        nextText = textBox1.Text.Substring(0, cursorPos) + e.Text + textBox1.Text.Substring(cursorPos);
    }
    else
    {
        nextText = textBox1.Text + e.Text;
    }
    double testVal;
    if (!Double.TryParse(nextText, out testVal))
    {
        e.Handled = true;
    }
}

This approach does a better job of catching invalid input before it gets into the textbox. However, setting the event to be Handled I suppose could get you into trouble depending on the rest of the destinations in the routing list for the message. A last piece that isn't handled here is the ability of the user to paste invalid input into the text box. This can be handled with the addition of this code, which is built off of Paste Event in a WPF TextBox.

private void OnPaste(object sender, DataObjectPastingEventArgs e)
{
    double testVal;
    bool ok = false;

    var isText = e.SourceDataObject.GetDataPresent(System.Windows.DataFormats.Text, true);
    if (isText)
    {
        var text = e.SourceDataObject.GetData(DataFormats.Text) as string;
        if (Double.TryParse(text, out testVal))
        {
            ok = true;
        }
    }

    if (!ok)
    {
        e.CancelCommand();
    }
}

Add this handler with this code after the InitializeComponent call:

DataObject.AddPastingHandler(textBox1, new DataObjectPastingEventHandler(OnPaste));
Community
  • 1
  • 1
Adam S
  • 3,065
  • 1
  • 37
  • 45
  • Question about Approach 1: PreviewTextInput would work better than KeyDown, right? (It's device-independent. And my scenario happens to be on a tablet, where a user may not use a keyboard.) – Kendall Frey Feb 03 '12 at 18:28
  • If KeyDown or PreviewKeyDown isn't going to work on your device, the case you'll have to handle is how to pick up the Space key press, I don't think that will fire the PreviewTextInput event. – Adam S Feb 03 '12 at 19:46
  • There are more problems than just the spacebar, see my answer (and that's leaving aside that space is a valid thousands separator in some cultures - e.g. fr-FR). – Joe Feb 04 '12 at 17:47
0

Its really annoying that TextBox does not provide PreviewTextChanged event and everybody should invent the wheel every time to emulate it. I solved exactly the same issue recently and even published my solution on github as WpfEx project (take a look at TextBoxBehavior.cs and TextBoxDoubleValidator.cs).

Adam S's answer is very good, but we should consider few other corner cases as well.

  1. Selected text.

During coputing resulting text in our textBox_PreviewTextInput event handler we should consider that user can select some text in text box and new input will replace it. So we should use something like:

private static void PreviewTextInputForDouble(object sender, 
    TextCompositionEventArgs e)
{
    // e.Text contains only new text and we should create full text manually

    var textBox = (TextBox)sender;
    string fullText;

    // If text box contains selected text we should replace it with e.Text
    if (textBox.SelectionLength > 0)
    {
        fullText = textBox.Text.Replace(textBox.SelectedText, e.Text);
    }
    else
    {
        // And only otherwise we should insert e.Text at caret position
        fullText = textBox.Text.Insert(textBox.CaretIndex, e.Text);
    }

    // Now we should validate our fullText, but not with
    // Double.TryParse. We should use more complicated validation logic.
    bool isTextValid = TextBoxDoubleValidator.IsValid(fullText);

    // Interrupting this event if fullText is invalid
    e.Handled = !isTextValid;
}

And we should use the same logic when we'll handle OnPaste event.

  1. Validating the text

We can't use simple Double.TryParse, because user can type '+.' to type '+.1' ('+.1' - is absolutely valid string for double), so our validation method should return true on '+.' or '-.' strings (I even created separate class called TextBoxDoubleValidator and the set of unit tests because this logic is so important).

Before dig into implementation lets take a look at set of unit tests that will cover all corner cases for validation method:

[TestCase("", Result = true)]
[TestCase(".", Result = true)]
[TestCase("-.", Result = true)]
[TestCase("-.1", Result = true)]
[TestCase("+", Result = true)]
[TestCase("-", Result = true)]
[TestCase(".0", Result = true)]
[TestCase("1.0", Result = true)]
[TestCase("+1.0", Result = true)]
[TestCase("-1.0", Result = true)]
[TestCase("001.0", Result = true)]
[TestCase(" ", Result = false)]
[TestCase("..", Result = false)]
[TestCase("..1", Result = false)]
[TestCase("1+0", Result = false)]
[TestCase("1.a", Result = false)]
[TestCase("1..1", Result = false)]
[TestCase("a11", Result = false)]
[SetCulture("en-US")]
public bool TestIsTextValid(string text)
{
    bool isValid = TextBoxDoubleValidator.IsValid(text);
    Console.WriteLine("'{0}' is {1}", text, isValid ? "valid" : "not valid");
    return isValid;
}

Note, that I'm using SetCulture("en-US') attribute, because decimal separator "local-specific".

I think I cover all corner cases with those tests but with this tool in your hands you can easily "emulate" user imput and check (and reuse) whatever cases you want. And now lets take a look at TextBoxDoubleValidator.IsValid method:

/// <summary> 
/// Helper class that validates text box input for double values. 
/// </summary> 
internal static class TextBoxDoubleValidator 
{ 
    private static readonly ThreadLocal<NumberFormatInfo> _numbersFormat = new ThreadLocal<NumberFormatInfo>( 
        () => Thread.CurrentThread.CurrentCulture.NumberFormat);

    /// <summary> 
    /// Returns true if input <param name="text"/> is accepted by IsDouble text box. 
    /// </summary> 
    public static bool IsValid(string text) 
    { 
        // First corner case: null or empty string is a valid text in our case 
        if (text.IsNullOrEmpty()) 
            return true;

        // '.', '+', '-', '+.' or '-.' - are invalid doubles, but we should accept them 
        // because user can continue typeing correct value (like .1, +1, -0.12, +.1, -.2) 
        if (text == _numbersFormat.Value.NumberDecimalSeparator || 
            text == _numbersFormat.Value.NegativeSign || 
            text == _numbersFormat.Value.PositiveSign || 
            text == _numbersFormat.Value.NegativeSign + _numbersFormat.Value.NumberDecimalSeparator || 
            text == _numbersFormat.Value.PositiveSign + _numbersFormat.Value.NumberDecimalSeparator) 
            return true;

        // Now, lets check, whether text is a valid double 
        bool isValidDouble = StringEx.IsDouble(text);

        // If text is a valid double - we're done 
        if (isValidDouble) 
            return true;

        // Text could be invalid, but we still could accept such input. 
        // For example, we should accepted "1.", because after that user will type 1.12 
        // But we should not accept "..1" 
        int separatorCount = CountOccurances(text, _numbersFormat.Value.NumberDecimalSeparator); 

        // If text is not double and we don't have separator in this text 
        // or if we have more than one separator in this text, than text is invalid 
        if (separatorCount != 1) 
            return false;

        // Lets remove first separator from our input text 
        string textWithoutNumbersSeparator = RemoveFirstOccurrance(text, _numbersFormat.Value.NumberDecimalSeparator);

        // Second corner case: 
        // '.' is also valid text, because .1 is a valid double value and user may try to type this value 
        if (textWithoutNumbersSeparator.IsNullOrEmpty()) 
            return true;

        // Now, textWithoutNumbersSeparator should be valid if text contains only one 
        // numberic separator 
        bool isModifiedTextValid = StringEx.IsDouble(textWithoutNumbersSeparator); 
        return isModifiedTextValid; 
    }

    /// <summary> 
    /// Returns number of occurances of value in text 
    /// </summary> 
    private static int CountOccurances(string text, string value) 
    { 
        string[] subStrings = text.Split(new[] { value }, StringSplitOptions.None); 
        return subStrings.Length - 1;

    }

    /// <summary> 
    /// Removes first occurance of valud from text. 
    /// </summary> 
    private static string RemoveFirstOccurrance(string text, string value) 
    { 
        if (string.IsNullOrEmpty(text)) 
            return String.Empty; 
        if (string.IsNullOrEmpty(value)) 
            return text;

        int idx = text.IndexOf(value, StringComparison.InvariantCulture); 
        if (idx == -1) 
            return text; 
        return text.Remove(idx, value.Length); 
    }

}
Sergey Teplyakov
  • 11,477
  • 34
  • 49
0

A comment rather than an answer, but...

I would beware of validating input on each keypress as it can have unintended consequences and annoy the end user.

For example, I remember being annoyed by a datepicker control which would not allow dates in the future, and was initialized to today's date. It performed validation after entering the day, month or year, so that it was impossible to enter a month/day later than the current date without first changing the year.

In the case of doubles, you could have a similar problem, for example your proposed validation would prevent the user from entering the perfectly valid values "-1", ".12", "1e+5":

-       - invalid
-1      - valid

.       - invalid
.1      - valid

1       - valid
1e      - invalid
1e+     - invalid
1e+5    - valid

I would recommend validating as normal when the user leaves the textbox or explicitly validates by clicking a button.

Joe
  • 122,218
  • 32
  • 205
  • 338