ViewModel Implementation — Від BaseViewModel до валідації
ViewModel Implementation: Від BaseViewModel до валідації
Вступ
У попередній статті ми розібрали MVVM Pattern як архітектурний патерн — три компоненти, золоті правила, потік даних. Але залишилося питання: як саме реалізувати ViewModel?
Уявіть ситуацію: ви створюєте додаток з 10 екранами. Кожен екран має свій ViewModel. Кожен ViewModel потребує:
- Реалізації
INotifyPropertyChanged - Методу
OnPropertyChanged(string propertyName) - Десятків властивостей з однаковим boilerplate-кодом:
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
OnPropertyChanged(nameof(FirstName));
}
}
Проблеми:
- ❌ Дублювання коду — той самий патерн для кожної властивості
- ❌ Помилки у назвах —
OnPropertyChanged("FirsName")(опечатка) → UI не оновлюється - ❌ Відсутність валідації — як показати помилки у View?
- ❌ Складність тестування — як перевірити, що PropertyChanged викликається?
Рішення: Створити BaseViewModel — базовий клас з усією інфраструктурою, який успадковують всі ViewModel.
Проблема boilerplate: Чому потрібен BaseViewModel
Розберемо детально, чому ручна реалізація INotifyPropertyChanged для кожного ViewModel — це антипатерн.
Дублювання коду у кожному ViewModel
Проблема: Той самий код у 10 ViewModel.
// MainViewModel.cs
public class MainViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private string _title;
public string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged(nameof(Title));
}
}
}
// SettingsViewModel.cs
public class SettingsViewModel : INotifyPropertyChanged
{
// ❌ Копіювання того самого коду
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private string _theme;
public string Theme
{
get => _theme;
set
{
_theme = value;
OnPropertyChanged(nameof(Theme));
}
}
}
// LoginViewModel.cs
public class LoginViewModel : INotifyPropertyChanged
{
// ❌ Знову те саме
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
// ... властивості
}
Що не так?
- Той самий код у 10 місцях
- Зміна логіки → потрібно змінити у 10 місцях
- Легко забути оновити один з ViewModel
Помилки у назвах властивостей
Проблема: Рядкові літерали — джерело помилок.
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
// ❌ Опечатка — UI не оновиться
OnPropertyChanged("FirsName");
}
}
Наслідки:
- UI не оновлюється при зміні властивості
- Помилка виявляється тільки у runtime
- Складно знайти причину — компілятор не попереджає
Спроба виправлення через nameof:
OnPropertyChanged(nameof(FirstName)); // ✅ Compile-time перевірка
Але це все одно багато boilerplate-коду для кожної властивості.
Відсутність централізованої логіки
Проблема: Як додати логування змін властивостей?
// Потрібно змінити у кожному ViewModel
protected void OnPropertyChanged(string propertyName)
{
// Додаємо логування
Console.WriteLine($"Property {propertyName} changed");
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
Якщо у вас 10 ViewModel — потрібно змінити 10 файлів. Якщо забули один — логування працює неповністю.
Інші приклади централізованої логіки:
- Відстеження змін для Undo/Redo
- Автоматичне збереження при зміні
- Аналітика (tracking змін користувача)
- Debugging (виведення стеку викликів)
Без базового класу — неможливо додати цю логіку централізовано.
BaseViewModel: Базовий клас для всіх ViewModel
Рішення всіх проблем — створити BaseViewModel — абстрактний базовий клас, який реалізує INotifyPropertyChanged та надає інфраструктуру для всіх ViewModel.
Мінімальна реалізація BaseViewModel
Мета: Винести спільний код у один клас.
using System.ComponentModel;
using System.Runtime.CompilerServices;
public abstract class BaseViewModel : INotifyPropertyChanged
{
// Подія для сповіщення UI про зміни
public event PropertyChangedEventHandler PropertyChanged;
// Метод для виклику події
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
Ключові моменти:
abstract class— не можна створити екземплярBaseViewModel, тільки успадкувати[CallerMemberName]— компілятор автоматично підставляє ім'я властивості, що викликала методvirtual— дозволяє перевизначити у нащадках для кастомної логіки
Використання:
public class MainViewModel : BaseViewModel
{
private string _title;
public string Title
{
get => _title;
set
{
_title = value;
// ✅ Не потрібно передавати ім'я — CallerMemberName підставить автоматично
OnPropertyChanged();
}
}
}
Переваги:
- ✅ Код
INotifyPropertyChangedу одному місці - ✅ Автоматична підстановка імені властивості
- ✅ Легко додати централізовану логіку
[CallerMemberName] — це compile-time magic. Компілятор підставляє ім'я методу/властивості, що викликала метод. Якщо викликати OnPropertyChanged() з властивості Title, компілятор підставить "Title".Як працює CallerMemberName
Розберемо детально механізм [CallerMemberName].
Код, який ви пишете:
public string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged(); // Не передаємо параметр
}
}
Що бачить компілятор:
public string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged("Title"); // Компілятор підставив автоматично
}
}
Діаграма процесу:
Переваги:
- ✅ Compile-time перевірка — якщо властивість не існує, компілятор видасть помилку
- ✅ Refactoring-safe — при перейменуванні властивості через IDE, ім'я оновиться автоматично
- ✅ Менше коду — не потрібно писати
nameof(Title)
Альтернативи без CallerMemberName:
// ❌ Рядковий літерал — помилки у runtime
OnPropertyChanged("Title");
// ✅ nameof — compile-time перевірка, але більше коду
OnPropertyChanged(nameof(Title));
// ✅ CallerMemberName — найкраще рішення
OnPropertyChanged();
SetProperty: Універсальний метод для властивостей
Навіть з BaseViewModel та CallerMemberName, кожна властивість потребує 7 рядків коду:
private string _title;
public string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged();
}
}
Якщо у ViewModel 20 властивостей — це 140 рядків однакового коду. Можна краще?
Реалізація SetProperty
Мета: Універсальний метод, що встановлює значення та викликає OnPropertyChanged тільки якщо значення змінилося.
public abstract class BaseViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
// Універсальний метод для встановлення властивостей
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
// Перевірка: чи змінилося значення?
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false; // Значення не змінилося — не викликаємо PropertyChanged
}
// Встановлюємо нове значення
field = value;
// Викликаємо PropertyChanged
OnPropertyChanged(propertyName);
return true; // Значення змінилося
}
}
Ключові моменти:
ref T field— передаємо backing field за посиланням, щоб змінити його значенняEqualityComparer<T>.Default— універсальне порівняння для будь-якого типу (працює дляstring,int,DateTime, custom класів)- Повертає
bool—trueякщо значення змінилося,falseякщо ні (корисно для додаткової логіки)
Використання SetProperty
До (без SetProperty):
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
OnPropertyChanged();
}
}
private string _lastName;
public string LastName
{
get => _lastName;
set
{
_lastName = value;
OnPropertyChanged();
}
}
private int _age;
public int Age
{
get => _age;
set
{
_age = value;
OnPropertyChanged();
}
}
Після (з SetProperty):
private string _firstName;
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value);
}
private string _lastName;
public string LastName
{
get => _lastName;
set => SetProperty(ref _lastName, value);
}
private int _age;
public int Age
{
get => _age;
set => SetProperty(ref _age, value);
}
Переваги:
- ✅ Менше коду — 3 рядки замість 7
- ✅ Автоматична перевірка на зміну —
PropertyChangedвикликається тільки якщо значення змінилося - ✅ Універсальність — працює для будь-якого типу
Порівняння кількості коду:
| Підхід | Рядків на властивість | Рядків для 20 властивостей |
|---|---|---|
| Без BaseViewModel | 10 | 200 |
| З BaseViewModel | 7 | 140 |
| З SetProperty | 3 | 60 |
set => SetProperty(...) — це скорочена форма для set { SetProperty(...); }. Працює з C# 7.0+.Чому перевірка на зміну важлива
Проблема без перевірки:
// Без перевірки
public string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged(); // Викликається завжди, навіть якщо значення не змінилося
}
}
// Використання
viewModel.Title = "Hello";
viewModel.Title = "Hello"; // ❌ PropertyChanged викликається знову, хоча значення те саме
viewModel.Title = "Hello"; // ❌ І знову
Наслідки:
- UI перемальовується без потреби (performance overhead)
- Тригерять залежні властивості без причини
- Безкінечні цикли у складних сценаріях
З перевіркою:
viewModel.Title = "Hello"; // ✅ PropertyChanged викликається
viewModel.Title = "Hello"; // ✅ Значення не змінилося — PropertyChanged НЕ викликається
viewModel.Title = "World"; // ✅ PropertyChanged викликається
Обчислювані властивості та залежності
У реальних додатках властивості часто залежать одна від одної. Наприклад, FullName залежить від FirstName та LastName.
Проблема залежних властивостей
Сценарій: Властивість FullName обчислюється з FirstName та LastName.
public class PersonViewModel : BaseViewModel
{
private string _firstName;
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value);
}
private string _lastName;
public string LastName
{
get => _lastName;
set => SetProperty(ref _lastName, value);
}
// Обчислювана властивість
public string FullName => $"{FirstName} {LastName}";
}
Проблема:
<TextBlock Text="{Binding FullName}"/>
Коли користувач змінює FirstName, UI для FullName не оновлюється, бо PropertyChanged для FullName не викликається.
Рішення: Ручне сповіщення про залежні властивості
Підхід 1: Викликати OnPropertyChanged вручну
public string FirstName
{
get => _firstName;
set
{
if (SetProperty(ref _firstName, value))
{
// Якщо FirstName змінився — сповістити про FullName
OnPropertyChanged(nameof(FullName));
}
}
}
public string LastName
{
get => _lastName;
set
{
if (SetProperty(ref _lastName, value))
{
// Якщо LastName змінився — сповістити про FullName
OnPropertyChanged(nameof(FullName));
}
}
}
public string FullName => $"{FirstName} {LastName}";
Переваги:
- ✅ Працює
- ✅ Явно видно залежності
Недоліки:
- ⚠️ Легко забути додати
OnPropertyChanged(nameof(FullName)) - ⚠️ Якщо
FullNameзалежить від 5 властивостей — потрібно додати у 5 місцях
Рішення: SetProperty з callback
Підхід 2: Розширити SetProperty для підтримки callback
public abstract class BaseViewModel : INotifyPropertyChanged
{
// ... попередній код
// Перевантаження з callback
protected bool SetProperty<T>(
ref T field,
T value,
Action onChanged = null,
[CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
// Викликаємо callback після зміни
onChanged?.Invoke();
return true;
}
}
Використання:
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value, onChanged: () => OnPropertyChanged(nameof(FullName)));
}
public string LastName
{
get => _lastName;
set => SetProperty(ref _lastName, value, onChanged: () => OnPropertyChanged(nameof(FullName)));
}
public string FullName => $"{FirstName} {LastName}";
Переваги:
- ✅ Компактніше
- ✅ Callback у одному рядку
Складні залежності: Множинні властивості
Сценарій: Властивість IsValid залежить від FirstName, LastName, Email.
public class PersonViewModel : BaseViewModel
{
private string _firstName;
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value, onChanged: NotifyValidationChanged);
}
private string _lastName;
public string LastName
{
get => _lastName;
set => SetProperty(ref _lastName, value, onChanged: NotifyValidationChanged);
}
private string _email;
public string Email
{
get => _email;
set => SetProperty(ref _email, value, onChanged: NotifyValidationChanged);
}
// Обчислювана властивість
public bool IsValid =>
!string.IsNullOrWhiteSpace(FirstName) &&
!string.IsNullOrWhiteSpace(LastName) &&
!string.IsNullOrWhiteSpace(Email) &&
Email.Contains("@");
// Централізований метод для сповіщення про валідацію
private void NotifyValidationChanged()
{
OnPropertyChanged(nameof(IsValid));
}
}
XAML:
<Button Content="Зберегти" IsEnabled="{Binding IsValid}"/>
Тепер кнопка автоматично активується/деактивується при зміні будь-якої з трьох властивостей.
NotifyValidationChanged()), щоб не дублювати OnPropertyChanged(nameof(IsValid)) у кожному setter.Валідація: INotifyDataErrorInfo
У реальних додатках потрібна валідація вводу користувача з відображенням помилок у UI. WPF надає інтерфейс INotifyDataErrorInfo для цього.
Що таке INotifyDataErrorInfo
Інтерфейс:
public interface INotifyDataErrorInfo
{
// Чи є помилки у об'єкті
bool HasErrors { get; }
// Отримати помилки для конкретної властивості
IEnumerable GetErrors(string propertyName);
// Подія при зміні помилок
event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
}
Як це працює:
Реалізація BaseViewModel з валідацією
Розширений BaseViewModel:
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
public abstract class BaseViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
// INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
// INotifyDataErrorInfo
private readonly Dictionary<string, List<string>> _errors = new Dictionary<string, List<string>>();
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
public bool HasErrors => _errors.Any();
public IEnumerable GetErrors(string propertyName)
{
if (string.IsNullOrEmpty(propertyName))
{
// Повернути всі помилки
return _errors.Values.SelectMany(e => e);
}
return _errors.ContainsKey(propertyName) ? _errors[propertyName] : null;
}
// Додати помилку
protected void AddError(string propertyName, string error)
{
if (!_errors.ContainsKey(propertyName))
{
_errors[propertyName] = new List<string>();
}
if (!_errors[propertyName].Contains(error))
{
_errors[propertyName].Add(error);
OnErrorsChanged(propertyName);
}
}
// Очистити помилки для властивості
protected void ClearErrors(string propertyName)
{
if (_errors.ContainsKey(propertyName))
{
_errors.Remove(propertyName);
OnErrorsChanged(propertyName);
}
}
// Викликати подію ErrorsChanged
protected void OnErrorsChanged(string propertyName)
{
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
OnPropertyChanged(nameof(HasErrors));
}
}
Використання валідації у ViewModel
Приклад: Форма реєстрації
public class RegistrationViewModel : BaseViewModel
{
private string _email;
public string Email
{
get => _email;
set
{
if (SetProperty(ref _email, value))
{
ValidateEmail();
}
}
}
private string _password;
public string Password
{
get => _password;
set
{
if (SetProperty(ref _password, value))
{
ValidatePassword();
}
}
}
private string _confirmPassword;
public string ConfirmPassword
{
get => _confirmPassword;
set
{
if (SetProperty(ref _confirmPassword, value))
{
ValidateConfirmPassword();
}
}
}
// Валідація Email
private void ValidateEmail()
{
ClearErrors(nameof(Email));
if (string.IsNullOrWhiteSpace(Email))
{
AddError(nameof(Email), "Email обов'язковий");
}
else if (!Email.Contains("@"))
{
AddError(nameof(Email), "Некоректний формат email");
}
else if (Email.Length < 5)
{
AddError(nameof(Email), "Email занадто короткий");
}
}
// Валідація Password
private void ValidatePassword()
{
ClearErrors(nameof(Password));
if (string.IsNullOrWhiteSpace(Password))
{
AddError(nameof(Password), "Пароль обов'язковий");
}
else if (Password.Length < 8)
{
AddError(nameof(Password), "Пароль має бути мінімум 8 символів");
}
else if (!Password.Any(char.IsDigit))
{
AddError(nameof(Password), "Пароль має містити цифру");
}
// Перевалідувати ConfirmPassword при зміні Password
ValidateConfirmPassword();
}
// Валідація ConfirmPassword
private void ValidateConfirmPassword()
{
ClearErrors(nameof(ConfirmPassword));
if (string.IsNullOrWhiteSpace(ConfirmPassword))
{
AddError(nameof(ConfirmPassword), "Підтвердження паролю обов'язкове");
}
else if (Password != ConfirmPassword)
{
AddError(nameof(ConfirmPassword), "Паролі не співпадають");
}
}
}
XAML з відображенням помилок:
<StackPanel Margin="20">
<!-- Email -->
<TextBlock Text="Email:"/>
<TextBox Text="{Binding Email, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<!-- Password -->
<TextBlock Text="Пароль:" Margin="0,10,0,0"/>
<PasswordBox x:Name="passwordBox"/>
<!-- Confirm Password -->
<TextBlock Text="Підтвердження паролю:" Margin="0,10,0,0"/>
<TextBox Text="{Binding ConfirmPassword, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<!-- Кнопка (активна тільки якщо немає помилок) -->
<Button Content="Зареєструватися"
IsEnabled="{Binding HasErrors, Converter={StaticResource InverseBooleanConverter}}"
Margin="0,20,0,0"/>
</StackPanel>
Ключові моменти:
ValidatesOnNotifyDataErrors=True— WPF автоматично підписується наErrorsChangedта показує помилкиUpdateSourceTrigger=PropertyChanged— валідація відбувається при кожній зміні, а не тільки при втраті фокусуIsEnabled="{Binding HasErrors, Converter=...}"— кнопка активна тільки якщо немає помилок
Стилізація помилок валідації у View
WPF автоматично показує червону рамку навколо контролу з помилкою, але можна кастомізувати через Validation.ErrorTemplate.
Кастомний шаблон помилки:
<Window.Resources>
<!-- Шаблон для відображення помилок -->
<ControlTemplate x:Key="ValidationErrorTemplate">
<DockPanel>
<!-- Червона рамка навколо контролу -->
<Border BorderBrush="Red" BorderThickness="2" CornerRadius="3">
<AdornedElementPlaceholder/>
</Border>
<!-- Іконка помилки -->
<TextBlock DockPanel.Dock="Right"
Text="⚠"
Foreground="Red"
FontSize="16"
Margin="5,0,0,0"
ToolTip="{Binding ElementName=adorner, Path=AdornedElement.(Validation.Errors)[0].ErrorContent}"/>
</DockPanel>
</ControlTemplate>
</Window.Resources>
<TextBox Text="{Binding Email, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"
Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"/>
Tooltip з текстом помилки:
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="ToolTip"
Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}"/>
</Trigger>
</Style.Triggers>
</Style>
Візуалізація:
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Width="300">
<TextBlock Text="Email:" FontWeight="Bold"/>
<TextBox Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}"
Margin="0,5,0,0"/>
<TextBlock Text="Пароль:" FontWeight="Bold" Margin="0,15,0,0"/>
<TextBox Text="{Binding Password, UpdateSourceTrigger=PropertyChanged}"
Margin="0,5,0,0"/>
<TextBlock Text="Підтвердження:" FontWeight="Bold" Margin="0,15,0,0"/>
<TextBox Text="{Binding ConfirmPassword, UpdateSourceTrigger=PropertyChanged}"
Margin="0,5,0,0"/>
<Button Content="Зареєструватися"
Command="{Binding RegisterCommand}"
Margin="0,20,0,0"
HorizontalAlignment="Stretch"/>
</StackPanel>
// Code-behind для демонстрації
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new RegistrationViewModel();
}
}
public class RegistrationViewModel
{
public string Email { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
public ICommand RegisterCommand => new RelayCommand(
() => { /* Логіка реєстрації */ },
() => !string.IsNullOrWhiteSpace(Email) &&
!string.IsNullOrWhiteSpace(Password) &&
Password == ConfirmPassword
);
}
INotifyDataErrorInfo повністю. У реальному WPF-проєкті червона рамка з'явиться автоматично при помилці валідації.DesignTime дані: Робота дизайнера без запуску
Одна з найбільших проблем при розробці UI — неможливість побачити результат без запуску додатку. Дизайнер Visual Studio показує порожні контроли, бо DataContext встановлюється у runtime.
Проблема: Порожній дизайнер
XAML:
<Window x:Class="MyApp.MainWindow"
DataContext="{Binding Source={StaticResource MainViewModel}}">
<StackPanel>
<TextBlock Text="{Binding Title}" FontSize="24"/>
<TextBlock Text="{Binding Description}"/>
<ListBox ItemsSource="{Binding Items}"/>
</StackPanel>
</Window>
Що бачить дизайнер:
- Порожній
TextBlock(боTitle= null) - Порожній
ListBox(боItems= null) - Неможливо оцінити layout, розміри, вирівнювання
Що хочемо бачити:
Title= "Мій додаток"Description= "Опис функціональності"Items= "Елемент 1", "Елемент 2", "Елемент 3"
Рішення: d:DataContext для DesignTime
WPF надає спеціальний namespace d: (design-time) для встановлення даних, що використовуються тільки у дизайнері.
Крок 1: Додати namespace:
<Window x:Class="MyApp.MainWindow"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:MyApp.ViewModels"
mc:Ignorable="d">
Крок 2: Встановити d:DataContext:
<Window x:Class="MyApp.MainWindow"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:MyApp.ViewModels"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel, IsDesignTimeCreatable=True}">
<StackPanel>
<TextBlock Text="{Binding Title}" FontSize="24"/>
<TextBlock Text="{Binding Description}"/>
<ListBox ItemsSource="{Binding Items}"/>
</StackPanel>
</Window>
Ключові моменти:
mc:Ignorable="d"— WPF ігнорує всіd:атрибути у runtimed:DataContext— DataContext тільки для дизайнераIsDesignTimeCreatable=True— дизайнер може створити екземпляр ViewModel через конструктор без параметрів
Створення DesignTime ViewModel
Підхід 1: Конструктор з параметром для DesignTime
public class MainViewModel : BaseViewModel
{
public string Title { get; set; }
public string Description { get; set; }
public ObservableCollection<string> Items { get; set; }
// Конструктор без параметрів для DesignTime
public MainViewModel() : this(isDesignTime: true)
{
}
// Конструктор з параметром
public MainViewModel(bool isDesignTime)
{
if (isDesignTime)
{
// DesignTime дані
Title = "Мій додаток (Design)";
Description = "Це тестові дані для дизайнера";
Items = new ObservableCollection<string>
{
"Елемент 1",
"Елемент 2",
"Елемент 3"
};
}
else
{
// Runtime дані
Title = "Мій додаток";
Description = "Завантаження...";
Items = new ObservableCollection<string>();
LoadDataAsync();
}
}
private async void LoadDataAsync()
{
// Завантаження реальних даних
}
}
Підхід 2: Окремий DesignTime ViewModel
// Runtime ViewModel
public class MainViewModel : BaseViewModel
{
public string Title { get; set; }
public ObservableCollection<string> Items { get; set; }
public MainViewModel()
{
Title = "Мій додаток";
Items = new ObservableCollection<string>();
LoadDataAsync();
}
}
// DesignTime ViewModel
public class DesignMainViewModel : MainViewModel
{
public DesignMainViewModel()
{
Title = "Мій додаток (Design)";
Items = new ObservableCollection<string>
{
"Тестовий елемент 1",
"Тестовий елемент 2",
"Тестовий елемент 3"
};
}
}
XAML з DesignTime ViewModel:
<Window d:DataContext="{d:DesignInstance Type=vm:DesignMainViewModel, IsDesignTimeCreatable=True}">
Перевірка DesignTime у коді
Іноді потрібно перевірити, чи код виконується у дизайнері, щоб не викликати API або БД.
Метод 1: DesignerProperties.GetIsInDesignMode
using System.ComponentModel;
using System.Windows;
public class MainViewModel : BaseViewModel
{
public MainViewModel()
{
// Перевірка: чи виконується у дизайнері?
if (DesignerProperties.GetIsInDesignMode(new DependencyObject()))
{
// DesignTime дані
Title = "Тестовий заголовок";
Items = new ObservableCollection<string> { "Item 1", "Item 2" };
}
else
{
// Runtime дані
LoadDataAsync();
}
}
}
Метод 2: Через Environment
public class MainViewModel : BaseViewModel
{
private static bool IsDesignMode =>
(bool)DesignerProperties.IsInDesignModeProperty.GetMetadata(typeof(DependencyObject)).DefaultValue;
public MainViewModel()
{
if (IsDesignMode)
{
// DesignTime
}
else
{
// Runtime
}
}
}
IsInDesignMode у конструкторі. Це чистіше та легше підтримувати.Переваги DesignTime даних
👁️ Візуальний feedback
🎨 Робота дизайнера
🐛 Легше знайти баги
📏 Тестування різних станів
🔵 Recap: ООП концепції у ViewModel
Для студентів зі слабким розумінням ООП — коротке нагадування ключових концепцій, що використовуються у ViewModel.
Абстрактні класи: BaseViewModel як шаблон
Що таке абстрактний клас?
Абстрактний клас — це клас, від якого не можна створити екземпляр. Він слугує шаблоном для нащадків.
// Абстрактний клас — шаблон
public abstract class BaseViewModel : INotifyPropertyChanged
{
// Спільна логіка для всіх ViewModel
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
// ❌ Не можна створити екземпляр
var vm = new BaseViewModel(); // Помилка компіляції
// ✅ Можна успадкувати
public class MainViewModel : BaseViewModel
{
// Використовує OnPropertyChanged з BaseViewModel
}
Чому це важливо для MVVM?
BaseViewModel— це шаблон для всіх ViewModel- Спільна логіка (
INotifyPropertyChanged,SetProperty) у одному місці - Всі ViewModel успадковують цю логіку автоматично
Аналогія: Абстрактний клас — це як креслення будинку. Ви не можете жити у кресленні, але можете побудувати багато будинків за цим кресленням.
Generics: SetProperty для будь-якого типу
Що таке Generics?
Generics — це можливість писати код, що працює з будь-яким типом.
// Без Generics — потрібен окремий метод для кожного типу
protected bool SetPropertyString(ref string field, string value) { /* ... */ }
protected bool SetPropertyInt(ref int field, int value) { /* ... */ }
protected bool SetPropertyDateTime(ref DateTime field, DateTime value) { /* ... */ }
// З Generics — один метод для всіх типів
protected bool SetProperty<T>(ref T field, T value)
{
// Працює для string, int, DateTime, будь-якого типу
}
Чому це важливо для ViewModel?
SetProperty<T>працює для будь-якого типу властивості- Не потрібно писати окремі методи для
string,int,DateTime - Компілятор перевіряє типи — безпечно
Аналогія: Generics — це як універсальний адаптер для розеток. Один адаптер працює з будь-якою вилкою.
Ref параметри: Зміна значення backing field
Що таке ref?
ref — це передача параметра за посиланням, а не за значенням. Метод може змінити оригінальну змінну.
// Без ref — зміна локальної копії
void SetValue(int x)
{
x = 10; // Змінюється тільки локальна копія
}
int number = 5;
SetValue(number);
Console.WriteLine(number); // 5 — не змінилося
// З ref — зміна оригінальної змінної
void SetValue(ref int x)
{
x = 10; // Змінюється оригінальна змінна
}
int number = 5;
SetValue(ref number);
Console.WriteLine(number); // 10 — змінилося
Чому це важливо для SetProperty?
protected bool SetProperty<T>(ref T field, T value)
{
// field — це backing field (_firstName, _age, тощо)
// ref дозволяє змінити оригінальний backing field
field = value;
}
// Використання
private string _firstName;
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value); // ref передає _firstName за посиланням
}
Аналогія: ref — це як дати комусь ключ від вашого будинку. Він може зайти та змінити щось всередині. Без ref — це як дати фотографію будинку — він може змінити фото, але не сам будинок.
Dictionary: Зберігання помилок валідації
Що таке Dictionary?
Dictionary — це колекція ключ-значення. Швидкий пошук за ключем.
// Dictionary<TKey, TValue>
Dictionary<string, List<string>> _errors = new Dictionary<string, List<string>>();
// Додати помилку для властивості "Email"
_errors["Email"] = new List<string> { "Некоректний email" };
// Отримати помилки для властивості "Email"
List<string> emailErrors = _errors["Email"];
// Перевірити, чи є помилки для властивості
bool hasEmailErrors = _errors.ContainsKey("Email");
Чому це важливо для валідації?
- Ключ — ім'я властивості (
"Email","Password") - Значення — список помилок для цієї властивості
- Швидкий доступ до помилок конкретної властивості
Структура:
_errors = {
"Email": ["Некоректний email", "Email занадто короткий"],
"Password": ["Пароль має містити цифру"],
"ConfirmPassword": ["Паролі не співпадають"]
}
Практичні завдання
Рівень 1: Створити BaseViewModel з SetProperty
Мета: Навчитися створювати базовий клас для всіх ViewModel.
Завдання:
Створіть BaseViewModel з наступною функціональністю:
- Реалізація
INotifyPropertyChanged - Метод
OnPropertyChangedз[CallerMemberName] - Метод
SetProperty<T>з перевіркою на зміну - Тестовий
PersonViewModelз властивостямиFirstName,LastName,Age
Критерії успіху:
BaseViewModelє абстрактним класомSetPropertyповертаєbool(чи змінилося значення)PersonViewModelуспадковуєBaseViewModel- Всі властивості використовують
SetProperty - При зміні властивості викликається
PropertyChanged
Підказка:
public abstract class BaseViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
// TODO: Реалізувати
}
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
// TODO: Реалізувати
// 1. Перевірити, чи змінилося значення (EqualityComparer<T>.Default.Equals)
// 2. Якщо не змінилося — повернути false
// 3. Встановити нове значення (field = value)
// 4. Викликати OnPropertyChanged
// 5. Повернути true
}
}
public class PersonViewModel : BaseViewModel
{
// TODO: Додати властивості FirstName, LastName, Age з SetProperty
}
Тест:
[Test]
public void SetProperty_ShouldRaisePropertyChanged()
{
var vm = new PersonViewModel();
bool eventRaised = false;
vm.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(PersonViewModel.FirstName))
eventRaised = true;
};
vm.FirstName = "Іван";
Assert.IsTrue(eventRaised);
}
[Test]
public void SetProperty_ShouldNotRaisePropertyChanged_WhenValueNotChanged()
{
var vm = new PersonViewModel { FirstName = "Іван" };
int eventCount = 0;
vm.PropertyChanged += (s, e) => eventCount++;
vm.FirstName = "Іван"; // Те саме значення
Assert.AreEqual(0, eventCount);
}
Рівень 2: Додати валідацію через INotifyDataErrorInfo
Мета: Реалізувати валідацію з відображенням помилок у UI.
Завдання:
Розширте BaseViewModel з Рівня 1, додавши підтримку INotifyDataErrorInfo:
- Реалізувати інтерфейс
INotifyDataErrorInfo - Додати методи
AddError,ClearErrors - Створити
RegistrationViewModelз валідацією:Email— обов'язковий, має містити@Password— мінімум 8 символів, має містити цифруConfirmPassword— має співпадати зPassword
- Створити XAML-форму з відображенням помилок
Критерії успіху:
BaseViewModelреалізуєINotifyDataErrorInfo- Валідація викликається при зміні властивості
- UI показує червону рамку при помилці
- Кнопка "Зареєструватися" активна тільки якщо немає помилок
- Всі тести проходять
Підказка для BaseViewModel:
public abstract class BaseViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
// ... попередній код INotifyPropertyChanged
private readonly Dictionary<string, List<string>> _errors = new Dictionary<string, List<string>>();
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
public bool HasErrors => _errors.Any();
public IEnumerable GetErrors(string propertyName)
{
// TODO: Реалізувати
}
protected void AddError(string propertyName, string error)
{
// TODO: Реалізувати
// 1. Створити список помилок для властивості, якщо не існує
// 2. Додати помилку до списку
// 3. Викликати OnErrorsChanged
}
protected void ClearErrors(string propertyName)
{
// TODO: Реалізувати
// 1. Видалити помилки для властивості
// 2. Викликати OnErrorsChanged
}
protected void OnErrorsChanged(string propertyName)
{
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
OnPropertyChanged(nameof(HasErrors));
}
}
Підказка для RegistrationViewModel:
public class RegistrationViewModel : BaseViewModel
{
private string _email;
public string Email
{
get => _email;
set
{
if (SetProperty(ref _email, value))
{
ValidateEmail();
}
}
}
private void ValidateEmail()
{
ClearErrors(nameof(Email));
if (string.IsNullOrWhiteSpace(Email))
{
AddError(nameof(Email), "Email обов'язковий");
}
else if (!Email.Contains("@"))
{
AddError(nameof(Email), "Некоректний формат email");
}
}
// TODO: Додати Password, ConfirmPassword з валідацією
}
Тести:
[Test]
public void Email_ShouldHaveError_WhenEmpty()
{
var vm = new RegistrationViewModel();
vm.Email = "";
Assert.IsTrue(vm.HasErrors);
var errors = vm.GetErrors(nameof(vm.Email)).Cast<string>().ToList();
Assert.IsTrue(errors.Any(e => e.Contains("обов'язковий")));
}
[Test]
public void Email_ShouldHaveError_WhenInvalidFormat()
{
var vm = new RegistrationViewModel();
vm.Email = "invalid-email";
Assert.IsTrue(vm.HasErrors);
var errors = vm.GetErrors(nameof(vm.Email)).Cast<string>().ToList();
Assert.IsTrue(errors.Any(e => e.Contains("Некоректний формат")));
}
[Test]
public void ConfirmPassword_ShouldHaveError_WhenNotMatchingPassword()
{
var vm = new RegistrationViewModel();
vm.Password = "Password123";
vm.ConfirmPassword = "DifferentPassword";
Assert.IsTrue(vm.HasErrors);
var errors = vm.GetErrors(nameof(vm.ConfirmPassword)).Cast<string>().ToList();
Assert.IsTrue(errors.Any(e => e.Contains("не співпадають")));
}
Рівень 3: Обчислювані властивості та DesignTime дані
Мета: Реалізувати складні залежності між властивостями та додати DesignTime дані для дизайнера.
Завдання:
Створіть ProductViewModel для інтернет-магазину:
- Властивості:
Name(string) — назва товаруPrice(decimal) — ціна за одиницюQuantity(int) — кількістьDiscount(decimal) — знижка у відсотках (0-100)TotalPrice(обчислювана) —Price * Quantity * (1 - Discount/100)IsValid(обчислювана) — чи всі поля заповнені коректно
- Валідація:
Name— обов'язковий, мінімум 3 символиPrice— більше 0Quantity— більше 0Discount— від 0 до 100
- Залежності:
- При зміні
Price,QuantityабоDiscount→ оновитиTotalPrice - При зміні будь-якої властивості → оновити
IsValid
- При зміні
- DesignTime дані:
- Створити
DesignProductViewModelз тестовими даними - XAML має показувати дані у дизайнері
- Створити
Критерії успіху:
- Всі обчислювані властивості оновлюються автоматично
- Валідація працює для всіх полів
- DesignTime дані видно у дизайнері Visual Studio
- Всі тести проходять
Підказка для ProductViewModel:
public class ProductViewModel : BaseViewModel
{
private string _name;
public string Name
{
get => _name;
set
{
if (SetProperty(ref _name, value))
{
ValidateName();
OnPropertyChanged(nameof(IsValid));
}
}
}
private decimal _price;
public decimal Price
{
get => _price;
set
{
if (SetProperty(ref _price, value))
{
ValidatePrice();
OnPropertyChanged(nameof(TotalPrice));
OnPropertyChanged(nameof(IsValid));
}
}
}
private int _quantity;
public int Quantity
{
get => _quantity;
set
{
if (SetProperty(ref _quantity, value))
{
ValidateQuantity();
OnPropertyChanged(nameof(TotalPrice));
OnPropertyChanged(nameof(IsValid));
}
}
}
private decimal _discount;
public decimal Discount
{
get => _discount;
set
{
if (SetProperty(ref _discount, value))
{
ValidateDiscount();
OnPropertyChanged(nameof(TotalPrice));
OnPropertyChanged(nameof(IsValid));
}
}
}
// Обчислювана властивість
public decimal TotalPrice
{
get
{
if (Price <= 0 || Quantity <= 0)
return 0;
return Price * Quantity * (1 - Discount / 100);
}
}
// Обчислювана властивість
public bool IsValid =>
!HasErrors &&
!string.IsNullOrWhiteSpace(Name) &&
Price > 0 &&
Quantity > 0 &&
Discount >= 0 && Discount <= 100;
// TODO: Додати методи валідації
}
Підказка для DesignTime:
public class DesignProductViewModel : ProductViewModel
{
public DesignProductViewModel()
{
Name = "Ноутбук Lenovo ThinkPad";
Price = 25000;
Quantity = 2;
Discount = 10;
}
}
XAML з DesignTime:
<Window x:Class="MyApp.MainWindow"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:MyApp.ViewModels"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:DesignProductViewModel, IsDesignTimeCreatable=True}">
<StackPanel Margin="20">
<TextBlock Text="Назва товару:"/>
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<TextBlock Text="Ціна:" Margin="0,10,0,0"/>
<TextBox Text="{Binding Price, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<TextBlock Text="Кількість:" Margin="0,10,0,0"/>
<TextBox Text="{Binding Quantity, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<TextBlock Text="Знижка (%):" Margin="0,10,0,0"/>
<TextBox Text="{Binding Discount, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<TextBlock Text="{Binding TotalPrice, StringFormat='Загальна сума: {0:C}'}"
FontSize="18"
FontWeight="Bold"
Margin="0,20,0,0"/>
<Button Content="Додати до кошика"
IsEnabled="{Binding IsValid}"
Margin="0,20,0,0"/>
</StackPanel>
</Window>
Тести:
[Test]
public void TotalPrice_ShouldUpdate_WhenPriceChanges()
{
var vm = new ProductViewModel { Price = 100, Quantity = 2, Discount = 0 };
Assert.AreEqual(200, vm.TotalPrice);
vm.Price = 150;
Assert.AreEqual(300, vm.TotalPrice);
}
[Test]
public void TotalPrice_ShouldApplyDiscount()
{
var vm = new ProductViewModel { Price = 100, Quantity = 2, Discount = 10 };
// 100 * 2 * (1 - 10/100) = 180
Assert.AreEqual(180, vm.TotalPrice);
}
[Test]
public void IsValid_ShouldBeFalse_WhenNameIsEmpty()
{
var vm = new ProductViewModel { Name = "", Price = 100, Quantity = 1, Discount = 0 };
Assert.IsFalse(vm.IsValid);
}
[Test]
public void PropertyChanged_ShouldRaiseForTotalPrice_WhenQuantityChanges()
{
var vm = new ProductViewModel { Price = 100, Quantity = 1, Discount = 0 };
bool eventRaised = false;
vm.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(ProductViewModel.TotalPrice))
eventRaised = true;
};
vm.Quantity = 2;
Assert.IsTrue(eventRaised);
}
Підсумок
ViewModel — це не просто клас з властивостями. Це інфраструктура, що забезпечує зв'язок між View та Model, валідацію, обчислювані властивості та DesignTime дані.
Ключові висновки:
🏗️ BaseViewModel
⚡ SetProperty<T>
🔗 Обчислювані властивості
✅ Валідація
🎨 DesignTime дані
🧪 Testability
Переваги правильної реалізації ViewModel:
- ✅ Менше boilerplate-коду (SetProperty замість 7 рядків на властивість)
- ✅ Compile-time перевірка (CallerMemberName, nameof)
- ✅ Централізована логіка (логування, tracking у одному місці)
- ✅ Автоматична валідація з відображенням у UI
- ✅ Обчислювані властивості з автоматичним оновленням
- ✅ DesignTime дані для швидкого feedback
Недоліки:
- ⚠️ Більше коду на початку (BaseViewModel, валідація)
- ⚠️ Потрібно пам'ятати про залежності між властивостями
- ⚠️ Складність для простих форм (overkill для 2-3 полів)
Що далі?
- Commands (наступна стаття) — ICommand, RelayCommand, AsyncRelayCommand для прив'язки дій до кнопок
- MVVM Toolkit (стаття 25) — автоматизація boilerplate через Source Generators (ObservableProperty, RelayCommand)
- Messenger Pattern (стаття 26) — комунікація між ViewModel без прямих посилань
Словник термінів
_firstName для FirstName).Обчислювана властивість — властивість без backing field, що обчислюється з інших властивостей (наприклад, FullName з FirstName та LastName).INotifyDataErrorInfo — інтерфейс для валідації з автоматичним відображенням помилок у UI.Validation.ErrorTemplate — шаблон для відображення помилок валідації у WPF (червона рамка, tooltip).DesignTime дані — тестові дані, що використовуються тільки у дизайнері Visual Studio для візуального feedback.d:DataContext — атрибут для встановлення DataContext тільки у дизайнері (ігнорується у runtime).IsDesignTimeCreatable — атрибут, що вказує, чи може дизайнер створити екземпляр ViewModel через конструктор без параметрів.EqualityComparerДодаткові ресурси
📖 Microsoft Docs: INotifyPropertyChanged
📖 Microsoft Docs: INotifyDataErrorInfo
MVVM Pattern — Від Spaghetti Code до архітектури
Розуміння MVVM як архітектурного патерну — мотивація, структура, три компоненти (Model, View, ViewModel) та золоті правила розділення відповідальності
Commands — Від event handlers до декларативних команд
ICommand інтерфейс, RelayCommand реалізація, CanExecute для автоматичного IsEnabled, CommandParameter, AsyncRelayCommand та KeyBindings