.NET / Attached свойства для ограничения текстового ввода

в 8:30, , рубрики: attached properties, wpf, метки: ,

WPF – это уже далеко не новая технология на рынке, но относительно новая для меня. И, как это часто бывает при изучении чего-то нового, появляется желание/необходимость в изобретении велосипедов с квадратными колесами и литыми дисками для решения некоторых типовых задач.
Одной из таких задач является ограничение ввода пользователем определенных данных. Например, мы хотим, чтобы в некоторое текстовое поле можно было вводить только целочисленные значения, а в другое – дату в определенном формате, а в третье – только числа с плавающей запятой. Конечно, окончательная валидация подобных значений все равно будет происходить во вью-моделях, но подобные ограничения на ввод делают пользовательский интерфейс более дружественным.
В Windows Forms эта задача решалась довольно легко, а когда в распоряжении был тот же TextBox от DevExpress со встроенной возможностью ограничения ввода с помощью регулярных выражений, то все было вообще просто. Примеров решения этой же задачи в WPF довольно много, большинство из которых сводится к одному из двух вариантов: использование наследника класса TextBox или добавление attached property с нужными ограничениями.ПРИМЕЧАНИЕЕсли вам не очень интересны мои рассуждения, а нужны сразу же примеры кода, то вы можете либо скачать весь проект WpfEx с GitHub, либо же скачать основную реализацию, которая содержится в TextBoxBehavior.cs и TextBoxDoubleValidator.cs.
Ну что, приступим?

Поскольку наследование вводит довольно жесткое ограничение, то лично мне в этом случае больше нравится использование attached свойств, благо этот механизм позволяет ограничить применение этих свойств к элементам управления определенного типа (я не хочу, чтобы это attached свойство IsDouble можно было применить к TextBlock-у для которого это не имеет смысла).
Кроме того нужно учесть, что  при ограничении ввода пользователя нельзя использовать какие-то конкретные разделители целой и дробной части (такие как ‘.’ (точка) или ‘,’ (запятая)), а также знаки ‘+’ и ‘-‘, поскольку все это зависит от региональных настроек пользователя.
Чтобы реализовать возможность ограничения ввода данных, нам нужно перехватить событие ввода данных пользователем внучную, проанализировать его и отменить эти изменения, если они нам не подходят. В отличие от Windows Forms, в котором принято использование пары событий XXXChanged и XXXChanging, в WPF для этих же целей используются Preview версии событий, которые могут быть обработаны таким образом, чтобы основное событие не срабатывало.  (Классическим примером может служить обработка событий от мыши или клавиатуры, запрещающие некоторые клавиши или их комбинации).
И все было бы хорошо, если бы класс TextBox вместе с событием TextChanged содержал бы еще и PreviewTextChanged, которое можно было бы обработать и «прервать» ввод данных пользователем, если мы считаем вводимый текст некорректным. А поскольку его нет, то и приходится всем и каждому свой лисапет изобретать.
Решение задачи

Решение задачи сводится к созданию класса TextBoxBehavior, содержащего attached свойство IsDoubleProperty, после установки которого пользователь не сможет вводить в данное текстовое поле ничего кроме символов +, -,. (разделителя целой и дробной части), а также цифр (не забываем, что нам нужно использовать настройки текущего потока, а не захардкодженные значения).
public class TextBoxBehavior
{
// Attached свойство булевого типа, установка которого приведет к
// ограничению вводимых пользователем данных
public static readonly DependencyProperty IsDoubleProperty =
DependencyProperty.RegisterAttached(
"IsDouble", typeof (bool),
typeof (TextBoxBehavior),
new FrameworkPropertyMetadata(false, OnIsDoubleChanged));

// Данный атрибут не позволит использовать IsDouble ни с какими другими
// UI элементами, кроме TextBox или его наследников
[AttachedPropertyBrowsableForType(typeof (TextBox))]
public static bool GetIsDouble(DependencyObject element)
{}

public static void SetIsDouble(DependencyObject element, bool value)
{}

// Вызовется, при установке TextBoxBehavior.IsDouble="True" в XAML-е

private static void OnIsDoubleChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
// Уличная магия
}
}

Основная сложность реализации обработчика PreviewTextInput (как и события вставки текста из буфера обмена) заключается в том, что в аргументах события передается не суммарное значение текста, а лишь вновь введенная его часть. Поэтому суммарный текст нужно формировать вручную, учитывая при этом возможность выделения текста в TextBox-е, текущее положение курсора в нем и, возможно, состояние кнопки Insert (которое мы анализировать не будем):
// Вызовется, при установке TextBoxBehavior.IsDouble="True" в XAML-е
private static void OnIsDoubleChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
// Поскольку мы ограничили наше attached свойство только классом
// TextBox или его наследниками, то следующее преобразование - безопасно
var textBox = (TextBox) d;

