У попередніх статтях ми створили BaseViewModel з INotifyPropertyChanged та валідацією. Але залишилася одна проблема: як зв'язати дії користувача (натискання кнопок, меню) з логікою у ViewModel?
Традиційний підхід WinForms — event handlers у code-behind:
// MainWindow.xaml.cs
public partial class MainWindow : Window
{
private MainViewModel _viewModel;
public MainWindow()
{
InitializeComponent();
_viewModel = new MainViewModel();
DataContext = _viewModel;
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
// ❌ Логіка у code-behind — порушення MVVM
_viewModel.Save();
}
private void DeleteButton_Click(object sender, RoutedEventArgs e)
{
// ❌ Знову логіка у code-behind
_viewModel.Delete();
}
private void RefreshButton_Click(object sender, RoutedEventArgs e)
{
// ❌ І знову
_viewModel.Refresh();
}
}
Проблеми:
SaveButton_Click?Рішення: ICommand — інтерфейс для декларативного зв'язування дій з ViewModel.
Розберемо детально, чому event handlers — це антипатерн для MVVM.
Золоте правило MVVM: ViewModel не має посилань на View. Але event handlers живуть у code-behind (частина View).
// XAML
<Button Content="Зберегти" Click="SaveButton_Click"/>
// Code-behind (частина View)
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
// Виклик ViewModel
_viewModel.Save();
}
Проблема: Логіка розділена між View (code-behind) та ViewModel. Це не чистий MVVM.
Проблема: Як написати unit-test для event handler?
[Test]
public void SaveButton_ShouldSaveData()
{
// ❌ Як викликати SaveButton_Click без створення UI?
var window = new MainWindow();
// ❌ Як симулювати клік?
window.SaveButton_Click(null, null);
// ❌ Як перевірити результат?
}
Наслідки:
Проблема: Як відключити кнопку, коли дія неможлива?
// ❌ Ручне управління IsEnabled
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
// При кожній зміні тексту — перевіряти чи можна зберегти
SaveButton.IsEnabled = !string.IsNullOrWhiteSpace(txtName.Text);
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
_viewModel.Save();
}
Проблеми:
IsEnabled у кількох місцяхIsEnabledПроблема: Той самий патерн для кожної кнопки.
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
_viewModel.Save();
}
private void DeleteButton_Click(object sender, RoutedEventArgs e)
{
_viewModel.Delete();
}
private void RefreshButton_Click(object sender, RoutedEventArgs e)
{
_viewModel.Refresh();
}
private void ExportButton_Click(object sender, RoutedEventArgs e)
{
_viewModel.Export();
}
// ... ще 10 кнопок
Якщо у додатку 20 кнопок — 20 event handlers з однаковим патерном.
WPF надає інтерфейс ICommand для декларативного зв'язування дій з ViewModel.
Інтерфейс:
public interface ICommand
{
// Виконати команду
void Execute(object? parameter);
// Чи можна виконати команду (для IsEnabled)
bool CanExecute(object? parameter);
// Подія при зміні CanExecute
event EventHandler CanExecuteChanged;
}
Як це працює:
Ключові моменти:
IsEnabled)CanExecuteXAML з Command:
<Button Content="Зберегти" Command="{Binding SaveCommand}"/>
ViewModel:
public class MainViewModel : BaseViewModel
{
public ICommand SaveCommand { get; }
public MainViewModel()
{
SaveCommand = new RelayCommand(Save, CanSave);
}
private void Save()
{
// Логіка збереження
}
private bool CanSave()
{
// Чи можна зберегти?
return !string.IsNullOrWhiteSpace(Name);
}
}
Переваги:
IsEnabled через CanExecuteSaveCommand.Execute(null)Порівняння:
| Аспект | Event Handler | ICommand |
|---|---|---|
| Логіка | Code-behind (View) | ViewModel |
| IsEnabled | Ручне управління | Автоматичне через CanExecute |
| Тестування | Складно (потрібен UI) | Легко (без UI) |
| XAML | Click="SaveButton_Click" | Command="{Binding SaveCommand}" |
| Keyboard shortcuts | Складно | Легко через InputBindings |
WPF не надає готової реалізації ICommand. Потрібно створити власну або використати бібліотеку. Почнемо з ручної реалізації для розуміння механізму.
Мета: Створити універсальний клас, що реалізує ICommand через делегати.
using System;
using System.Windows.Input;
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool> _canExecute;
// Конструктор
public RelayCommand(Action execute, Func<bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
// Подія при зміні CanExecute
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
// Чи можна виконати команду
public bool CanExecute(object parameter)
{
return _canExecute == null || _canExecute();
}
// Виконати команду
public void Execute(object parameter)
{
_execute();
}
}
Ключові моменти:
Action для Execute, Func<bool> для CanExecuteCanExecute при зміні фокусу, натисканні клавішViewModel:
public class PersonViewModel : BaseViewModel
{
private string _name;
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
private int _age;
public int Age
{
get => _age;
set => SetProperty(ref _age, value);
}
public ICommand SaveCommand { get; }
public ICommand ClearCommand { get; }
public PersonViewModel()
{
// Команда з CanExecute
SaveCommand = new RelayCommand(Save, CanSave);
// Команда без CanExecute (завжди доступна)
ClearCommand = new RelayCommand(Clear);
}
private void Save()
{
// Логіка збереження
MessageBox.Show($"Збережено: {Name}, {Age} років");
}
private bool CanSave()
{
// Можна зберегти тільки якщо ім'я не пусте та вік > 0
return !string.IsNullOrWhiteSpace(Name) && Age > 0;
}
private void Clear()
{
Name = string.Empty;
Age = 0;
}
}
XAML:
<StackPanel Margin="20">
<TextBlock Text="Ім'я:"/>
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Вік:" Margin="0,10,0,0"/>
<TextBox Text="{Binding Age, UpdateSourceTrigger=PropertyChanged}"/>
<StackPanel Orientation="Horizontal" Margin="0,20,0,0">
<!-- Кнопка активна тільки якщо CanSave повертає true -->
<Button Content="Зберегти" Command="{Binding SaveCommand}" Width="100"/>
<!-- Кнопка завжди активна -->
<Button Content="Очистити" Command="{Binding ClearCommand}" Width="100" Margin="10,0,0,0"/>
</StackPanel>
</StackPanel>
Що відбувається:
Name → PropertyChanged → WPF перевіряє CanExecute → оновлює IsEnabledExecute → виконується метод SaveЩо таке CommandManager.RequerySuggested?
Це подія WPF, що викликається автоматично при:
WPF використовує цю подію для автоматичної перевірки CanExecute всіх команд.
Діаграма:
Переваги:
RaiseCanExecuteChangedНедоліки:
Іноді потрібно вручну сповістити про зміну CanExecute:
public class RelayCommand : ICommand
{
// ... попередній код
// Метод для ручного виклику CanExecuteChanged
public void RaiseCanExecuteChanged()
{
CommandManager.InvalidateRequerySuggested();
}
}
Використання:
public class MainViewModel : BaseViewModel
{
private bool _isBusy;
public bool IsBusy
{
get => _isBusy;
set
{
if (SetProperty(ref _isBusy, value))
{
// Вручну сповістити про зміну CanExecute
((RelayCommand)SaveCommand).RaiseCanExecuteChanged();
}
}
}
public ICommand SaveCommand { get; }
public MainViewModel()
{
SaveCommand = new RelayCommand(Save, CanSave);
}
private bool CanSave()
{
return !IsBusy; // Не можна зберегти під час завантаження
}
}
Іноді потрібно передати параметр з View у команду. Наприклад, видалити конкретний елемент зі списку.
public class RelayCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Func<T, bool> _canExecute;
public RelayCommand(Action<T> execute, Func<T, bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public bool CanExecute(object parameter)
{
return _canExecute == null || _canExecute((T)parameter);
}
public void Execute(object parameter)
{
_execute((T)parameter);
}
}
ViewModel:
public class TodoViewModel : BaseViewModel
{
public ObservableCollection<TodoItem> Items { get; set; }
public ICommand DeleteCommand { get; }
public TodoViewModel()
{
Items = new ObservableCollection<TodoItem>
{
new TodoItem { Title = "Завдання 1" },
new TodoItem { Title = "Завдання 2" },
new TodoItem { Title = "Завдання 3" }
};
DeleteCommand = new RelayCommand<TodoItem>(Delete, CanDelete);
}
private void Delete(TodoItem item)
{
Items.Remove(item);
}
private bool CanDelete(TodoItem item)
{
return item != null;
}
}
XAML:
<ListBox ItemsSource="{Binding Items}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Title}" Width="200"/>
<!-- CommandParameter передає поточний елемент -->
<Button Content="Видалити"
Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
Ключові моменти:
CommandParameter="{Binding}" — передає поточний TodoItem у командуRelativeSource={RelativeSource AncestorType=ListBox} — знаходить DataContext батьківського ListBox (бо всередині DataTemplate контекст — це TodoItem)Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20">
<TextBlock Text="Список завдань:" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
<ListBox Height="200">
<ListBoxItem>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Завдання 1" Width="200" VerticalAlignment="Center"/>
<Button Content="Видалити"
Command="{Binding DeleteCommand}"
CommandParameter="Item1"
Padding="10,5"/>
</StackPanel>
</ListBoxItem>
<ListBoxItem>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Завдання 2" Width="200" VerticalAlignment="Center"/>
<Button Content="Видалити"
Command="{Binding DeleteCommand}"
CommandParameter="Item2"
Padding="10,5"/>
</StackPanel>
</ListBoxItem>
<ListBoxItem>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Завдання 3" Width="200" VerticalAlignment="Center"/>
<Button Content="Видалити"
Command="{Binding DeleteCommand}"
CommandParameter="Item3"
Padding="10,5"/>
</StackPanel>
</ListBoxItem>
</ListBox>
<TextBlock Text="Натисніть 'Видалити' для видалення завдання"
Margin="0,10,0,0"
FontStyle="Italic"
Foreground="Gray"/>
</StackPanel>
// Code-behind для демонстрації
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new TodoViewModel();
}
}
public class TodoViewModel
{
public ICommand DeleteCommand { get; }
public TodoViewModel()
{
DeleteCommand = new RelayCommand<string>(
item => { /* Видалення */ },
item => item != null
);
}
}
ObservableCollection елементи видалятимуться динамічно.Багато операцій у сучасних додатках асинхронні: завантаження даних з API, збереження у БД, експорт файлів. Для цього потрібна асинхронна версія RelayCommand.
Проблема: Якщо Execute виконує довгу операцію, UI зависає.
public ICommand LoadDataCommand { get; }
public MainViewModel()
{
LoadDataCommand = new RelayCommand(LoadData);
}
private void LoadData()
{
// ❌ Синхронна операція — UI зависає на 5 секунд
Thread.Sleep(5000);
var data = _apiService.GetData();
Items = new ObservableCollection<Item>(data);
}
Наслідки:
using System;
using System.Threading.Tasks;
using System.Windows.Input;
public class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool> _canExecute;
private bool _isExecuting;
public AsyncRelayCommand(Func<Task> execute, Func<bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public bool CanExecute(object parameter)
{
// Не можна виконати, якщо вже виконується
return !_isExecuting && (_canExecute == null || _canExecute());
}
public async void Execute(object parameter)
{
if (_isExecuting)
return;
_isExecuting = true;
RaiseCanExecuteChanged();
try
{
await _execute();
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
public void RaiseCanExecuteChanged()
{
CommandManager.InvalidateRequerySuggested();
}
}
Ключові моменти:
Func<Task> — делегат повертає Task для асинхронного виконання_isExecuting — прапорець для запобігання повторному виконаннюasync void Execute — WPF вимагає void, але всередині використовуємо awaittry-finally — гарантуємо скидання _isExecuting навіть при помилціViewModel:
public class MainViewModel : BaseViewModel
{
private bool _isBusy;
public bool IsBusy
{
get => _isBusy;
set => SetProperty(ref _isBusy, value);
}
private ObservableCollection<Item> _items;
public ObservableCollection<Item> Items
{
get => _items;
set => SetProperty(ref _items, value);
}
public ICommand LoadDataCommand { get; }
public MainViewModel()
{
LoadDataCommand = new AsyncRelayCommand(LoadDataAsync);
}
private async Task LoadDataAsync()
{
IsBusy = true;
try
{
// Симуляція завантаження з API
await Task.Delay(2000);
var data = await _apiService.GetDataAsync();
Items = new ObservableCollection<Item>(data);
}
catch (Exception ex)
{
// Обробка помилки
MessageBox.Show($"Помилка: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
}
XAML з індикатором завантаження:
<Grid>
<StackPanel>
<Button Content="Завантажити дані"
Command="{Binding LoadDataCommand}"
IsEnabled="{Binding IsBusy, Converter={StaticResource InverseBooleanConverter}}"/>
<ListBox ItemsSource="{Binding Items}" Margin="0,10,0,0"/>
</StackPanel>
<!-- Індикатор завантаження -->
<Grid Background="#80000000" Visibility="{Binding IsBusy, Converter={StaticResource BooleanToVisibilityConverter}}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<ProgressBar IsIndeterminate="True" Width="200" Height="20"/>
<TextBlock Text="Завантаження..."
Foreground="White"
Margin="0,10,0,0"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</Grid>
Що відбувається:
IsBusy = true → кнопка відключається, з'являється індикаторIsBusy = false → кнопка активується, індикатор зникаєПроблема: Якщо у Execute виникає виняток, він "проковтується" через async void.
Рішення: Обробляти винятки всередині Execute:
public class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Action<Exception> _onException;
public AsyncRelayCommand(Func<Task> execute, Action<Exception> onException = null)
{
_execute = execute;
_onException = onException;
}
public async void Execute(object parameter)
{
if (_isExecuting)
return;
_isExecuting = true;
RaiseCanExecuteChanged();
try
{
await _execute();
}
catch (Exception ex)
{
// Викликати callback для обробки помилки
_onException?.Invoke(ex);
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
}
Використання:
public MainViewModel()
{
LoadDataCommand = new AsyncRelayCommand(
execute: LoadDataAsync,
onException: ex => ErrorMessage = ex.Message
);
}
private string _errorMessage;
public string ErrorMessage
{
get => _errorMessage;
set => SetProperty(ref _errorMessage, value);
}
Команди можна прив'язати не тільки до кнопок, а й до клавіатурних скорочень.
XAML:
<Window x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Прив'язка клавіатурних скорочень -->
<Window.InputBindings>
<KeyBinding Key="S" Modifiers="Ctrl" Command="{Binding SaveCommand}"/>
<KeyBinding Key="N" Modifiers="Ctrl" Command="{Binding NewCommand}"/>
<KeyBinding Key="O" Modifiers="Ctrl" Command="{Binding OpenCommand}"/>
<KeyBinding Key="F5" Command="{Binding RefreshCommand}"/>
<KeyBinding Key="Delete" Command="{Binding DeleteCommand}"/>
</Window.InputBindings>
<StackPanel Margin="20">
<Button Content="Зберегти (Ctrl+S)" Command="{Binding SaveCommand}"/>
<Button Content="Новий (Ctrl+N)" Command="{Binding NewCommand}" Margin="0,10,0,0"/>
<Button Content="Відкрити (Ctrl+O)" Command="{Binding OpenCommand}" Margin="0,10,0,0"/>
<Button Content="Оновити (F5)" Command="{Binding RefreshCommand}" Margin="0,10,0,0"/>
</StackPanel>
</Window>
Переваги:
CanExecute працює для обохXAML:
<Window.InputBindings>
<!-- Подвійний клік миші -->
<MouseBinding Gesture="LeftDoubleClick" Command="{Binding OpenCommand}"/>
<!-- Клік правою кнопкою -->
<MouseBinding MouseAction="RightClick" Command="{Binding ShowContextMenuCommand}"/>
</Window.InputBindings>
XAML:
<ListBox ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem}">
<ListBox.InputBindings>
<!-- Delete для видалення вибраного елемента -->
<KeyBinding Key="Delete" Command="{Binding DeleteCommand}" CommandParameter="{Binding SelectedItem}"/>
<!-- Enter для редагування -->
<KeyBinding Key="Enter" Command="{Binding EditCommand}" CommandParameter="{Binding SelectedItem}"/>
</ListBox.InputBindings>
</ListBox>
Для студентів зі слабким розумінням ООП — коротке нагадування ключових концепцій, що використовуються у Commands.
Що таке делегат?
Делегат — це посилання на метод. Можна передати метод як параметр іншому методу.
// Делегат Action — метод без повернення значення
Action greet = () => Console.WriteLine("Привіт!");
greet(); // Виклик методу через делегат
// Делегат Action<T> — метод з параметром
Action<string> greetPerson = name => Console.WriteLine($"Привіт, {name}!");
greetPerson("Іван");
// Делегат Func<T> — метод з поверненням значення
Func<bool> canSave = () => !string.IsNullOrWhiteSpace(Name);
bool result = canSave();
Чому це важливо для Commands?
public class RelayCommand : ICommand
{
private readonly Action _execute; // Делегат для Execute
private readonly Func<bool> _canExecute; // Делегат для CanExecute
public RelayCommand(Action execute, Func<bool> canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public void Execute(object parameter)
{
_execute(); // Виклик делегата
}
}
Використання:
// Передаємо методи як делегати
SaveCommand = new RelayCommand(Save, CanSave);
// Save та CanSave — це методи, але передаються як делегати
private void Save() { /* ... */ }
private bool CanSave() { /* ... */ }
Аналогія: Делегат — це як пульт дистанційного керування. Ви не знаєте, що всередині телевізора, але можете викликати дію (увімкнути, вимкнути) через пульт.
Що таке інтерфейс?
Інтерфейс — це контракт, який клас зобов'язується виконати.
// Інтерфейс — контракт
public interface ICommand
{
void Execute(object parameter);
bool CanExecute(object parameter);
event EventHandler CanExecuteChanged;
}
// Клас, що виконує контракт
public class RelayCommand : ICommand
{
// Реалізація контракту
public void Execute(object parameter) { /* ... */ }
public bool CanExecute(object parameter) { /* ... */ }
public event EventHandler CanExecuteChanged;
}
Чому це важливо для Commands?
ICommandICommand, може бути командоюRelayCommand, AsyncRelayCommand, DelegateCommand — всі реалізують ICommandАналогія: Інтерфейс — це як розетка. Будь-який пристрій з відповідною вилкою (реалізацією інтерфейсу) може підключитися.
Що таке async/await?
async/await — це механізм для асинхронного виконання коду без блокування UI.
// Синхронний метод — блокує UI
private void LoadData()
{
Thread.Sleep(5000); // ❌ UI зависає на 5 секунд
var data = _apiService.GetData();
}
// Асинхронний метод — не блокує UI
private async Task LoadDataAsync()
{
await Task.Delay(5000); // ✅ UI не зависає
var data = await _apiService.GetDataAsync();
}
Чому це важливо для Commands?
Аналогія: Синхронний код — це як стояти в черзі у банку. Асинхронний код — це як замовити піцу онлайн і займатися своїми справами, поки вона готується.
Мета: Навчитися створювати команди з автоматичним управлінням IsEnabled.
Завдання:
Створіть форму для введення даних користувача:
FirstName (string)LastName (string)Email (string)SaveCommand — зберігає даніCanExecute — повертає true тільки якщо всі поля заповненіКритерії успіху:
Підказка:
public class UserViewModel : BaseViewModel
{
private string _firstName;
public string FirstName
{
get => _firstName;
set => SetProperty(ref _firstName, value);
}
// TODO: Додати LastName, Email
public ICommand SaveCommand { get; }
public UserViewModel()
{
SaveCommand = new RelayCommand(Save, CanSave);
}
private void Save()
{
MessageBox.Show($"Збережено: {FirstName} {LastName}, {Email}");
}
private bool CanSave()
{
// TODO: Перевірити, чи всі поля заповнені
return !string.IsNullOrWhiteSpace(FirstName) &&
!string.IsNullOrWhiteSpace(LastName) &&
!string.IsNullOrWhiteSpace(Email);
}
}
Тести:
[Test]
public void SaveCommand_ShouldBeDisabled_WhenFieldsEmpty()
{
var vm = new UserViewModel();
Assert.IsFalse(vm.SaveCommand.CanExecute(null));
}
[Test]
public void SaveCommand_ShouldBeEnabled_WhenAllFieldsFilled()
{
var vm = new UserViewModel
{
FirstName = "Іван",
LastName = "Петренко",
Email = "ivan@example.com"
};
Assert.IsTrue(vm.SaveCommand.CanExecute(null));
}
[Test]
public void SaveCommand_ShouldExecute_WhenEnabled()
{
var vm = new UserViewModel
{
FirstName = "Іван",
LastName = "Петренко",
Email = "ivan@example.com"
};
// Не повинно кинути виняток
vm.SaveCommand.Execute(null);
}
Мета: Реалізувати повний CRUD (Create, Read, Update, Delete) через команди.
Завдання:
Створіть застосунок для управління списком контактів:
Contact (Id, Name, Phone, Email)ObservableCollection<Contact> ContactsContact SelectedContactAddCommand — додати новий контактEditCommand — редагувати вибраний контактDeleteCommand — видалити вибраний контактRefreshCommand — оновити списокEditCommand — активна тільки якщо вибрано контактDeleteCommand — активна тільки якщо вибрано контактAddCommand — завжди активнаКритерії успіху:
Підказка для ViewModel:
public class ContactsViewModel : BaseViewModel
{
public ObservableCollection<Contact> Contacts { get; set; }
private Contact _selectedContact;
public Contact SelectedContact
{
get => _selectedContact;
set => SetProperty(ref _selectedContact, value);
}
public ICommand AddCommand { get; }
public ICommand EditCommand { get; }
public ICommand DeleteCommand { get; }
public ICommand RefreshCommand { get; }
public ContactsViewModel()
{
Contacts = new ObservableCollection<Contact>();
AddCommand = new RelayCommand(Add);
EditCommand = new RelayCommand(Edit, CanEdit);
DeleteCommand = new RelayCommand(Delete, CanDelete);
RefreshCommand = new RelayCommand(Refresh);
LoadData();
}
private void Add()
{
var newContact = new Contact { Name = "Новий контакт" };
Contacts.Add(newContact);
SelectedContact = newContact;
}
private void Edit()
{
// Логіка редагування
}
private bool CanEdit()
{
return SelectedContact != null;
}
private void Delete()
{
if (SelectedContact != null)
{
Contacts.Remove(SelectedContact);
}
}
private bool CanDelete()
{
return SelectedContact != null;
}
private void Refresh()
{
LoadData();
}
private void LoadData()
{
// Завантаження даних
}
}
Тести:
[Test]
public void AddCommand_ShouldAddNewContact()
{
var vm = new ContactsViewModel();
int initialCount = vm.Contacts.Count;
vm.AddCommand.Execute(null);
Assert.AreEqual(initialCount + 1, vm.Contacts.Count);
}
[Test]
public void DeleteCommand_ShouldBeDisabled_WhenNoSelection()
{
var vm = new ContactsViewModel();
vm.SelectedContact = null;
Assert.IsFalse(vm.DeleteCommand.CanExecute(null));
}
[Test]
public void DeleteCommand_ShouldRemoveContact()
{
var vm = new ContactsViewModel();
var contact = new Contact { Name = "Test" };
vm.Contacts.Add(contact);
vm.SelectedContact = contact;
vm.DeleteCommand.Execute(null);
Assert.IsFalse(vm.Contacts.Contains(contact));
}
Мета: Реалізувати асинхронну команду з індикатором завантаження та можливістю скасування.
Завдання:
Створіть застосунок для завантаження файлів:
LoadDataCommand (AsyncRelayCommand) — завантажує дані з APICancelCommand — скасовує завантаженняIsBusy — чи відбувається завантаженняProgress — прогрес завантаження (0-100)StatusMessage — повідомлення про статусКритерії успіху:
Підказка для AsyncRelayCommand з CancellationToken:
public class AsyncRelayCommand : ICommand
{
private readonly Func<CancellationToken, Task> _execute;
private CancellationTokenSource _cts;
private bool _isExecuting;
public AsyncRelayCommand(Func<CancellationToken, Task> execute)
{
_execute = execute;
}
public bool CanExecute(object parameter)
{
return !_isExecuting;
}
public async void Execute(object parameter)
{
if (_isExecuting)
return;
_isExecuting = true;
_cts = new CancellationTokenSource();
RaiseCanExecuteChanged();
try
{
await _execute(_cts.Token);
}
catch (OperationCanceledException)
{
// Завантаження скасовано
}
finally
{
_isExecuting = false;
_cts = null;
RaiseCanExecuteChanged();
}
}
public void Cancel()
{
_cts?.Cancel();
}
// ... інші методи
}
Підказка для ViewModel:
public class DownloadViewModel : BaseViewModel
{
private bool _isBusy;
public bool IsBusy
{
get => _isBusy;
set => SetProperty(ref _isBusy, value);
}
private int _progress;
public int Progress
{
get => _progress;
set => SetProperty(ref _progress, value);
}
private string _statusMessage;
public string StatusMessage
{
get => _statusMessage;
set => SetProperty(ref _statusMessage, value);
}
public AsyncRelayCommand LoadDataCommand { get; }
public ICommand CancelCommand { get; }
public DownloadViewModel()
{
LoadDataCommand = new AsyncRelayCommand(LoadDataAsync);
CancelCommand = new RelayCommand(Cancel, CanCancel);
}
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
IsBusy = true;
Progress = 0;
StatusMessage = "Завантаження...";
try
{
for (int i = 0; i <= 100; i += 10)
{
// Перевірка скасування
cancellationToken.ThrowIfCancellationRequested();
// Симуляція завантаження
await Task.Delay(500, cancellationToken);
Progress = i;
StatusMessage = $"Завантажено {i}%";
}
StatusMessage = "Завантаження завершено!";
}
catch (OperationCanceledException)
{
StatusMessage = "Завантаження скасовано";
Progress = 0;
}
finally
{
IsBusy = false;
}
}
private void Cancel()
{
LoadDataCommand.Cancel();
}
private bool CanCancel()
{
return IsBusy;
}
}
Тести:
[Test]
public async Task LoadDataCommand_ShouldUpdateProgress()
{
var vm = new DownloadViewModel();
vm.LoadDataCommand.Execute(null);
await Task.Delay(1000);
Assert.IsTrue(vm.Progress > 0);
}
[Test]
public async Task CancelCommand_ShouldCancelLoading()
{
var vm = new DownloadViewModel();
vm.LoadDataCommand.Execute(null);
await Task.Delay(500);
vm.CancelCommand.Execute(null);
await Task.Delay(500);
Assert.IsFalse(vm.IsBusy);
Assert.IsTrue(vm.StatusMessage.Contains("скасовано"));
}
Commands — це декларативний спосіб зв'язування дій користувача з логікою ViewModel без event handlers у code-behind.
Ключові висновки:
🎯 ICommand інтерфейс
⚡ RelayCommand
📦 CommandParameter
⏳ AsyncRelayCommand
⌨️ InputBindings
✅ Testability
Переваги Commands:
Недоліки:
Що далі?
ViewModel Implementation — Від BaseViewModel до валідації
Практична реалізація ViewModel — створення BaseViewModel, SetProperty<T>, обчислювані властивості, валідація через INotifyDataErrorInfo та DesignTime дані
MVVM Toolkit — MVVM без boilerplate через Source Generators
CommunityToolkit.Mvvm для автоматизації MVVM — [ObservableProperty], [RelayCommand], [NotifyPropertyChangedFor], ObservableValidator та Source Generators