C#, ввод только цифр (чисел) в текстовое поле (TextBox).

Или окончательное решение цифирьного вопроса.

Преамбула

Ранее мы показывали простые способы, как обеспечить, чтобы в TextBox можно было ввести только цифры, т.е. целое число (копия), а потом расширили пример до ввода в TextBox отрицательных (копия) и дробных чисел (копия)

К сожалению, во всех этих примерах есть фатальный недостаток, текст в них все-таки вставить можно, если воспользоваться стандартным контекстным меню или комбинацией клавиш CTRL+V. На уровне простого взаимодействия с формой и контролами это перехватить невозможно, придется несколько извернуться, т.к. для перехвата события «вставить», придется перехватить сообщение Windows WM_PASTE, которое отправляется окну, или элементу управления окна при выполнении операции вставки. Для Windows, тащемта, однохуйственно, кому отправлять сообщение, форме (окну) или, например, текстовому полю. Т.к. для Windows, и текстовое поле на самом деле окно, просто дочернее, т.е. размещенное в другом окне (форме, в нашем случае). Но это я залез глубоко в бок. Нам нужно добраться до сообщений. А это можно сделать только изнутри самого контрола, но не из событий стандартных контролов, так что будем писать свой!

Постановка задачи: необходимо создать свой контрол на основе текстового поля, который позволяет вводить только числа определенного типа — целые, целые отрицательные, отрицательные и положительные/отрицательные с дробной частью.

Начало

Подключим необходимые пространства имен:

using System.Windows.Forms;
using System.ComponentModel;
using System.Globalization;

Начнем делать свой контрол, наследуемый от TextBox. Т.е. создадим новый класс:

public class InputDigitControl:TextBox
{
	//тут будет код :)
}

Для начала необходимо определить сообщения, которые будем перехватывать. Заводим в классе константы, определяющие коды нужных сообщений:

const int WM_PASTE = 0x0302; //Сообщение "Вставка" (через к.м. и комбинацию клавиш)
const int WM_CHAR = 0x0102; //Сообщение - нажатие алфавитно-цифровой клавиши

WM_CHAR будет отправлено форме системой только тогда, когда будет нажата алфавитно-цифровая клавиша, его будем перехватывать для отслеживания цифр (и прочего). Правда есть важный нюанс — WM_CHAR посылается и при нажатии комбинаций клавиш, например Ctrl+C и т.д., а также некоторых клавиш, которые не совсем подходят под понятие «алфавитно-цифровая», например BACKSPACE. Это надо будет учесть.

Анализ ввода с клавиатуры будем производить в функции PreProcessMessage(), которую переопределим. PreProcessMessage() вызывается для предварительной обработки входящих сообщений, и нужно будет вернуть true, если это сообщение было обработано.

Т.е. алгоритм действий таков — мы проверяем входящий символ на соответствие, и, либо пропускаем его дальше (возвращаем base.PreProcessMessage(ref msg) или false), либо что-то делаем с содержимым текстового поля, если символ нужен (это понадобится при вводе отрицательных и дробей) и возвращаем true. Или ничего не делаем, если символ нежелательный, и просто вызываем true. В последнем случае, символ просто не попадет в поле ввода, т.к. контрол будет думать, что он уже обработан.

Вставку, как я уже говорил выше, тоже нужно будет обработать, но, естественно, несколько иначе, это будем делать в переопределенной функции WndProc()

Общее по перехвату ввода

В переопределенную функцию PreProcessMessage()добавляем следующий код:

