У попередній статті ми розібрали MVVM Pattern як архітектурний патерн — три компоненти, золоті правила, потік даних. Але залишилося питання: як саме реалізувати ViewModel?
Уявіть ситуацію: ви створюєте додаток з 10 екранами. Кожен екран має свій ViewModel. Кожен ViewModel потребує:
INotifyPropertyChangedOnPropertyChanged(string propertyName)private string _firstName;
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
OnPropertyChanged(nameof(FirstName));
}
}
Проблеми:
OnPropertyChanged("FirsName") (опечатка) → UI не оновлюєтьсяРішення: Створити BaseViewModel — базовий клас з усією інфраструктурою, який успадковують всі ViewModel.
Розберемо детально, чому ручна реалізація INotifyPropertyChanged для кожного 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));
}
// ... властивості
}
Що не так?
Проблема: Рядкові літерали — джерело помилок.
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
// ❌ Опечатка — UI не оновиться
OnPropertyChanged("FirsName");
}
}
Наслідки:
Спроба виправлення через 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 файлів. Якщо забули один — логування працює неповністю.
Інші приклади централізованої логіки:
Без базового класу — неможливо додати цю логіку централізовано.
Рішення всіх проблем — створити BaseViewModel — абстрактний базовий клас, який реалізує INotifyPropertyChanged та надає інфраструктуру для всіх ViewModel.
Мета: Винести спільний код у один клас.
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].
Код, який ви пишете:
public string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged(); // Не передаємо параметр
}
}
Що бачить компілятор:
public string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged("Title"); // Компілятор підставив автоматично
}
}
Діаграма процесу:
Переваги:
nameof(Title)Альтернативи без CallerMemberName:
// ❌ Рядковий літерал — помилки у runtime
OnPropertyChanged("Title");
// ✅ nameof — compile-time перевірка, але більше коду
OnPropertyChanged(nameof(Title));
// ✅ CallerMemberName — найкраще рішення
OnPropertyChanged();
Навіть з BaseViewModel та CallerMemberName, кожна властивість потребує 7 рядків коду:
private string _title;
public string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged();
}
}
Якщо у ViewModel 20 властивостей — це 140 рядків однакового коду. Можна краще?
Мета: Універсальний метод, що встановлює значення та викликає 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):
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);
}
Переваги:
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"; // ❌ І знову
Наслідки:
З перевіркою:
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 місцяхПідхід 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}";
Переваги:
Сценарій: Властивість 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.У реальних додатках потрібна валідація вводу користувача з відображенням помилок у UI. WPF надає інтерфейс INotifyDataErrorInfo для цього.
Інтерфейс:
public interface INotifyDataErrorInfo
{
// Чи є помилки у об'єкті
bool HasErrors { get; }
// Отримати помилки для конкретної властивості
IEnumerable GetErrors(string propertyName);
// Подія при зміні помилок
event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
}
Як це працює:
Розширений 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));
}
}
Приклад: Форма реєстрації
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=...}" — кнопка активна тільки якщо немає помилок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-проєкті червона рамка з'явиться автоматично при помилці валідації.Одна з найбільших проблем при розробці 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)Що хочемо бачити:
Title = "Мій додаток"Description = "Опис функціональності"Items = "Елемент 1", "Елемент 2", "Елемент 3"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 через конструктор без параметрівПідхід 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}">
Іноді потрібно перевірити, чи код виконується у дизайнері, щоб не викликати 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 у конструкторі. Це чистіше та легше підтримувати.👁️ Візуальний feedback
🎨 Робота дизайнера
🐛 Легше знайти баги
📏 Тестування різних станів
Для студентів зі слабким розумінням ООП — коротке нагадування ключових концепцій, що використовуються у ViewModel.
Що таке абстрактний клас?
Абстрактний клас — це клас, від якого не можна створити екземпляр. Він слугує шаблоном для нащадків.
// Абстрактний клас — шаблон
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 — це шаблон для всіх ViewModelINotifyPropertyChanged, 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?
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<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": ["Паролі не співпадають"]
}
Мета: Навчитися створювати базовий клас для всіх ViewModel.
Завдання:
Створіть BaseViewModel з наступною функціональністю:
INotifyPropertyChangedOnPropertyChanged з [CallerMemberName]SetProperty<T> з перевіркою на змінуPersonViewModel з властивостями FirstName, LastName, AgeКритерії успіху:
BaseViewModel є абстрактним класомSetProperty повертає bool (чи змінилося значення)PersonViewModel успадковує BaseViewModelSetPropertyPropertyChangedПідказка:
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);
}
Мета: Реалізувати валідацію з відображенням помилок у UI.
Завдання:
Розширте BaseViewModel з Рівня 1, додавши підтримку INotifyDataErrorInfo:
INotifyDataErrorInfoAddError, ClearErrorsRegistrationViewModel з валідацією:
Email — обов'язковий, має містити @Password — мінімум 8 символів, має містити цифруConfirmPassword — має співпадати з PasswordКритерії успіху:
BaseViewModel реалізує INotifyDataErrorInfoПідказка для 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("не співпадають")));
}
Мета: Реалізувати складні залежності між властивостями та додати 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 до 100Price, Quantity або Discount → оновити TotalPriceIsValidDesignProductViewModel з тестовими данимиКритерії успіху:
Підказка для 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:
Недоліки:
Що далі?
_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