// Теперь нам нужно обработать два важных слчая:
// 1. Ручной ввод данных пользователем
// 2. Вставка данных из буфера обмена
textBox.PreviewTextInput += PreviewTextInputForDouble;
DataObject.AddPastingHandler(textBox, OnPasteForDouble);
}

Класс TextBoxDoubleValidator

Вторым важным моментом является реализация логики валидации вновь введенного текста, ответственность за которую отведена методу IsValid отдельного класса TextBoxDoubleValidator.
Самым простым способом понять, как должен вести себя метод IsValid этого класса, это написать для него юнит-тест, который покроет все corner case-ы (это как раз один из тех случаев, когда параметризованные юнит-тесты рулят со страшной силой):ПРИМЕЧАНИЕ
Это как раз тот случай, когда юнит тест – это не просто тест, проверяющий корректность реализации определенной функциональности. Это как раз тот случай, о котором неоднократно говорил Кент Бек, описывая accountability; прочитав этот тест можно понять, о чем думал разработчик метода валидации, «повторно использовать» его знания и найти ошибки в его рассуждениях и, тем самым, вероятно и в коде реализации. Это не просто набор тестов – это важная часть спецификации этого метода!
private static void PreviewTextInputForDouble(object sender,
TextCompositionEventArgs e)
{
// e.Text содержит только новый текст, так что без текущего
// состояния TextBox-а не обойтись

var textBox = (TextBox)sender;
string fullText;

// Если TextBox содержит выделенный текст, то заменяем его на e.Text
if (textBox.SelectionLength > 0)
{
fullText = textBox.Text.Replace(textBox.SelectedText, e.Text);
}
else
{
// Иначе нам нужно вставить новый текст в позицию курсора
fullText = textBox.Text.Insert(textBox.CaretIndex, e.Text);
}

// Теперь валидируем полученный текст
bool isTextValid = TextBoxDoubleValidator.IsValid(fullText);

// И предотвращаем событие TextChanged если текст невалиден
e.Handled = !isTextValid;
}

Тестовый метод возвращает true, если параметр text является валидным, и это означает, что соответствующий текст можно будет вбить в TextBox с attached свойством IsDouble. Обратите внимание на несколько моментов: (1) использование атрибута SetCulture, который устанавливает нужную локаль и (2) на некоторые входные значения, такие значения как “-.”, которые не являются корректными значениями для типа Double.
Явная установка локали нужна для того, чтобы тесты не падали у разработчиков с другими персональными настройками, ведь в русской локали, в качестве разделителя используется символ ‘,’ (запятая), а в американской – ‘.’ (точка). Такой странный текст, как “-.” является корректным, поскольку мы должны пользователю завершить ввод, если он хочет ввести строку “-.1”, которая является корректным значением для Double. (Интересно, что на StackOverflow для решения этой задачи очень часто советуют просто использовать Double.TryParse, который явно не будет работать в некоторых случаях).ПРИМЕЧАНИЕ
Я не хочу захламлять статью деталями реализации метода IsValid, хочу лишь отметить использование в теле этого метода ThreadLocal, который позволяет получить и закэшировать DoubleSeparator локальный для каждого потока. Полную реализацию метода TextBoxDoubleValidator.IsValid можно найти здесь, более подробную информацию об ThreadLocal можно почитать у Джо Албахари в статье Работа с потоками. Часть 3.
Альтернативные решения

Помимо перехвата событий PreviewTextInput и вставки текста из буфера обмена существуют и другие решения. Так, например, я встречал попытку решить эту же задачу путем перехвата события PreviewKeyDown с фильтрацией всех клавиш кроме цифровых. Однако это решение сложнее, поскольку все равно придется заморачиваться с «суммарным» состоянием TextBox-а, да и чисто теоретически, разделителем целой и дробной части может быть не один символ, а целая строка (NumberFormatInfo.NumberDecimalSeparator возвращает string, а не char).
Есть еще вариант в событии KeyDown сохранить предыдущее состояние TextBox.Text, а в событии TextChanged вернуть ему старое значение, если новое значение не устраивает. Но это решение выглядит неестественным, да и реализовать его с помощью attached свойств будет не так-то просто.
Заключение

Когда мы в последний раз обсуждали с коллегами отсутствие в WPF полезных и весьма типовых возможностей, то мы пришли к выводу, что этому есть объяснение, и в этом есть и положительная сторона. Объяснение сводится к тому, что от закона дырявых абстракций никуда не деться и WPF будучи «абстракцией» весьма сложной, течет как решето. Полезная же сторона заключается в том, что отсутствие некоторых полезных возможностей заставляет нас иногда думать (!) и не забывать о том, что мы программисты, а не умельцы копи-пасты.
Напомню, что полную реализацию классов приведенных классов, примеры их использования и юнит-тесты можно найти на github.


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js