if (msg.Msg == WM_CHAR) //перехватываем сообщение WM_CHAR
{

{

в if добавляем:

//была нажата комбинация клавиш
if ((ModifierKeys != Keys.None)&&
(ModifierKeys != Keys.Shift)) return false;

Control.ModifierKeys — внутреннее свойство класса Control, от которого наследуются элементы управления, оно позволяет определить, была ли нажата клавиша-модификатор (Ctrl, Alt или Shift). Для нашего случая (ввод цифр в TextBox), нужно пропустить стандартные комбинации клавиш для TextBox (CTRL+A, CTRL+C, CTRL+V, CTRL+X), чтобы не поломать работу TextBox‘а. С комбинаций с SHIFT для TextBox нет никаких комбинаций, кроме заглавных букв и знаков препинания.

В WParam сообщения Windows WM_CHAR содержится UTF код символа, т.е. 32-битное число, преобразуем его в char:

char chr = (char)msg.WParam.ToInt32();

И да, есть несколько служебных клавиш, воспринимаемых системой из-за древнего legacy (тянущегося еще с тех времен, когда мониторов не было, а вывод происходил на принтер) алфавитно-цифровыми, нам надо чтоб работала одна из них — BACKSPACE, добавляем:

if (chr == '\b') return false; //backspace

Клавиши управления курсором, HOME, END и DELETE алфавитно-цифровыми не считаются, так что будут работать и так, т.к. сообщение WM_CHAR не будет посылаться контролу.

Ввод только цифр

С вышеописанным это несложно сделать, в if (msg.Msg == WM_CHAR) добавляем следующий код ниже:

//это цифры (ура, товарищи)
if (chr >= '0' && chr <= '9')
{
    return false;
}
else
{
	return true;
}

Т.е теперь у нас работают системные клавиатурные комбинации для TextBox, BACKSPACE и ввод только цифр. Продолжаем.

Отрицательные числа

Добавим в класс свойство, позволяющее включить или отключить ввод отрицательных чисел.

[Description("Enable or disable negative number input"),
Category("Behavior"), DefaultValue(false)]
public bool Negative { get; set; } //включает/отключает ввод отрицательных чисел

С помощью классов из пространства имен System.ComponentModel можно добавить описание свойства, категорию, в которую будет помещено свойство, когда включен вид по категориям в Properties Window в редакторе, а также значение свойства по умолчанию.

После пересборки проекта, свойство появится в Properties Window

Ввод минуса, алгоритм:

1. Сохранить позицию курсора в текстовом поле.
2. Проверить, есть ли в начале строки знак ‘-‘
3.1. Если есть, его надо убрать, т.е. присвоить свойству this.Text значение this.Text.Substring(1), это все символы кроме первого.
3.2. Надо вернуть курсор на прежнее место, т.е. на 1 символ меньше, т.к. был удален 1 символ: this.SelectionStart = pos - 1
4.1. Если нет — надо добавить: this.Text = "-" + this.Text
4.2. И переставить курсор на 1 позицию вперед: this.SelectionStart = pos + 1.

Код:

//это цифры (ура, товарищи)
if (chr >= '0' && chr <= '9')
{
    return false;
}
else
{
    //получаем текущую позицию курсора для вставки точки/минуса
    int pos = this.SelectionStart;

    //нажали минус, ввод отрицательных разрешен свойством Negative
    if (chr == '-' && Negative)
    {                        
        if (this.Text.StartsWith("-")) //минус уже есть
        {
            this.Text = this.Text.Substring(1);//убираем
            //ставим курсор на прежнюю позицию. 
            //Т.е. на -1 от текущей, т.к. удалили 1 символ
            this.SelectionStart = pos - 1;
        }
        else //минуса нет
        {
            this.Text = "-" + this.Text; //добавили
            //переставили курсор
            this.SelectionStart = pos + 1;
        }
        
    } //конец ввод отрицательных            
    return true;
}

Действительные (дробные) числа.

Т.е. надо вставлять разделитель целой и дробной части.

Добавим свойство, включающее и выключающее этот режим, как было в случае отрицательных:

[Description("Enable or disable fractional number input"),
Category("Behavior"), DefaultValue(false)]
public bool Fractional { get; set; } //включает/отключает ввод дробных чисел

Также, я решил добавить свойство, позволяющее задать разделитель (точку или запятую), а остальные запретить, ибо нефиг. Чтоб так сделать, можно генерировать исключение прямо в свойстве:

private char separator = '.';
[Description("Decimal separator, may be '.' or ','"),
Category("Format"), DefaultValue('.')] //разделитель дробной и целой части числа
public char Separator 
{
    get { return separator; }
    set
    {
        if ((value != '.') && (value != ','))
        {
            throw new ArgumentOutOfRangeException("Separator",
                "Value must be '.' or ','");
        }
        else
        {
            separator = value;
        }
    }
}

Пересобираем проект, работает. Если попробовать ввести что-нибудь кроме точки или запятой в IDE — получим вот такое окно:

Переходим к вводу, алгоритм:

1. Поле ввода будет реагировать как на ввод точки, так и на ввод запятой в качестве разделителя (ибо заебало переключаться/вспоминать, что разделитель другой в зависимости от языка). Отображать поле будет разделитель, указанный в свойстве Separator.
2. Проверяем, нет ли в тексте разделителя, если есть, отменяем ввод, вернув true.
3. Если поле пустое, а введен разделитель, то добавим лидирующий 0 перед разделителем, а курсор переместим в конец строки.
3.1. Если нет, то в WParam запишем код разделителя из свойства контрола, вне зависимости от того, что было нажато, точка или запятая.
3.2. Проверим, не поставили ли разделитель в начале строки.
3.2.1 Если поставили, надо проверить, начинается ли текст с минуса и если да, отменить ввод — разделителя перед знаком «-» не бывает.
3.2.2 Если минуса в начале строки нет, значит добавляем в начало текста 0, разделитель, и перемещаем курсор на 2 символа от начала строки. Возвращаем true.
3.3. Проверим, не начинается ли строка с символа «-» и не введен ли разделитель, когда курсор стоит сразу после минуса.
3.3.1. Если да, вставляем в начало текста -0, разделитель, и изначальный текст кроме первого символа (первым символом был минус), устанавливаем курсор после разделителя, возвращаем true.

Код (после } //конец ввод отрицательных и перед return true;):

//ввод разделителя дробной части
//поле реагирует и на . и на ,
if ((chr == '.' || chr == ',') && Fractional)
{
    //проверяем, чтоб в строке не было двух разделителей
    if (this.Text.Contains(separator.ToString()))
    {
        return true;
    }

    //если поле пустое, добавляем 0 перед разделителем
    if (this.Text == string.Empty)
    {
        this.Text = "0" + separator.ToString();
        //ставим курсор в конец текста
        this.SelectionStart = this.Text.Length;
    }
    else
    {
        //меняем WParam на код разделителя
        msg.WParam = (IntPtr)separator;
        
        //проверяем, не поставили ли разделитель
        //в начале текста
        if (this.SelectionStart == 0)
        {
            //если поставили и текст начинается с -
            //игнорируем нажатие, перед "-" 
            //разделителя не бывает
            if (this.Text.StartsWith("-")) return true;
            
            //добавляем лидирующий 0
            this.Text = "0" + separator.ToString() 
                + this.Text;
            this.SelectionStart = 2;
            return true;
        }
        
        //если курсор стоит после "-"
        if ((this.SelectionStart == 1) &&
            this.Text.StartsWith("-"))
        {
            //добавляем "-0," или "-0." к началу текста

            this.Text = "-0" + separator.ToString() +
                this.Text.Substring(1);
            this.SelectionStart = 3;

            return true;
        }
        
        return false;
    }
}

Вставка

Для начала надо переопределить WndProc, общую функцию для любого контрола, куда попадает большинство оконных сообщений:

protected override void WndProc(ref Message m)
{
   //тут будет код
    base.WndProc(ref m);
}

В WndProc перехватываем сообщение WM_PASTE для которого мы выше заготовили константу.

protected override void WndProc(ref Message m)
{
    if (m.Msg == WM_PASTE) //перехватываем сообщение "вставка"
    {
        //тут будет код
    }

    base.WndProc(ref m);
}

Далее, необходимо перехватить данные из буфера обмена, а потом проверять их в зависимости от того, какие данные может принимать наш контрол.

//получаем строку из буфера обмена
IDataObject obj = Clipboard.GetDataObject();
string input = (string)obj.GetData(typeof(string));
//надо будет в дальнейшем
ulong tmpulong = 0;
long tmplong = 0;

Целые числа без знака

Тут все просто, попытаемся сконвертировать содержимое буфера обмена в максимально возможный беззнаковый тип (UInt64, он же ulong) — получилось, разрешаем вставку, не получилось, пишем в Result сообщения (IntPtr)0, тем самым отменяя вставку, и выходим из функции.

if ((!Fractional) && (!Negative)) //только цифры
{
    //пытаемся конвертировать в беззнаковый long
    if (!ulong.TryParse(input,out tmpulong))
    {
        //не получилось
        m.Result = (IntPtr)0; //отменяем вставку
        return;
    }
}

Целые числа со знаком

Тоже просто, действуем по вышеуказанному алгоритму, только конвертируем не в беззнаковый, а знаковый тип (Int64, он же long):

//отрицательные и положительные целые
if ((!Fractional) && (Negative))
{
    //пытаемся конвертировать в знаковый long
    if (!long.TryParse(input,out tmplong))
    {
        //не получилось
        m.Result = (IntPtr)0; //отменяем вставку
        return;
    }
}

Дробные числа

Тут немного сложнее, для начала придется написать функцию для их конверсии, которая учитывает разделитель точку и разделитель запятую:

1. Меняем разделитель на какой-нибудь один (пусть тот, который задан в свойстве Separator контрола):

st = st.Replace('.', separator);
st = st.Replace(',', separator);

2. Создаем формат для функции конвертации, как это описано здесь (копия):

NumberFormatInfo format = new NumberFormatInfo();
format.NumberDecimalSeparator = separator.ToString();

3. Пытаемся сконвертировать, получилось — возвращаем true, нет — false:

try
{
    double d = Convert.ToDouble(st, format);
    return true;
}
catch
{
    return false;
}

Целиком

Теперь можно приступить к анализу буфера обмена:
1. Если дробные числа разрешены, пытаемся конвертировать в Double, не получилось — отменяем вставку:

//пытаемся конвертировать в double
if (!IsDouble(input))
{
//не получилось
m.Result = (IntPtr)0; //отменяем вставку
return;
}

2. Заменяем разделитель на тот, который установлен в свойстве Separator контрола:

//заменяем разделитель на установленный в контроле
input = input.Replace('.', separator);
input = input.Replace(',', separator);

3. Добавляем лидирующий 0 для положительных чисел, т.е. если строка начинается с разделителя, заменяем разделитель на 0 + разделитель (например, на 0,)

4. То же проделываем для отрицательных, т.е. заменяем - + разделитель (например -,) на -0:

//добавляем лидирующий 0 если надо
if (input.StartsWith(separator.ToString()))
{
    input = input.Replace(separator.ToString(),
        "0" + separator.ToString());
}
if (input.StartsWith("-" + separator.ToString()))
{
    input = input.Replace("-" + separator.ToString(),
        "-0" + separator.ToString());
}

Дробные не отрицательные.

Используя предыдущий алгоритм, просто добавляем проверку, чтоб строка в буфере не начиналась с минуса:

//дробные не отрицательные
if (!Negative)
{
    if (input.StartsWith("-"))
    {
        m.Result = (IntPtr)0; //отменяем вставку
        return;
    }
}

В конце вставки дробных чисел меняем содержимое буфера обмена:

//меняем содержимое буфера обмена
Clipboard.SetText(input);

Конец функции вставки

В конце функции вставки разрешаем вставлять числа только целиком, иначе это усложнит код (может допилю в будущих версиях), просто удаляя содержимое текстового поля, а вставку за нас система сделает:

//вставка чисел целиком
this.Text = string.Empty;

Вся функция целиком

Исходники

Контрол

Тестовое приложение

Репозиторий

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *