Wednesday, July 25, 2018

Numeric Editable ComboBox in WPF

I looked all over to find someone who had done this for me but in the end I had to write it myself.

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;
        }

         public static String UnmaskText(String Text)


        {
            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}"
                                IntegerPart="3" DecimalPart="4" AllowSign="False"
                                Width="100" Height="20" IsEditable="True"
                                HorizontalContentAlignment="Right"/>
</Window>

Our code behind has some properties for the NumericComboBox to bind to.

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));
            }
        }
    }
}

When you run the project you will see the numeric combo box in the middle of the window.


If you edit the value you will see you cannot enter invalid decimals.