У попередніх статтях ми створили BaseViewModel з INotifyPropertyChanged та Commands з RelayCommand. Але кожна властивість потребувала 3-7 рядків коду, кожна команда — 10-15 рядків. Для ViewModel з 20 властивостями та 5 командами — це 100+ рядків boilerplate-коду.
Приклад типового ViewModel:
public class PersonViewModel : BaseViewModel
{
// Властивість 1 — 7 рядків
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
if (SetProperty(ref _firstName, value))
{
OnPropertyChanged(nameof(FullName));
}
}
}
// Властивість 2 — 7 рядків
private string _lastName;
public string LastName
{
get => _lastName;
set
{
if (SetProperty(ref _lastName, value))
{
OnPropertyChanged(nameof(FullName));
}
}
}
// Обчислювана властивість — 1 рядок
public string FullName => $"{FirstName} {LastName}";
// Команда — 15 рядків
private RelayCommand _saveCommand;
public ICommand SaveCommand => _saveCommand ??= new RelayCommand(Save, CanSave);
private void Save()
{
// Логіка збереження
}
private bool CanSave()
{
return !string.IsNullOrWhiteSpace(FirstName);
}
}
Підрахунок: 2 властивості (14 рядків) + 1 команда (15 рядків) = 29 рядків для базової функціональності.
Питання: Чи можна це спростити?
Відповідь: Так! CommunityToolkit.Mvvm автоматизує boilerplate через Source Generators.
Розберемо детально, скільки коду потрібно для типового ViewModel без Toolkit.
Для 1 властивості з залежністю:
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
if (SetProperty(ref _firstName, value))
{
OnPropertyChanged(nameof(FullName));
}
}
}
7 рядків на властивість.
Для 1 команди:
private RelayCommand _saveCommand;
public ICommand SaveCommand => _saveCommand ??= new RelayCommand(Save, CanSave);
private void Save()
{
// Логіка
}
private bool CanSave()
{
return !string.IsNullOrWhiteSpace(FirstName);
}
10-15 рядків на команду.
Для типового ViewModel:
| Елемент | Кількість | Рядків на елемент | Всього рядків |
|---|---|---|---|
| Властивості | 20 | 7 | 140 |
| Команди | 5 | 12 | 60 |
| BaseViewModel | 1 | 50 | 50 |
| Разом | 250 |
250 рядків boilerplate-коду для одного ViewModel!
Проблема: Той самий патерн повторюється десятки разів.
// Патерн 1: Властивість (повторюється 20 разів)
private string _field;
public string Property
{
get => _field;
set => SetProperty(ref _field, value);
}
// Патерн 2: Команда (повторюється 5 разів)
private RelayCommand _command;
public ICommand Command => _command ??= new RelayCommand(Execute, CanExecute);
Що не так?
OnPropertyChanged для залежної властивості)CommunityToolkit.Mvvm (раніше Microsoft.Toolkit.Mvvm) — це офіційна бібліотека від Microsoft для автоматизації MVVM через Source Generators.
Source Generators — це механізм C# 9.0+, що дозволяє генерувати код під час компіляції на основі атрибутів.
Приклад:
Ваш код:
[ObservableProperty]
private string _firstName;
Згенерований код (автоматично):
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value);
}
Переваги:
NuGet Package:
dotnet add package CommunityToolkit.Mvvm
Або через Package Manager:
Install-Package CommunityToolkit.Mvvm
Версія: 8.0+ (підтримує .NET 6+, .NET Framework 4.6.2+)
Чому цей Toolkit:
Найпотужніший атрибут Toolkit — [ObservableProperty] для автоматичної генерації властивостей з INotifyPropertyChanged.
До (без Toolkit):
public class PersonViewModel : BaseViewModel
{
private string _firstName;
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value);
}
}
Після (з Toolkit):
public partial class PersonViewModel : ObservableObject
{
[ObservableProperty]
private string _firstName;
}
Що генерується:
// Згенерований код (автоматично)
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value, global::System.ComponentModel.PropertyChangedEventArgs.Empty);
}
Ключові моменти:
partial class — обов'язково для Source GeneratorsObservableObject — базовий клас з INotifyPropertyChanged (замість нашого BaseViewModel)[ObservableProperty] — атрибут на private полі_firstName → FirstName (автоматично)Source Generator автоматично створює partial methods для кастомної логіки.
Згенеровані partial methods:
// Згенерований код
partial void OnFirstNameChanging(string value);
partial void OnFirstNameChanged(string value);
Використання:
public partial class PersonViewModel : ObservableObject
{
[ObservableProperty]
private string _firstName;
// Викликається ПЕРЕД зміною значення
partial void OnFirstNameChanging(string value)
{
Console.WriteLine($"FirstName змінюється з '{_firstName}' на '{value}'");
}
// Викликається ПІСЛЯ зміни значення
partial void OnFirstNameChanged(string value)
{
Console.WriteLine($"FirstName змінено на '{value}'");
// Додаткова логіка
if (string.IsNullOrWhiteSpace(value))
{
ErrorMessage = "Ім'я обов'язкове";
}
}
}
Переваги:
_firstName) та нового (value) значенняПроблема: FullName залежить від FirstName та LastName.
До (без Toolkit):
public string FirstName
{
get => _firstName;
set
{
if (SetProperty(ref _firstName, value))
{
OnPropertyChanged(nameof(FullName)); // Ручне сповіщення
}
}
}
public string FullName => $"{FirstName} {LastName}";
Після (з Toolkit):
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
private string _firstName;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
private string _lastName;
public string FullName => $"{FirstName} {LastName}";
Що генерується:
public string FirstName
{
get => _firstName;
set
{
if (SetProperty(ref _firstName, value))
{
OnPropertyChanged(nameof(FullName)); // Автоматично!
}
}
}
Переваги:
[NotifyPropertyChangedFor(nameof(Prop1), nameof(Prop2))]public partial class ProductViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TotalPrice))]
private decimal _price;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TotalPrice))]
private int _quantity;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TotalPrice))]
private decimal _discount;
// Обчислювана властивість — оновлюється автоматично
public decimal TotalPrice => Price * Quantity * (1 - Discount / 100);
}
Результат: При зміні Price, Quantity або Discount → TotalPrice оновлюється автоматично.
Атрибут [RelayCommand] генерує ICommand властивості з RelayCommand або AsyncRelayCommand.
До (без Toolkit):
private RelayCommand _saveCommand;
public ICommand SaveCommand => _saveCommand ??= new RelayCommand(Save, CanSave);
private void Save()
{
// Логіка збереження
}
private bool CanSave()
{
return !string.IsNullOrWhiteSpace(FirstName);
}
Після (з Toolkit):
[RelayCommand]
private void Save()
{
// Логіка збереження
}
Що генерується:
private RelayCommand? _saveCommand;
public IRelayCommand SaveCommand => _saveCommand ??= new RelayCommand(Save);
Ключові моменти:
Save() → SaveCommandICommand властивість створюється автоматичноПідхід 1: Окремий метод
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
// Логіка збереження
}
private bool CanSave()
{
return !string.IsNullOrWhiteSpace(FirstName);
}
Підхід 2: Inline через lambda (C# 10+)
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
// Логіка збереження
}
private bool CanSave => !string.IsNullOrWhiteSpace(FirstName);
Проблема: При зміні FirstName потрібно оновити CanExecute для SaveCommand.
До (без Toolkit):
public string FirstName
{
get => _firstName;
set
{
if (SetProperty(ref _firstName, value))
{
((RelayCommand)SaveCommand).NotifyCanExecuteChanged(); // Ручне оновлення
}
}
}
Після (з Toolkit):
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string _firstName;
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
// Логіка збереження
}
private bool CanSave => !string.IsNullOrWhiteSpace(FirstName);
Що генерується:
public string FirstName
{
get => _firstName;
set
{
if (SetProperty(ref _firstName, value))
{
SaveCommand.NotifyCanExecuteChanged(); // Автоматично!
}
}
}
Переваги:
[NotifyCanExecuteChangedFor(nameof(Cmd1), nameof(Cmd2))]Для асинхронних методів:
[RelayCommand]
private async Task LoadDataAsync()
{
IsBusy = true;
try
{
await Task.Delay(2000);
var data = await _apiService.GetDataAsync();
Items = new ObservableCollection<Item>(data);
}
finally
{
IsBusy = false;
}
}
Що генерується:
private AsyncRelayCommand? _loadDataCommand;
public IAsyncRelayCommand LoadDataCommand => _loadDataCommand ??= new AsyncRelayCommand(LoadDataAsync);
З CancellationToken:
[RelayCommand]
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
for (int i = 0; i < 100; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
Progress = i;
}
}
Згенерована команда автоматично підтримує скасування:
// У XAML
<Button Content="Завантажити" Command="{Binding LoadDataCommand}"/>
<Button Content="Скасувати" Command="{Binding LoadDataCommand.CancelCommand}"/>
[RelayCommand]
private void Delete(TodoItem item)
{
Items.Remove(item);
}
Що генерується:
private RelayCommand<TodoItem>? _deleteCommand;
public IRelayCommand<TodoItem> DeleteCommand => _deleteCommand ??= new RelayCommand<TodoItem>(Delete);
XAML:
<Button Content="Видалити"
Command="{Binding DeleteCommand}"
CommandParameter="{Binding}"/>
ObservableValidator — базовий клас для валідації через Data Annotations (замість ручної реалізації INotifyDataErrorInfo).
До (без Toolkit):
public class PersonViewModel : BaseViewModel, INotifyDataErrorInfo
{
private Dictionary<string, List<string>> _errors = new();
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");
}
}
// ... 50+ рядків INotifyDataErrorInfo
}
Після (з Toolkit):
public partial class PersonViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "Email обов'язковий")]
[EmailAddress(ErrorMessage = "Некоректний формат email")]
private string _email;
}
Переваги:
[Required], [MinLength], [EmailAddress]ValidatesOnNotifyDataErrorspublic partial class RegistrationViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "Ім'я обов'язкове")]
[MinLength(3, ErrorMessage = "Мінімум 3 символи")]
[MaxLength(50, ErrorMessage = "Максимум 50 символів")]
private string _firstName;
[ObservableProperty]
[Required]
[EmailAddress(ErrorMessage = "Некоректний email")]
private string _email;
[ObservableProperty]
[Required]
[MinLength(8, ErrorMessage = "Мінімум 8 символів")]
[RegularExpression(@"^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$",
ErrorMessage = "Пароль має містити цифру, малу та велику літеру")]
private string _password;
[ObservableProperty]
[Range(18, 120, ErrorMessage = "Вік від 18 до 120")]
private int _age;
}
public partial class PersonViewModel : ObservableValidator
{
[ObservableProperty]
[Required]
private string _firstName;
[RelayCommand]
private void Save()
{
// Валідувати всі властивості
ValidateAllProperties();
if (HasErrors)
{
// Показати помилки
var errors = GetErrors().Cast<ValidationResult>();
MessageBox.Show(string.Join("\n", errors.Select(e => e.ErrorMessage)));
return;
}
// Зберегти дані
}
}
public partial class PersonViewModel : ObservableValidator
{
[ObservableProperty]
[CustomValidation(typeof(PersonViewModel), nameof(ValidatePassword))]
private string _password;
[ObservableProperty]
private string _confirmPassword;
public static ValidationResult ValidatePassword(string password, ValidationContext context)
{
var instance = (PersonViewModel)context.ObjectInstance;
if (password != instance.ConfirmPassword)
{
return new ValidationResult("Паролі не співпадають");
}
return ValidationResult.Success;
}
}
Розберемо, як працюють Source Generators та що саме генерується.
Крок 1: Увімкнути генерацію файлів
У .csproj:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
Крок 2: Збудувати проєкт
dotnet build
Крок 3: Знайти згенеровані файли
obj/Debug/net8.0/Generated/CommunityToolkit.Mvvm.SourceGenerators/
Або через IDE:
Visual Studio: Dependencies → Analyzers → CommunityToolkit.Mvvm.SourceGenerators → розгорнути
Ваш код:
public partial class PersonViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
private string _firstName;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
private string _lastName;
public string FullName => $"{FirstName} {LastName}";
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
// Логіка збереження
}
private bool CanSave => !string.IsNullOrWhiteSpace(FirstName);
}
Згенерований код (спрощено):
// Згенеровано CommunityToolkit.Mvvm.SourceGenerators
partial class PersonViewModel
{
// Властивість FirstName
public string FirstName
{
get => _firstName;
set
{
if (!EqualityComparer<string>.Default.Equals(_firstName, value))
{
OnFirstNameChanging(value);
OnPropertyChanging(nameof(FirstName));
_firstName = value;
OnFirstNameChanged(value);
OnPropertyChanged(nameof(FirstName));
OnPropertyChanged(nameof(FullName)); // NotifyPropertyChangedFor
}
}
}
partial void OnFirstNameChanging(string value);
partial void OnFirstNameChanged(string value);
// Властивість LastName
public string LastName
{
get => _lastName;
set
{
if (!EqualityComparer<string>.Default.Equals(_lastName, value))
{
OnLastNameChanging(value);
OnPropertyChanging(nameof(LastName));
_lastName = value;
OnLastNameChanged(value);
OnPropertyChanged(nameof(LastName));
OnPropertyChanged(nameof(FullName)); // NotifyPropertyChangedFor
}
}
}
partial void OnLastNameChanging(string value);
partial void OnLastNameChanged(string value);
// Команда SaveCommand
private RelayCommand? _saveCommand;
public IRelayCommand SaveCommand => _saveCommand ??= new RelayCommand(
execute: new Action(Save),
canExecute: new Func<bool>(CanSave)
);
}
Порівняння:
| Аспект | Ваш код | Згенерований код |
|---|---|---|
| Рядків | 15 | 60+ |
| Властивості | 2 поля | 2 повні властивості |
| Команди | 1 метод | 1 ICommand властивість |
| Partial methods | 0 | 4 (Changing/Changed) |
⚡ Compile-time
🔍 IntelliSense
🛡️ Type-safe
📦 Zero dependencies
Розберемо реальний приклад — форма реєстрації.
public class RegistrationViewModel : BaseViewModel, INotifyDataErrorInfo
{
// INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged;
protected 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;
}
// Властивості
private string _email;
public string Email
{
get => _email;
set
{
if (SetProperty(ref _email, value))
{
ValidateEmail();
((RelayCommand)RegisterCommand).NotifyCanExecuteChanged();
}
}
}
private string _password;
public string Password
{
get => _password;
set
{
if (SetProperty(ref _password, value))
{
ValidatePassword();
((RelayCommand)RegisterCommand).NotifyCanExecuteChanged();
}
}
}
// Команди
private RelayCommand _registerCommand;
public ICommand RegisterCommand => _registerCommand ??= new RelayCommand(Register, CanRegister);
private void Register()
{
// Логіка реєстрації
}
private bool CanRegister()
{
return !HasErrors;
}
// INotifyDataErrorInfo (50+ рядків)
private Dictionary<string, List<string>> _errors = new();
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
public bool HasErrors => _errors.Any();
public IEnumerable GetErrors(string propertyName)
{
return _errors.ContainsKey(propertyName) ? _errors[propertyName] : null;
}
private void AddError(string propertyName, string error)
{
if (!_errors.ContainsKey(propertyName))
_errors[propertyName] = new List<string>();
_errors[propertyName].Add(error);
OnErrorsChanged(propertyName);
}
private void ClearErrors(string propertyName)
{
if (_errors.ContainsKey(propertyName))
{
_errors.Remove(propertyName);
OnErrorsChanged(propertyName);
}
}
private void OnErrorsChanged(string propertyName)
{
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
OnPropertyChanged(nameof(HasErrors));
}
// Валідація
private void ValidateEmail()
{
ClearErrors(nameof(Email));
if (string.IsNullOrWhiteSpace(Email))
AddError(nameof(Email), "Email обов'язковий");
else if (!Email.Contains("@"))
AddError(nameof(Email), "Некоректний формат email");
}
private void ValidatePassword()
{
ClearErrors(nameof(Password));
if (string.IsNullOrWhiteSpace(Password))
AddError(nameof(Password), "Пароль обов'язковий");
else if (Password.Length < 8)
AddError(nameof(Password), "Мінімум 8 символів");
}
}
Підрахунок: ~150 рядків коду.
public partial class RegistrationViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "Email обов'язковий")]
[EmailAddress(ErrorMessage = "Некоректний формат email")]
[NotifyCanExecuteChangedFor(nameof(RegisterCommand))]
private string _email;
[ObservableProperty]
[Required(ErrorMessage = "Пароль обов'язковий")]
[MinLength(8, ErrorMessage = "Мінімум 8 символів")]
[NotifyCanExecuteChangedFor(nameof(RegisterCommand))]
private string _password;
[RelayCommand(CanExecute = nameof(CanRegister))]
private void Register()
{
ValidateAllProperties();
if (!HasErrors)
{
// Логіка реєстрації
}
}
private bool CanRegister => !HasErrors;
}
Підрахунок: ~20 рядків коду.
Скорочення: 150 → 20 рядків = 87% менше коду!
| Аспект | Ручна реалізація | Toolkit |
|---|---|---|
| Рядків коду | 150+ | 20 |
| INotifyPropertyChanged | Ручна реалізація (30 рядків) | ObservableObject (0 рядків) |
| INotifyDataErrorInfo | Ручна реалізація (50 рядків) | ObservableValidator (0 рядків) |
| Властивості | 7 рядків на властивість | 1 рядок (атрибут) |
| Команди | 10-15 рядків на команду | 1 рядок (атрибут) |
| Валідація | Ручні методи (10+ рядків) | Data Annotations (1 атрибут) |
| Залежні властивості | Ручне OnPropertyChanged | NotifyPropertyChangedFor |
| CanExecute оновлення | Ручне NotifyCanExecuteChanged | NotifyCanExecuteChangedFor |
Мета: Навчитися використовувати CommunityToolkit.Mvvm замість ручної реалізації.
Завдання:
Переписати PersonViewModel з попередніх статей на Toolkit:
CommunityToolkit.MvvmBaseViewModel на ObservableObject[ObservableProperty]FullNameДо:
public class PersonViewModel : BaseViewModel
{
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
if (SetProperty(ref _firstName, value))
{
OnPropertyChanged(nameof(FullName));
}
}
}
private string _lastName;
public string LastName
{
get => _lastName;
set
{
if (SetProperty(ref _lastName, value))
{
OnPropertyChanged(nameof(FullName));
}
}
}
public string FullName => $"{FirstName} {LastName}";
}
Після (TODO):
public partial class PersonViewModel : ObservableObject
{
// TODO: Додати [ObservableProperty] для _firstName
// TODO: Додати [NotifyPropertyChangedFor(nameof(FullName))]
// TODO: Додати [ObservableProperty] для _lastName
// TODO: Додати [NotifyPropertyChangedFor(nameof(FullName))]
public string FullName => $"{FirstName} {LastName}";
}
Критерії успіху:
ObservableObjectpartial[ObservableProperty]FullName оновлюється автоматично при зміні FirstName або LastNameТести:
[Test]
public void FirstName_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 FirstName_ShouldRaisePropertyChangedForFullName()
{
var vm = new PersonViewModel();
bool fullNameEventRaised = false;
vm.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(PersonViewModel.FullName))
fullNameEventRaised = true;
};
vm.FirstName = "Іван";
Assert.IsTrue(fullNameEventRaised);
}
Мета: Створити повний CRUD-застосунок з Toolkit.
Завдання:
Створіть застосунок для управління списком завдань (Todo):
TodoItem (Id, Title, IsCompleted, DueDate)ObservableCollection<TodoItem> ItemsTodoItem SelectedItemstring NewTodoTitleAddCommand — додати нове завданняDeleteCommand — видалити вибране завданняToggleCompleteCommand — змінити статус завданняClearCompletedCommand — видалити всі завершені[ObservableProperty] для всіх властивостей[RelayCommand] для всіх команд[NotifyCanExecuteChangedFor] для автоматичного оновлення CanExecuteПідказка:
public partial class TodoViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<TodoItem> _items = new();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
[NotifyCanExecuteChangedFor(nameof(ToggleCompleteCommand))]
private TodoItem _selectedItem;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string _newTodoTitle;
[RelayCommand(CanExecute = nameof(CanAdd))]
private void Add()
{
// TODO: Додати нове завдання
Items.Add(new TodoItem { Title = NewTodoTitle });
NewTodoTitle = string.Empty;
}
private bool CanAdd => !string.IsNullOrWhiteSpace(NewTodoTitle);
[RelayCommand(CanExecute = nameof(CanDelete))]
private void Delete()
{
// TODO: Видалити вибране завдання
}
private bool CanDelete => SelectedItem != null;
// TODO: Додати ToggleCompleteCommand
// TODO: Додати ClearCompletedCommand
}
Критерії успіху:
Мета: Реалізувати валідацію через Data Annotations.
Завдання:
Створіть форму реєстрації з повною валідацією:
Email — обов'язковий, формат emailPassword — мінімум 8 символів, має містити цифру та велику літеруConfirmPassword — має співпадати з PasswordAge — від 18 до 120AcceptTerms — обов'язковий (checkbox)RegisterCommand — активна тільки якщо немає помилокClearCommand — очистити формуПідказка:
public partial class RegistrationViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "Email обов'язковий")]
[EmailAddress(ErrorMessage = "Некоректний формат email")]
[NotifyCanExecuteChangedFor(nameof(RegisterCommand))]
private string _email;
[ObservableProperty]
[Required(ErrorMessage = "Пароль обов'язковий")]
[MinLength(8, ErrorMessage = "Мінімум 8 символів")]
[RegularExpression(@"^(?=.*\d)(?=.*[A-Z]).+$",
ErrorMessage = "Пароль має містити цифру та велику літеру")]
[NotifyPropertyChangedFor(nameof(PasswordsMatch))]
[NotifyCanExecuteChangedFor(nameof(RegisterCommand))]
private string _password;
[ObservableProperty]
[Required(ErrorMessage = "Підтвердження паролю обов'язкове")]
[CustomValidation(typeof(RegistrationViewModel), nameof(ValidateConfirmPassword))]
[NotifyCanExecuteChangedFor(nameof(RegisterCommand))]
private string _confirmPassword;
[ObservableProperty]
[Range(18, 120, ErrorMessage = "Вік від 18 до 120")]
[NotifyCanExecuteChangedFor(nameof(RegisterCommand))]
private int _age;
[ObservableProperty]
[Required(ErrorMessage = "Потрібно прийняти умови")]
[NotifyCanExecuteChangedFor(nameof(RegisterCommand))]
private bool _acceptTerms;
public bool PasswordsMatch => Password == ConfirmPassword;
public static ValidationResult ValidateConfirmPassword(string confirmPassword, ValidationContext context)
{
var instance = (RegistrationViewModel)context.ObjectInstance;
if (confirmPassword != instance.Password)
{
return new ValidationResult("Паролі не співпадають");
}
return ValidationResult.Success;
}
[RelayCommand(CanExecute = nameof(CanRegister))]
private void Register()
{
ValidateAllProperties();
if (!HasErrors)
{
// Логіка реєстрації
MessageBox.Show("Реєстрація успішна!");
}
}
private bool CanRegister => !HasErrors && AcceptTerms;
[RelayCommand]
private void Clear()
{
Email = string.Empty;
Password = string.Empty;
ConfirmPassword = string.Empty;
Age = 0;
AcceptTerms = false;
ClearErrors();
}
}
XAML:
<StackPanel Margin="20">
<TextBlock Text="Email:"/>
<TextBox Text="{Binding Email, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<TextBlock Text="Пароль:" Margin="0,10,0,0"/>
<PasswordBox x:Name="passwordBox"/>
<TextBlock Text="Підтвердження паролю:" Margin="0,10,0,0"/>
<TextBox Text="{Binding ConfirmPassword, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<TextBlock Text="Вік:" Margin="0,10,0,0"/>
<TextBox Text="{Binding Age, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
<CheckBox Content="Приймаю умови використання"
IsChecked="{Binding AcceptTerms}"
Margin="0,10,0,0"/>
<StackPanel Orientation="Horizontal" Margin="0,20,0,0">
<Button Content="Зареєструватися"
Command="{Binding RegisterCommand}"
Width="150"/>
<Button Content="Очистити"
Command="{Binding ClearCommand}"
Width="100"
Margin="10,0,0,0"/>
</StackPanel>
</StackPanel>
Критерії успіху:
Тести:
[Test]
public void Email_ShouldHaveError_WhenInvalid()
{
var vm = new RegistrationViewModel();
vm.Email = "invalid-email";
vm.ValidateAllProperties();
Assert.IsTrue(vm.HasErrors);
var errors = vm.GetErrors(nameof(vm.Email)).Cast<ValidationResult>();
Assert.IsTrue(errors.Any(e => e.ErrorMessage.Contains("Некоректний формат")));
}
[Test]
public void Password_ShouldHaveError_WhenTooShort()
{
var vm = new RegistrationViewModel();
vm.Password = "short";
vm.ValidateAllProperties();
Assert.IsTrue(vm.HasErrors);
}
[Test]
public void RegisterCommand_ShouldBeDisabled_WhenHasErrors()
{
var vm = new RegistrationViewModel();
vm.Email = "invalid";
vm.ValidateAllProperties();
Assert.IsFalse(vm.RegisterCommand.CanExecute(null));
}
CommunityToolkit.Mvvm автоматизує MVVM через Source Generators, скорочуючи код у 5-10 разів.
Ключові висновки:
🎯 [ObservableProperty]
⚡ [RelayCommand]
🔗 [NotifyPropertyChangedFor]
🔔 [NotifyCanExecuteChangedFor]
✅ ObservableValidator
🔧 Source Generators
Переваги Toolkit:
Недоліки:
Що далі?
Commands — Від event handlers до декларативних команд
ICommand інтерфейс, RelayCommand реалізація, CanExecute для автоматичного IsEnabled, CommandParameter, AsyncRelayCommand та KeyBindings
Messenger Pattern — Комунікація між ViewModel без прямих посилань
WeakReferenceMessenger для loose coupling між ViewModel — надсилання повідомлень, ValueChangedMessage, RequestMessage та уникнення memory leaks