Overview
This is a simple extension of the System.Windows.Forms.TextBox
component. Only the following keystrokes are allowed: digits, operators (+,-,*,/), escape, enter, backspace, decimal separator, group separator. This is an adaptation of the Numeric TextBox found on MSDN.
Background
I was working on a project where we have to add costs to items. At first, I wanted to restrict the input to digits and other needed keystrokes (backspace, decimal point, negative sign). But sometimes, our costs involve a few basic calculations. The last thing I wanted was for users to pull out their calculators (most of which, such as myself, don't even have one or don't bother taking it out and simply use Windows Calculator). I looked around and couldn't find anything. So I figure I am not the only one who will benefit from this simple code.
The Code
First, let's create our <CalcTextBox>
class:
Imports System.Globalization
Public Class CalcTextBox
Inherits TextBox
Private valDefault As String = "0.00" Private SpaceOK As Boolean = False
Private Event TotalValidated()
Public Property DefaultValue As String
Get
Return valDefault
End Get
Set(value As String)
valDefault = value
End Set
End Property
Public ReadOnly Property IntValue() As Integer
Get
Return Int32.Parse(Me.Text)
End Get
End Property
Public Property AllowSpace() As Boolean
Get
Return Me.SpaceOK
End Get
Set(ByVal value As Boolean)
Me.SpaceOK = value
End Set
End Property
Public Sub New()
Me.Text= valDefault
Me.TextAlign= HorizontalAlignment.Right
End Sub
End Class
As it is now, this is a simple TextBox
. The IntValue
property returns the integer portion of the TextBox
. The DefaultValue
property allows to change the default TextBox
value to whatever number you decide. Having a default value makes it obvious to the user what it is they should input. Also when the user will press 'Escape', the TextBox
will reset to this value. The AllowSpace
property was taken from the original MSDN post, I didn't bother changing it. Some languages separate the thousands using spaces, so this allows for that to happen.
The New() sub
ensures we put the DefaultValue
into the TextBox
upon creation (visible in design mode as well). It also aligns the text to the right (this could be done as a property as well, but I figured since this is a calculator-type TextBox
, a right-alignment is best.
Select content when control gets Focus
Now, I also wanted the contents of the textbox
to be selected as soon as the control gets focus. This is done using two methods: OnGotFocus
(this happens on tab stop) and OnMouseUp
(when the user clicks on the control). Now in doing this, we don't want EVERY mouse click to cause the selection. So we'll use a flag that we will set when the control already has focus (alreadyFocused
). I snipped this from Tim Murphy (see 2nd answer on this page).
Private alreadyFocused As Boolean
Protected Overrides Sub OnMouseUp(ByVal mevent As MouseEventArgs)
MyBase.OnMouseUp(mevent)
If Not Me.alreadyFocused AndAlso Me.SelectionLength = 0 Then
Me.alreadyFocused = True
Me.SelectAll()
End If
End Sub
Protected Overrides Sub OnLeave(ByVal e As EventArgs)
If Not calculated Then
Me.Text = valOriginal
calculated = True
valOperator = Nothing
Beep()
End If
RaiseEvent TotalValidated()
MyBase.OnLeave(e)
Me.alreadyFocused = False
End Sub
Protected Overrides Sub OnGotFocus(e As EventArgs)
MyBase.OnGotFocus(e)
If MouseButtons = MouseButtons.None Then
Me.SelectAll()
Me.alreadyFocused = True
End If
End Sub
The Interesting Part!
Ok, so now we have a TextBox
with a few addons, but it doesn't do much. The next step is to filter the keystrokes. First, we handle the ones we accept (digits, separators, backspace, enter, escape, etc.) and lastly we'll handle all the rest that we don't want in a simple 'else
' statement. Let's see how it works. Here is the full code of the OnKeyPress
subroutine:
Private valOriginal As Double = valDefault Private valCalculated As Double = valDefault Private valOperator As Char = Nothing Private calculated As Boolean = True
Protected Overrides Sub OnKeyPress(ByVal e As KeyPressEventArgs)
MyBase.OnKeyPress(e)
Dim numberFormatInfo As NumberFormatInfo = _
System.Globalization.CultureInfo.CurrentCulture.NumberFormat
Dim decimalSeparator As String = numberFormatInfo.NumberDecimalSeparator
Dim groupSeparator As String = numberFormatInfo.NumberGroupSeparator
Dim negativeSign As String = numberFormatInfo.NegativeSign
Dim keyInput As String = e.KeyChar.ToString()
If [Char].IsDigit(e.KeyChar) Then
ElseIf keyInput.Equals(decimalSeparator) OrElse keyInput.Equals(groupSeparator) Then
If keyInput.Equals(decimalSeparator) And Me.Text.Contains(decimalSeparator) then
e.Handled=True
Beep()
End If
ElseIf e.KeyChar = vbBack Then
ElseIf Me.SpaceOK AndAlso e.KeyChar = " "c Then
ElseIf e.KeyChar = Microsoft.VisualBasic.ChrW(Keys.Escape) Then
Me.Text = valDefault
Me.SelectAll()
valOriginal = 0
valCalculated = 0
valOperator = Nothing
calculated = True
RaiseEvent TotalValidated()
ElseIf e.KeyChar = Microsoft.VisualBasic.ChrW(Keys.Return) Then
If Not calculated then
If CalculateTotal(e)=True then
valOperator = Nothing
Me.Text = valCalculated
calculated = True
Me.SelectAll()
End If
RaiseEvent TotalValidated() End If
e.Handled = True
ElseIf e.KeyChar = "/"c OrElse e.KeyChar = "*"c _
OrElse e.KeyChar = "+"c OrElse e.KeyChar = "-"c Then
If Me.Text <> "" Then
If calculated = False Then
Dim tmpResult as Boolean = CalculateTotal(e) Else
valOriginal = CDbl(Me.Text)
End If
calculated = False
Me.Text = ""
End If
valOperator = e.KeyChar e.Handled = True
Else
e.Handled = True
Beep()
End If
End Sub
Let's try to explain what is happening here.
Protected Overrides Sub OnKeyPress(ByVal e As KeyPressEventArgs)
MyBase.OnKeyPress(e)
When the user types anything, whether it is a valid keystroke or not, it will fire the OnKeyPress
event. Since we're overriding it, the first line in the Sub
ensures that we call the parent version of this event. The important thing to know is that the OnKeyPress
event happens before anything is added to the TextBox. This is important since we want to control the input. Some keystrokes will be ignored, others will go through.
Dim numberFormatInfo As NumberFormatInfo = System.Globalization.CultureInfo.CurrentCulture.NumberFormat
Dim decimalSeparator As String = numberFormatInfo.NumberDecimalSeparator
Dim groupSeparator As String = numberFormatInfo.NumberGroupSeparator
Dim negativeSign As String = numberFormatInfo.NegativeSign
Next, we get the decimal separator, negative symbol and group separator from the user's settings. This is especially useful where I live, since some use the different settings (comma as the decimal separator and space as the thousand group separator -- although this last one is never used when inputting numbers, it could be useful when pasting). In any case, it's just a few more lines of code, and makes things almost universal.
If... elseif... andif... else... endif... That's a lot of if's!!!
When the user types digits, separators, or backspace, there's no need to do anything. We will let the TextBox
act normally. This is what these lines of code do:
If [Char].IsDigit(e.KeyChar) Then
ElseIf keyInput.Equals(decimalSeparator) OrElse keyInput.Equals(groupSeparator) Then
If keyInput.Equals(decimalSeparator) And Me.Text.Contains(decimalSeparator) then
e.Handled=True
Beep()
End If
ElseIf e.KeyChar = vbBack Then
ElseIf M.SpaceOK AndAlso e.KeyChar = " "c Then
The first line checks if the keystroke is a digit. The following blocks basically work the same way, but look for different keystrokes. The second block looks for a decimal or group separator but also checks if the decimal separator is already present, preventing it from being entered twice. The third block looks for the backspace key and the last one for the space key (and only if it is allowed through the SpaceOK
variable). In all these cases (except the 2nd one if we already have a decimal separator), the keystroke is allowed, so there is no need to do anything. We simply let the process go through.
In the last line, you might have noticed the 'c' after " ". This is not a typo. It simply converts " " (a string containing only a space) to a KeyChar. You will see this later on in the code for other comparisons.
Let's proceed to the 'escape' key. Basically, this key should reset everything to default values, including the .Text
property. It should also select all the contents so the client can easily proceed with the next operation. This is done using the following code (we're still in the same if
...then
clause):
ElseIf e.KeyChar = Microsoft.VisualBasic.ChrW(Keys.Escape) Then
Me.Text = valDefault
Me.SelectAll()
valOriginal = 0
valCalculated = 0
valOperator = Nothing
calculated = True
Don't worry if you're not sure why some of these variables are assigned these values, you'll understand later on.
Let's skip to the last 'else
' statement of our giant if
..then
clause. This basically is where the logic will bring us everytime an invalid keystroke is pressed. If the keystroke does not correspond to those mentioned above it, then we need to 'swallow' the keystroke, in other words, prevent it from being added to the TextBox
. As you might have noticed, the subroutine has the <KeyPressEventArg>
'e
' is passed along when it is fired. This is not just a variable, but a class that includes functions and properties. So how do we tell it to skip those unwanted keystrokes? We do this using e.Handled=True
. This basically says we've handled the keystroke, no need to do anything else with it. The next events in the chain will not process it (i.e., the event in charge of drawing or painting the character in the TextBox
will not draw anything). We'll also add a beeping sound to advise the user of his mistake. Here is the code:
Else
e.Handled = True
Beep()
End If
Next Step
So far, we've modified our TextBox
to act only on digits (and a few other keystrokes), autoselect on focus (either by clicking or tab stop) and reset when the user presses 'escape'.
Before we dig any further into the code, let's define how we want our TextBox
to work. The user can type any number and as soon as he types one of the operators, we have to store the first operand in a variable (valOriginal
) and the operator in another variable (valOperator
). Then the TextBox
clears out, ready for the user to input the 2nd number, or operand, in our operation. Usually when he's done, the user will press 'enter'. When this happens, we calculate using the first operand (valOriginal
), the operator (valOperator
, telling us what to do), and the current value of our TextBox
(which is not empty) as the last operand.
Too simple. What if we have multiple operations in a row? Then, we have to start calculating after the second operand but before the second operator. For example, if the user types 23*1.5+5, we want to calculate 23x1.5 before adding 5 (we won't use operator precedence, at least not in this version). In order to do this, we will use a variable called 'calculated
' which will always be true
, except when we catch an operator. When false
, it will tell us that we've started a new operation and the next time the user presses 'Enter' or another operator key (and the TextBox.Text
value is not empty, giving us our second operand), we must not store the number in our valOriginal
variable but instead do the math right away and then store the result in that very same variable, passing it on to the next operation. Here is the code with comments to help along. This part of code is just before the last 'else
' statement we just discussed.
ElseIf e.KeyChar = Microsoft.VisualBasic.ChrW(Keys.Return) Then
If Not calculated Then
If CalculateTotal(e) = True Then
valOperator = Nothing
Me.Text = valCalculated
calculated = True
Me.SelectAll()
End If
RaiseEvent TotalValidated()
End If
e.Handled = True
ElseIf e.KeyChar = "/"c OrElse e.KeyChar = "*"c _
OrElse e.KeyChar = "+"c OrElse e.KeyChar = "-"c Then
If Me.Text <> "" Then
If calculated = False Then
Dim tmpResult as Boolean = CalculateTotal(e) Else
valOriginal = CDbl(Me.Text)
End If
calculated = False
Me.Text = ""
End If
valOperator = e.KeyChar e.Handled = True
Next is the CalculateTotal
function:
Private Function CalculateTotal(ByRef e As KeyPressEventArgs) As Boolean
If calculated = False And valOperator <> Nothing And Me.Text <> "" Then
Select Case valOperator
Case "*"c
valCalculated = valOriginal * [Double].Parse(Me.Text)
Case "-"c
valCalculated = valOriginal - [Double].Parse(Me.Text)
Case "+"c
valCalculated = valOriginal + [Double].Parse(Me.Text)
Case "/"c
If [Double].Parse(Me.Text) = 0 Then
Me.Text = valDefault
valOperator = Nothing
valOriginal = 0.0
valCalculated = 0.0
calculated = True
e.Handled = True
Me.SelectAll()
Beep()
Return False End If
valCalculated = valOriginal / [Double].Parse(Me.Text)
End Select
valOriginal = valCalculated
e.Handled = True End If
Return True
End Function
You'll notice that in both 'elseif
' cases we just added, we end up swallowing the key. This is important, we don't want those keys to show in the TextBox
.
The TotalCalculated event
This is a new one in version 2. I simply wanted a way to be noticed either when the user pressed 'Enter', when the contents was reset or when the control lost focus. This is useful if you want to use the content of this CalcTextBox
is used to calculate other items. In my case, as soon as the event is fired in my application, I use the contents to calculate the total costs and update a label on the form.
Conclusion
The only problem I've seen is that you can't negate, since the negative sign is an operator. But for my purpose, we have no need to negate, or do operations using negative operands. Our costs will only be positive, if anything we will subtract numbers from other amounts. All of which is possible using this code.
So there you have it. Simple and easily improvable. If you want to use the provided class file, simply include it in your project and compile your project. The new control will then be available from the toolbox.
Future Addons
- Pasting (verifying content of pasted text)
Prevent double decimal separators (updated Nov 27)
- Setting decimal places
- Verify
DefaultValue
property (make sure it is numeric!)
- Ensure the 2nd operand is not just a decimal separator, group separator or space before calculating
History
- November 27, 2014: Initial version
- November 27, 2014: Added double decimal separator verification
- November 28, 2014: Added
TotalValidated()
event, multiline set to false
on creation, handling what to do when the user moves on to another control without having completed the operation (version 2, uploaded)