I have a requirement to allow the user to select from a list of tax rates or enter their own. The obvious candidate control is a ComboBox in editable mode, but users can type whatever they want into that. I need to prevent users from typing invalid decimal numbers. This is how I did it.
Start by creating a new User Control called NumericComboBox.
Rename UserControl1 to NumericComboBox and put it in a Namespace called CustomControls. The XAML is trivial. We inherit from ComboBox like this.
<ComboBox x:Class="CustomControls.NumericComboBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:NumericComboBox"
PreviewKeyDown="ComboBox_PreviewKeyDown"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
</ComboBox>
The code behind defines three dependency properties like this. The IntegerPart specifies the maximum digits allowed before the decimal point. The DecimalPart specifies the maximum number of digits allowed after the decimal point. The AllowSign specified whether the user can enter a minus sign or not.
using System;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace CustomControls
{
public partial class NumericComboBox : ComboBox
{
static readonly DependencyProperty IntegerPartProperty;
static readonly DependencyProperty DecimalPartProperty;
static readonly DependencyProperty AllowSignProperty;
public int
IntegerPart
{
get { return (int)GetValue(IntegerPartProperty); }
set { SetValue(IntegerPartProperty, value); }
}
public int
DecimalPart
{
get { return (int)GetValue(DecimalPartProperty); }
set { SetValue(DecimalPartProperty, value); }
}
public Boolean AllowSign
{
get { return (Boolean)GetValue(AllowSignProperty); }
set { SetValue(AllowSignProperty, value); }
}
public NumericComboBox()
{
InitializeComponent();
}
static NumericComboBox()
{
FrameworkPropertyMetadata IntegerPartMetaData = new FrameworkPropertyMetadata(9);
FrameworkPropertyMetadata DecimalPartMetaData = new FrameworkPropertyMetadata(0);
FrameworkPropertyMetadata AllowSignMetaData = new FrameworkPropertyMetadata(false);
IntegerPartProperty = DependencyProperty.Register("IntegerPart", typeof(int), typeof(NumericComboBox), IntegerPartMetaData);
DecimalPartProperty = DependencyProperty.Register("DecimalPart", typeof(int), typeof(NumericComboBox), DecimalPartMetaData);
AllowSignProperty = DependencyProperty.Register("AllowSign", typeof(Boolean), typeof(NumericComboBox), AllowSignMetaData);
}
}
}The other thing we have to do is provide a preview key down event handler. This event handler allows us to discard keystrokes we don't like. The problem with this event is that it tells us the key that was pressed and the current value in the combo box. We have to figure out what the combo box will look like if we allow the key press to complete.
private void ComboBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
ComboBox cb = (ComboBox)sender;
TextBox tb = (TextBox)(cb.Template.FindName("PART_EditableTextBox", cb));
String OldText = tb.Text;
Char EnteredChar = GetCharFromKeyEvent(e);
int
ci = tb.CaretIndex;
String NewText = "";
Boolean Discard = false;
Boolean Process = true;
Boolean AlreadyFull = false;
Boolean HasSign = false;
int
HasBeforeDP = 0;
Boolean HasDP = false;
int
HasAfterDP = 0;
// Clipboard processing
if
((Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) && ("ACVX".Contains(EnteredChar.ToString().ToUpper())))
{
byte[] unicodebytes = Encoding.Unicode.GetBytes(Clipboard.GetText());
string ClipBoardText = Encoding.UTF8.GetString(unicodebytes);
string CleanText = Utility.UnmaskText(ClipBoardText, 0, "#");
if (tb.Text.Replace(tb.SelectedText, CleanText).Split('.').Length != 2 &&
EnteredChar.ToString().ToUpper() == "V")
{
// if the result after the
past would not contain exactly one dp do not allow the paste
e.Handled = true;
return;
}
if (tb.SelectedText.Contains(".") && EnteredChar.ToString().ToUpper() == "X")
{
// If the user is deleting
text that includes the decimal point, make sure the decimal point remains
tb.Text =
tb.Text.Replace(tb.SelectedText, ".");
e.Handled = true;
return;
}
Clipboard.SetText(CleanText, TextDataFormat.Text);
return;
}
// default processing
if
((EnteredChar == '<')
|| (EnteredChar == '>')
|| (EnteredChar == '\t') || (EnteredChar ==
'\r'))
return;
if
(EnteredChar == '\b')
// Backspace
{
if (tb.SelectionLength == 0 && ci > 0 &&
tb.Text.Substring(ci - 1, 1) == ".")
{
// User tried to delete the decimal point
tb.CaretIndex -= 1;
e.Handled = true;
return;
}
if (tb.SelectionLength > 0 && tb.SelectedText.Contains("."))
{
tb.Text =
tb.Text.Replace(tb.SelectedText, ".");
tb.CaretIndex = ci;
e.Handled = true;
return;
}
e.Handled = false;
return;
}
if
(EnteredChar == (Char)127)
// Delete
{
if (tb.SelectionLength == 0 && ci < tb.Text.Length &&
tb.Text.Substring(ci, 1) == ".")
{
// User tried to delete the
decimal point
tb.CaretIndex += 1;
e.Handled = true;
return;
}
if (tb.SelectionLength > 0 && tb.SelectedText.Contains("."))
{
tb.Text =
tb.Text.Replace(tb.SelectedText, ".");
tb.CaretIndex = ci;
e.Handled = true;
return;
}
e.Handled = false;
return;
}
if
(tb.SelectedText.Contains(".")
&& EnteredChar != '.' &&
tb.Text.Length != tb.SelectionLength)
{
// If the user has the dp highlighted and
did not hit period ignore the key press
// Unless the entire text is selected
e.Handled = true;
return;
}
while
(Process)
{
HasSign = false;
HasBeforeDP = 0;
HasDP = false;
HasAfterDP = 0;
AlreadyFull = false;
if ((Keyboard.IsKeyToggled(Key.Insert)) &&
(tb.SelectionLength == 0) && (OldText.Length > ci) &&
(OldText.Substring(ci, 1) != "."))
tb.SelectionLength = 1;
NewText = OldText.Substring(0,
ci) + EnteredChar + OldText.Substring(ci + tb.SelectionLength);
Discard = false;
Process = false;
// Strip trailing zero after DP to make
room for new character
if
((this.DecimalPart > 0)
&& (NewText.IndexOf(".") > -1) && (NewText.Length - NewText.IndexOf(".") - 1 == this.DecimalPart + 1) &&
(NewText.EndsWith("0")))
NewText =
NewText.Substring(0, NewText.Length - 1);
// If NUMERIC and next char is - or . and
user just typed - or . then just move the cursor on
if (("-.".IndexOf(EnteredChar) >
-1) && (ci < OldText.Length) && (OldText.Substring(ci, 1) ==
EnteredChar.ToString()))
{
tb.CaretIndex += 1;
e.Handled = true;
break;
}
foreach (char c in NewText)
{
switch (c)
{
case '-':
if (!AllowSign ||
(NewText.IndexOf(c) > 0) || HasSign)
Discard = true;
HasSign = true;
break;
case '.':
if (HasDP || (DecimalPart ==
0))
Discard = true;
else if (NewText.IndexOf(c) == 0)
ci--;
HasDP = true;
break;
default:
if (char.IsNumber(c))
if (HasDP)
{
HasAfterDP
+= 1;
if (HasAfterDP >
DecimalPart)
AlreadyFull = true;
}
else
{
HasBeforeDP += 1;
if (HasBeforeDP >
IntegerPart)
AlreadyFull = true;
}
else
Discard = true;
break;
}
}
if (!Discard)
{
if (!AlreadyFull)
{
tb.Text = NewText;
tb.CaretIndex = ci + 1;
}
}
}
e.Handled = true;
}
We need a couple of utility functions. One converts a key to a character and the other strips unwanted characters from a string.
public static Char GetCharFromKeyEvent(KeyEventArgs e)
{
Boolean IsNumLockOn = Console.NumberLock;
Boolean IsShiftOn = (Keyboard.IsKeyDown(Key.LeftShift)
|| Keyboard.IsKeyDown(Key.RightShift));
Char Result = '\0';
switch (e.Key)
{
case Key.Back:
Result = '\b';
break;
case Key.Delete:
Result = (Char)127;
break;
case Key.Tab:
Result = '\t';
break;
case Key.Enter:
Result = '\r';
break;
case Key.OemPeriod:
case Key.Decimal:
Result = '.';
break;
case Key.Left:
Result = '<';
break;
case Key.Right:
Result = '>';
break;
case Key.Subtract:
case Key.OemMinus:
if (IsShiftOn)
Result = '_';
else
Result = '-';
break;
case Key.D1:
if (IsShiftOn)
Result = '!';
else
Result = '1';
break;
case Key.D2:
if (IsShiftOn)
Result = '@';
else
Result = '2';
break;
case Key.D3:
if (IsShiftOn)
Result = '#';
else
Result = '3';
break;
case Key.D4:
if (IsShiftOn)
Result = '$';
else
Result = '4';
break;
case Key.D5:
if (IsShiftOn)
Result = '%';
else
Result = '5';
break;
case Key.D6:
if (IsShiftOn)
Result = '^';
else
Result = '6';
break;
case Key.D7:
if (IsShiftOn)
Result = '&';
else
Result = '7';
break;
case Key.D8:
if (IsShiftOn)
Result = '*';
else
Result = '8';
break;
case Key.D9:
if (IsShiftOn)
Result = '(';
else
Result = '9';
break;
case Key.D0:
if (IsShiftOn)
Result = ')';
else
Result = '0';
break;
default:
if ((e.Key >= Key.NumPad0) && (e.Key <= Key.NumPad9))
if (IsNumLockOn)
Result = Convert.ToChar(e.Key.ToString().Replace("NumPad", ""));
else
{
if (e.Key == Key.NumPad4) Result = '<';
if (e.Key == Key.NumPad6)
Result = '>';
}
if ((e.Key >= Key.A) && (e.Key <= Key.Z))
Result = Convert.ToChar(e.Key.ToString());
break;
}
return Result;
}
{
String UnmaskedText = "";
foreach (Char c in Text)
{
if ("1234567890.-".IndexOf(c) < -1) UnmaskedText += c;
}
return UnmaskedText;
}
Now we need to test our new control. Add a WPF Application project to the solution and call it TestNumericComboBox. Don't forget to add a reference to NumericComboBox in your new project's references.
This application will contain a single NumericComboBox like this. Note the new dependency properties are referenced here.
<Window x:Class="TestNumericComboBox.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TestNumericComboBox"
xmlns:custom="clr-namespace:CustomControls;assembly=NumericComboBox"
DataContext="{Binding RelativeSource={RelativeSource Self}}"
mc:Ignorable="d" Title="MainWindow" Height="350" Width="525">
<custom:NumericComboBox ItemsSource="{Binding TaxRates}"
Text="{Binding SelectedTaxRate,
UpdateSourceTrigger=PropertyChanged, StringFormat=N4}"
UpdateSourceTrigger=PropertyChanged, StringFormat=N4}"
IntegerPart="3" DecimalPart="4" AllowSign="False"
Width="100" Height="20" IsEditable="True"
HorizontalContentAlignment="Right"/>
HorizontalContentAlignment="Right"/>
</Window>
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows;
namespace TestNumericComboBox
{
public partial class MainWindow : Window, INotifyPropertyChanged
{
private Decimal _SelectedTaxRate;
public Decimal SelectedTaxRate
{
get { return
_SelectedTaxRate; }
set
{
_SelectedTaxRate = value;
NotifyPropertyChanged("SelectedTaxRate");
}
}
private List<Decimal> _TaxRates ;
public List<Decimal> TaxRates
{
get { return
_TaxRates; }
set
{
_TaxRates = value;
NotifyPropertyChanged("TaxRates");
}
}
public MainWindow()
{
try
{
InitializeComponent();
TaxRates = new List<decimal> {
0.5000M, 0.7500M };
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
public event PropertyChangedEventHandler
PropertyChanged;
private void
NotifyPropertyChanged(String PropertyName = "")
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(PropertyName));
}
}
}
}