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)
{
// Уличная магия
} }
Следующим, и основным этапом является реализация той «основной магии», о которой говорится в комментарии в методе OnIsDoubleChanged.
Основная сложность реализации этого метода состоит в следующем: во-первых, нам нужно понять, что является валидными данными, а что нужно отсекать, и во-вторых, нам нужно каким-то образом получить «вводимый» пользователем текст, поскольку готового события, типа PreviewTextChanged в нашем распоряжении нет.
Формирование измененной строки
Для получения строки, вводимой пользователем в методе OnIsDoubleChanged нужно подписаться на два события: (1) PreviewTextInput и (2) перехватить событие «вставки» текста из буфера обмена:
// Вызовется, при установке 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); }
Основная сложность реализации обработчика PreviewTextInput (как и события вставки текста из буфера обмена) заключается в том, что в аргументах события передается не суммарное значение текста, а лишь вновь введенная его часть. Поэтому суммарный текст нужно формировать вручную, учитывая при этом возможность выделения текста в TextBox-е, текущее положение курсора в нем и, возможно, состояние кнопки Insert (которое мы анализировать не будем):
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; }
Класс TextBoxDoubleValidator
Вторым важным моментом является реализация логики валидации вновь введенного текста, ответственность за которую отведена методу IsValid отдельного класса TextBoxDoubleValidator.
Самым простым способом понять, как должен вести себя метод IsValid этого класса, это написать для него юнит-тест, который покроет все corner case-ы (это как раз один из тех случаев, когда параметризованные юнит-тесты рулят со страшной силой):
ПРИМЕЧАНИЕ
Это как раз тот случай, когда юнит тест – это не просто тест, проверяющий корректность реализации определенной функциональности. Это как раз тот случай, о котором неоднократно говорил Кент Бек, описывая accountability; прочитав этот тест можно понять, о чем думал разработчик метода валидации, «повторно использовать» его знания и найти ошибки в его рассуждениях и, тем самым, вероятно и в коде реализации. Это не просто набор тестов – это важная часть спецификации этого метода!
[TestCase("", Result = true)]
[TestCase(".", Result = true)]
[TestCase("-.", Result = true)]
[TestCase("-.1", Result = true)]
[TestCase("+", Result = true)]
[TestCase("-", Result = true)]
[TestCase(".0", Result = true)]
[TestCase("1.0", Result = true)]
[TestCase("+1.0", Result = true)]
[TestCase("-1.0", Result = true)]
[TestCase("001.0", Result = true)]
[TestCase(" ", Result = false)]
[TestCase("..", Result = false)]
[TestCase("..1", Result = false)]
[TestCase("1+0", Result = false)]
[TestCase("1.a", Result = false)]
[TestCase("1..1", Result = false)]
[TestCase("a11", Result = false)]
[SetCulture("en-US")]
public bool TestIsTextValid(string text)
{
bool isValid = TextBoxDoubleValidator.IsValid(text);
Console.WriteLine("'{0}' is {1}", text, isValid ? "valid" : "not valid");
return isValid;
}
Тестовый метод возвращает true, если параметр text является валидным, и это означает, что соответствующий текст можно будет вбить в TextBox с attached свойством IsDouble. Обратите внимание на несколько моментов: (1) использование атрибута SetCulture, который устанавливает нужную локаль и (2) на некоторые входные значения, такие значения как “-.”, которые не являются корректными значениями для типа Double.
Явная установка локали нужна для того, чтобы тесты не падали у разработчиков с другими персональными настройками, ведь в русской локали, в качестве разделителя используется символ ‘,’ (запятая), а в американской – ‘.’ (точка). Такой странный текст, как “-.” является корректным, поскольку мы должны пользователю завершить ввод, если он хочет ввести строку “-.1”, которая является корректным значением для Double. (Интересно, что на StackOverflow для решения этой задачи очень часто советуют просто использовать Double.TryParse, который явно не будет работать в некоторых случаях).
ПРИМЕЧАНИЕ
Я не хочу захламлять статью деталями реализации метода IsValid, хочу лишь отметить использование в теле этого метода ThreadLocal<T>, который позволяет получить и закэшировать DoubleSeparator локальный для каждого потока. Полную реализацию метода TextBoxDoubleValidator.IsValid можно найти здесь, более подробную информацию об ThreadLocal<T> можно почитать у Джо Албахари в статье Работа с потоками. Часть 3.
Альтернативные решения
Помимо перехвата событий PreviewTextInput и вставки текста из буфера обмена существуют и другие решения. Так, например, я встречал попытку решить эту же задачу путем перехвата события PreviewKeyDown с фильтрацией всех клавиш кроме цифровых. Однако это решение сложнее, поскольку все равно придется заморачиваться с «суммарным» состоянием TextBox-а, да и чисто теоретически, разделителем целой и дробной части может быть не один символ, а целая строка (NumberFormatInfo.NumberDecimalSeparator возвращает string, а не char).
Есть еще вариант в событии KeyDown сохранить предыдущее состояние TextBox.Text, а в событии TextChanged вернуть ему старое значение, если новое значение не устраивает. Но это решение выглядит неестественным, да и реализовать его с помощью attached свойств будет не так-то просто.
Заключение
Когда мы в последний раз обсуждали с коллегами отсутствие в WPF полезных и весьма типовых возможностей, то мы пришли к выводу, что этому есть объяснение, и в этом есть и положительная сторона. Объяснение сводится к тому, что от закона дырявых абстракций никуда не деться и WPF будучи «абстракцией» весьма сложной, течет как решето. Полезная же сторона заключается в том, что отсутствие некоторых полезных возможностей заставляет нас иногда думать (!) и не забывать о том, что мы программисты, а не умельцы копи-пасты.
Напомню, что полную реализацию классов приведенных классов, примеры их использования и юнит-тесты можно найти на github.
Отличная статья.
ОтветитьУдалитьПравда для проверки ввода целочисленных значений тоже нужно написать Validator. Одной проверки в Int32.TryParse недостаточно, так как эта проверка не позволяет ввести отрицательное число.
Со вставкой из буфера тоже не все так гладко. Проверяется только текст, находящийся в буфере обмена. о ничто не мешает мне вставить несколько раз текст вида "-123". В Итоге можно в текстбоксе получить "-123-456-789".
Хотя как я понял главный упор был сделан именно на проверку значений с плавающей точкой. Там подобных багов не наблюдается.
@Victor: да, спасибо. Конечно же реалиация всех методов валидации должна быть аналогчна и анализировать только "новый" текст недостаточно.
ОтветитьУдалитьИ, да, упор был сделан именно на IsDouble, поэтому реализацию IsInteger нужно к ней будет подтянуть (ну и добавить бросание исключение, если пользователь установил и IsDouble и IsInteger).
в OnIsDoubleChanged нужно в зависимости от того какое NewValue нужно подписываться или отписываться.
ОтветитьУдалить@Раиль: На самом деле, не совсем так.
ОтветитьУдалитьПоскольку дефолтное значение этого аттачт проперти равно False, то событие OnIsDoublePropertyChanged сработает только в том случае, когда в xaml-е мы установим это свойство в True.
Так что мы точно знаем, что если событие сработало, то пользователь "включает" эту возможность.
Сергей, на самом деле не всё так очевидно. К примеру, если значение свойства IsDouble установлено через биндинг и изменяется несколько раз - то могут накапливаться лишние подписки на события.
ОтветитьУдалитьВсе это конечно круто, но без особых заморочек можно добиться подобного эффекта всего-лишь несколькими строками кода:
ОтветитьУдалить...
private string oldValue = "0,0";
...
private void textBox1_TextChanged(object sender, TextChangedEventArgs e)
{
// С помощью регулярок делаем какую угодно проверку
if (Regex.IsMatch(textBox1.Text.Trim(), @"^\d+[\.,]?\d*$"))
oldValue = textBox1.Text;
else
textBox1.Text = oldValue;
}
Эээээ. Behind code - это не всегда удобно, а иногда и вообще невозможно. Да и для каждого из десятка текст боксов держать поле - не лучший вариант.
ОтветитьУдалитьЗадача сильно притянута за уши. То есть, с самими attached property/attached behavior все ок, но валидировать ввод таким образом - почти всегда не лучшая идея (в качестве исключения, когда это приемлемо, я могу сходу придумать разве что ввод телефонного номера в текстовое поле с маской). Как пользователю, мне было бы гораздо удобнее, чтобы мне не ограничивали ввод, а показывали, когда он не валиден и почему. Иногда бывает удобно вообще сделать через пробел два разных значения, и подумать, какое надо в результате. Не давать вводить такое в принципе - грусть и печаль с точки зрения удобства использования. Как это сделать в WPF - вопрос действительно не совсем очевидный, но тоже реально без всяких code behind и не ломая чистенький MVVM-подход. Если интересно - могу продемонстрировать, но не в комментариях, там в две строчки не уложишься :(
ОтветитьУдалить@Ivan: я бы с вами согласился по поводу притянуттости за уши задачи, если бы этот код не использвался в реальном проекте;)
ОтветитьУдалитьПри этом я искренне считаю, что если мне нужно в текст бокс вводить только проценты, то я буду рад, чтобы на уровне ввода я не мог вводить ничего другого. При этом не вижу проблемы в двухфазной валидации: мы запрещаем вводить откровенный бред в поля, в которых не числовые данные не валидны в принципе и во второй фазе мы проверяем логические ошибки выхода за границы диапазона.
Я же не предлагаю найти общее решение общей задачи;) Но в частном случае этот подход ИМХО более удобен.
> Если интересно - могу продемонстрировать, но не в комментариях, там в две строчки не уложишься :(
Готов посмотреть, просто я так и не понял, чем этот вариант не устраивает;) если все таки мы решим решить эту задачу:)
Чего только на реальных проектах не используется... :))
ОтветитьУдалитьВот возьмем тот же буфер. Пользователь не догадывается об особенности вашего текст-бокса, откуда-то выцепил число и скопировал в буфер. Выделял мышкой, прицеливаться было лень - с числом попал пробел. Или скобка (а то пробелы можно и потримить перед парсом). Он пытается вставить - а текстовое поле не реагирует. Что пользователь подумает про автора?)) Ничего хорошего, гарантирую.
Это только один сценарий. Их в реальной жизни больше. Лично у меня первая мысль при виде подобных ограничений сродни "автор пытается быть слишком умным и думать за ползователя, где это не нужно" - естественно, тоже имхо :)
Ну и вообще, обнаружение подобного жесткого ограничения - для меня сюрприз из разряда неприятных.
Кроме того, я не вижу по большому счету никаких недостатков в том, чтобы при некорректном вводе просто обвести поле рамочкой и показать в тултипе или рядом сообщение о том, что именно не так.
@Ivan: только что проконсультировался с двумя UI-щиками. Оба сказали, что такой подход вполне ОК и они его использовали в своих проектах;) И я пока не увидел убедительных недостатков.
ОтветитьУдалитьВ случае буфера мы в текстовом поле получим числовое значение из буфера обмена и все:) Значение в нем сразу же будет корректным; проблемы нет. Какие еще проблемы есть в реальной жизни?
И я пока не помню ни одного случая жалобы наших пользователей, когда использовалось бы это ограничение. Так что в данном случае что-то не так не только с авторами приложения, но и с пользователями;)
Недостаток с вводом откровенно невалидных данных в том, что мы откладываем обработку ошибок на более поздний срок, хотя уже заведомо знаем, что они неверные.
Ну пусть это будет my personal preference. Для юзеров это не настолько критично, чтобы жаловались. Особенно учитывая бюрократические сложности сообщения о неудобствах :)
ОтветитьУдалитьС точки зрения разработчиков... предлагаю для начала сформулировать проблему более четко.
Пусть у нас есть view model с одним int полем, на которое висит binding со стороны view. Плюс, есть кнопка OK, на которую прицеплена (опять же через binding) команда из VM. Code behind использовать нельзя. Нужно разрешать нажимать на кнопку только если в TextBox валидное значение (во-первых, парсабельное в int, во-вторых, пусть будет между -100 и 100).
Один из вариантов решения - описан в данной статье. Не вопрос, работает. Какие еще решения? Ну, просто чтобы было, из чего выбирать :)
Я клоню к тому, что в данном случае велика вероятность того, что использовали данный вариант не потому что это лучший вариант для пользователей, а потому что в существующей WPF-инфраструктуре это самый просто-реализуемый вариант и над чем-то поинтереснее никто не задумывался.
> В случае буфера мы в текстовом поле получим числовое значение из буфера обмена и все:) Значение в нем сразу же будет корректным; проблемы нет. Какие еще проблемы есть в реальной жизни?
ОтветитьУдалитьИли я чего-то не понимаю, или таки не получим. Юзер случайно скопировал вместо "123" в буфер "123)". Мы распарсим только пока не встретим невалидный символ ")"? Если да - то что делать с вариантами "(123" или "123 456"? Последний более чем реален, кстати, - разделитель разрядов часто именно пробел.
> Недостаток с вводом откровенно невалидных данных в том, что мы откладываем обработку ошибок на более поздний срок, хотя уже заведомо знаем, что они неверные.
Почему откладываем? Мы сразу показываем пользователю индикацию, что эти данные некорректны. Но не пытаемся за него решить, что ему можно вводить, а что нельзя. В конце концов, если он сделал что-то не так - Ctrl+Z никто не отменял.
>> Я клоню к тому, что в данном случае велика вероятность того, что использовали данный вариант не потому что это лучший вариант для пользователей, а потому что в существующей WPF-инфраструктуре это самый просто-реализуемый вариант и над чем-то поинтереснее никто не задумывался.
ОтветитьУдалитьПредложенный Вами вариант проще в реализации;) Так что я не пойму откуда вы взяли, что над "поинтереснее" вариантами никто не задумывался;)
Логика конвертации может быть такая:
123) -> 123
(123 -> 123
123 123 -> 123123
>> Почему отклаываем?
Да, такой вариант тоже вполне подходит и это таки personal preferences. Я бы, например, не убеждал бы Вас, что Ваш вариант хуже моего, поэтому едва ли в этом случае Вам удастся аргументированно убедить, что мой вариант хуже;)
Я полностью согласен, что предложенный мною вариант плох для сложных ограничений. Но вот поле age зарестриктить таким образом - вполне.
> Предложенный Вами вариант проще в реализации;)
ОтветитьУдалитьВот это уже интересно. Просветите? :)
> Вот это уже интересно. Просветите? :)
ОтветитьУдалитьЯ про Ваш вариант с подсветкой ошибки в случае неверно вбитых данных.
Если посмотреть граничные условия вбивания double и валидации его на лету, то этот вариант сложнее простого вызова TryParse во вью модели и в уведомлении пользователя о некорректном вводе.
Чтобы сделать в VM вызов TryParse - нужно чтобы свойство VM имело тип string, иначе инвалидное значение в него просто не попадет. Кроме того, такой подход нельзя повторно использовать и от копипаста он ушел не особенно далеко.
ОтветитьУдалитьКороче, я такого не предлагал :)
Ну а куча тестов и необходимость особого AI для "угадывания" как парсить "(123", "123)" и "123 456" - это, скорее, свидетельство излишней сложности ограничения ввода :Ь
Плюс, для каждого типа данных этот AI приходится придумывать заново, т.к. вменяемые последовательности символов для double, int, IP-адреса, даты или еще какого-то хитрого типа - существенно отличаются.
В моем варианте немного сложнее инфраструктурная часть, которая пишется и тщательно тестируется один раз, зато потом просто расширяется малыми усилиями. Сейчас выложу, чтобы не быть голословным и не рассказывать о каких-то гипотетических молочных берегах без собственно кода :)
ЗЫ. string в VM вместо конкретных типов убивает читабельность и поддерживаемость в ноль, если на VM таких полей хотя бы штук 5.
ОтветитьУдалитьhttp://sparethought.wordpress.com/2013/05/21/wpf-ui-validation-technique/
ОтветитьУдалить@Ivan: спасибо, отличное альтернативное решение!
ОтветитьУдалитьЯ не вполне понял суть проблемы. Если нужно ограничить ввод, почему нельзя для этой цели просто отфильтровывать ввод в соответствующем поле ViewModel?
ОтветитьУдалитьНапример:
private string m_Percent;
private readonly Regex _ValueRegex = new Regex("^([-.0-9]|[-]?[.]|[-]?\\d+|[-]?\\d+[.]||[-]?\\d+[.]\\d+)$");
// Property bound to a TextBox.Text
public string Percent
{
get { return m_Percent; }
set
{
if (m_Percent == value || !IsInputValid(value))
{
return;
}
m_Percent = value;
OnPropertyChanged();
}
}
private bool IsInputValid(string value)
{
return _ValueRegex.IsMatch(value);
}
Обновление модели может происходить в том же сеттере:
ОтветитьУдалитьpublic string Percent
{
get { return m_Percent; }
set
{
if (m_Percent == value || !IsInputValid(value))
{
return;
}
m_Percent = value;
UpdateModel(double.Parse(m_Percent));
OnPropertyChanged();
}
}
А биндинг может выглядеть как:
<TextBox Text="{Binding Percent, ValidatesOnDataErrors=True, UpdateSourceTrigger=PropertyChanged}" ... />
Не вижу ничего неестественного в использовании текстовых полей во ViewModel для ввода данных, представимых ввиде текста.
ОтветитьУдалитьНасчет поддерживаемости и расширяемости, ответственность за проверку определенных типов данных может быть поручена внешнему классу.
Например:
if (m_Percent == value || !InputFilter.IsPartOfDouble(value))
{
return;
}
m_Percent = value;
OnPropertyChanged();
if (InputValidator.IsValidPercent(m_Percent)
{
m_Model.Percent = double.Parse(m_Percent);
}
else
{
SetPropertyError(Strings.InvalidPercentMessage);
}
Да все с ним отлично. Кроме того, что легко ошибиться, забыть, это дублированный код, который появляется десятками в каждом нетривиальном VM. Плюс, полноценной model обновляемой сразу же очень часто просто нет (например, сохранять нужно только по кнопке OK, а до тех пор держать в памяти - вдруг юзер Cancel нажмет) - и вы получаете еще одну пачку переменных. То есть на каждое значение в контроле у вас будет одно поле корректного типа, одно поле текстовое, проперти с нетривиальным сеттером из десятка строк. В целом, это большое "место для удара головой".
ОтветитьУдалить@Ivan: Настройка поведения полей требует не меньшего внимания, однако код VM тестируется проще и ошибки выявляются легче чем ошибки в разметке xaml (какое поле - как себя ведет). Основная идея MVVM - в переносе всей логики представления в модель представления. Создание контролов, обладающих поведением, отличным от исключительно визуальных эффектов, на мой взгляд, - попытка применить MVC к WPF, что IMHO не вполне корректно.
ОтветитьУдалитьКак спроектировать корректную VM без излишнего дублирования кода - тема, заслуживающая отдельного топика.
@Pavel: на мой взгляд, гораздо важнее вопрос, где ошибку проще допустить, чем где ее проще протестировать в данном случае. Ни разу не встречал, чтобы в сколько-нибудь крупном проекте писались тесты для каждой проперти каждой VM. Поэтому это больше теоретическое преимущество. Плюс, вам в любом случае нужно тестировать корректную установку Binding path и прочих свойств. К этому в случае UI Validation добавляется очень мало, поэтому мало шансов ошибиться. В VM же такого места как раз будет много. И по опыту - размножаться такие проперти будут копипастами, что будет приводить к вполне реальным ошибкам.
ОтветитьУдалить