У попередній статті ми створили форму з Data Binding:
<TextBox Text="{Binding FirstName}"/>
<TextBlock Text="{Binding FirstName}"/>
Все працювало чудово — зміни у TextBox автоматично оновлювали модель. Але спробуймо змінити модель з коду:
private void ChangeFromCode_Click(object sender, RoutedEventArgs e)
{
_contact.FirstName = "Новий текст";
// Чому TextBox та TextBlock не оновилися? 🤔
}
Проблема: UI не оновлюється при зміні властивості з коду. TextBox та TextBlock все ще показують старе значення.
Чому так відбувається? Тому що наш POCO-клас Contact не повідомляє WPF Binding Engine про зміни. WPF не знає, що FirstName змінився, тому не оновлює UI.
Рішення — INotifyPropertyChanged — інтерфейс, що дозволяє класу повідомляти про зміни властивостей.
Розберемо детально, чому POCO-класи не працюють для повноцінного Data Binding.
Створимо просту форму:
<StackPanel Margin="20">
<TextBlock Text="Введіть ім'я:"/>
<TextBox Text="{Binding FirstName}" Margin="0,0,0,10"/>
<TextBlock Text="Поточне ім'я:"/>
<TextBlock Text="{Binding FirstName}" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
<Button Content="Змінити на 'Олександр'" Click="Change_Click"/>
</StackPanel>
POCO-модель:
public class Person
{
public string FirstName { get; set; }
}
Code-behind:
public partial class MainWindow : Window
{
private Person _person;
public MainWindow()
{
InitializeComponent();
_person = new Person { FirstName = "Іван" };
DataContext = _person;
}
private void Change_Click(object sender, RoutedEventArgs e)
{
// Змінюємо властивість
_person.FirstName = "Олександр";
// ❌ UI не оновлюється!
// TextBox та TextBlock все ще показують "Іван"
}
}
Проблема: Binding Engine не знає, що FirstName змінився, тому що ніхто йому не сказав.
Можна примусово оновити DataContext:
private void Change_Click(object sender, RoutedEventArgs e)
{
_person.FirstName = "Олександр";
// Примусово оновлюємо DataContext
DataContext = null;
DataContext = _person;
// ✅ Тепер UI оновлюється
}
Але це антипатерн:
❌ Неефективно
❌ Втрата стану
❌ Не масштабується
INotifyPropertyChanged — це інтерфейс з простору імен System.ComponentModel, що дозволяє класу повідомляти про зміни властивостей.
Для студентів зі слабким розумінням ООП — коротке пояснення.
Інтерфейс — це контракт, який клас зобов'язується виконати. Інтерфейс визначає що клас має робити, але не як.
// Інтерфейс — контракт
public interface INotifyPropertyChanged
{
// Подія, яку клас має викликати при зміні властивості
event PropertyChangedEventHandler PropertyChanged;
}
// Клас, що виконує контракт
public class Person : INotifyPropertyChanged
{
// Реалізація контракту — додаємо подію
public event PropertyChangedEventHandler PropertyChanged;
// Тепер клас "обіцяє" повідомляти про зміни
}
Аналогія: Інтерфейс — це як договір. INotifyPropertyChanged — це договір: "Я обіцяю повідомити тебе (Binding Engine), коли моя властивість зміниться".
Інтерфейс має лише один член — подію PropertyChanged:
public interface INotifyPropertyChanged
{
event PropertyChangedEventHandler PropertyChanged;
}
// Делегат для події
public delegate void PropertyChangedEventHandler(
object sender,
PropertyChangedEventArgs e
);
// Аргументи події
public class PropertyChangedEventArgs : EventArgs
{
public string PropertyName { get; }
}
Що це означає:
PropertyChangedРозберемо покроково, як реалізувати INotifyPropertyChanged у класі.
using System.ComponentModel;
public class Person : INotifyPropertyChanged
{
// 1️⃣ Подія з інтерфейсу
public event PropertyChangedEventHandler PropertyChanged;
// 2️⃣ Backing field для властивості
private string _firstName;
// 3️⃣ Властивість з повідомленням про зміни
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
// Викликаємо подію
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("FirstName"));
}
}
}
Що відбувається:
INotifyPropertyChangedPropertyChanged?. оператор: Це null-conditional operator. PropertyChanged?.Invoke(...) означає "якщо PropertyChanged не null, викликати Invoke". Це безпечно, бо на початку ніхто не підписаний на подію.Щоб не дублювати код виклику події, створимо метод:
public class Person : 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;
OnPropertyChanged("FirstName"); // Викликаємо метод
}
}
}
Проблема попереднього коду — рядок "FirstName" написаний вручну. Якщо перейменуємо властивість через рефакторинг, рядок не оновиться → баг.
Рішення — атрибут [CallerMemberName]:
using System.Runtime.CompilerServices;
public class Person : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
// [CallerMemberName] автоматично підставляє назву властивості
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
OnPropertyChanged(); // Назва властивості підставиться автоматично!
}
}
}
OnPropertyChanged(). Це безпечно при рефакторингу — перейменували властивість → назва оновиться автоматично.Щоб уникнути зайвих оновлень UI, перевіряємо, чи значення дійсно змінилося:
public class Person : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
// Перевіряємо, чи значення змінилося
if (_firstName != value)
{
_firstName = value;
OnPropertyChanged();
}
}
}
}
Чому це важливо:
FirstName = "Іван", коли він вже "Іван" — подія не викликаєтьсяСтворимо повноцінний клас з кількома властивостями.
using System.ComponentModel;
using System.Runtime.CompilerServices;
public class Contact : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
// Backing fields
private string _firstName;
private string _lastName;
private string _email;
// Властивості з INPC
public string FirstName
{
get => _firstName;
set
{
if (_firstName != value)
{
_firstName = value;
OnPropertyChanged();
OnPropertyChanged(nameof(FullName)); // Оновлюємо залежну властивість
}
}
}
public string LastName
{
get => _lastName;
set
{
if (_lastName != value)
{
_lastName = value;
OnPropertyChanged();
OnPropertyChanged(nameof(FullName)); // Оновлюємо залежну властивість
}
}
}
public string Email
{
get => _email;
set
{
if (_email != value)
{
_email = value;
OnPropertyChanged();
}
}
}
// Обчислювана властивість (без backing field)
public string FullName => $"{FirstName} {LastName}";
}
FirstName змінюється, FullName теж змінюється (бо обчислюється з FirstName + LastName). Тому викликаємо OnPropertyChanged(nameof(FullName)) вручну.<Window x:Class="INPCDemo.MainWindow"
Title="Live Update Demo" Width="400" Height="300">
<StackPanel Margin="20">
<TextBlock Text="Ім'я:" Margin="0,0,0,5"/>
<TextBox Text="{Binding FirstName}" Margin="0,0,0,10"/>
<TextBlock Text="Прізвище:" Margin="0,0,0,5"/>
<TextBox Text="{Binding LastName}" Margin="0,0,0,10"/>
<TextBlock Text="Email:" Margin="0,0,0,5"/>
<TextBox Text="{Binding Email}" Margin="0,0,0,10"/>
<Separator Margin="0,10"/>
<TextBlock Text="Повне ім'я:" Margin="0,10,0,5"/>
<TextBlock Text="{Binding FullName}"
FontWeight="Bold"
FontSize="16"
Margin="0,0,0,10"/>
<Button Content="Змінити з коду" Click="Change_Click"/>
</StackPanel>
</Window>
public partial class MainWindow : Window
{
private Contact _contact;
public MainWindow()
{
InitializeComponent();
_contact = new Contact
{
FirstName = "Іван",
LastName = "Петренко",
Email = "ivan@example.com"
};
DataContext = _contact;
}
private void Change_Click(object sender, RoutedEventArgs e)
{
// Змінюємо властивості з коду
_contact.FirstName = "Олександр";
_contact.LastName = "Коваленко";
// ✅ UI автоматично оновлюється!
// TextBox-и показують нові значення
// FullName автоматично перераховується
}
}
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="10">
<TextBlock Text="Ім'я:"/>
<TextBox Text="Іван"/>
<TextBlock Text="Прізвище:"/>
<TextBox Text="Петренко"/>
<TextBlock Text="Повне ім'я:" FontWeight="Bold"/>
<TextBlock Text="Іван Петренко" FontSize="16"/>
<TextBlock Text="(У реальному WPF зміна Ім'я/Прізвище миттєво оновлює Повне ім'я)"
FontSize="10"
Foreground="Gray"/>
</StackPanel>
UpdateSourceTrigger визначає, коли зміни у Target (UI) передаються у Source (модель).
| UpdateSourceTrigger | Коли оновлюється Source? | Use Case |
|---|---|---|
PropertyChanged | При кожній зміні (кожна клавіша у TextBox) | Live search, real-time validation |
LostFocus | При втраті фокусу (default для TextBox) | Форми введення даних |
Explicit | Тільки при виклику UpdateSource() | Ручний контроль оновлення |
Default | Залежить від контролу | Автоматичний вибір |
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="{Binding SearchQuery}"/>
Поведінка: Кожна клавіша у TextBox миттєво оновлює SearchQuery → TextBlock оновлюється миттєво.
Коли використовувати:
<TextBox Text="{Binding FirstName, UpdateSourceTrigger=LostFocus}"/>
Поведінка: FirstName оновлюється тільки коли користувач клікає поза TextBox (втрата фокусу).
Коли використовувати:
TextBox.Text має UpdateSourceTrigger=LostFocus. Це розумний вибір для більшості форм — не перевантажує систему оновленнями при кожній клавіші.<TextBox x:Name="txtManual" Text="{Binding FirstName, UpdateSourceTrigger=Explicit}"/>
<Button Content="Застосувати" Click="Apply_Click"/>
private void Apply_Click(object sender, RoutedEventArgs e)
{
// Примусово оновлюємо Source
var binding = txtManual.GetBindingExpression(TextBox.TextProperty);
binding.UpdateSource();
}
Коли використовувати:
Створимо калькулятор, що миттєво перераховує результат при зміні операндів.
public class CalculatorViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private double _number1;
private double _number2;
public double Number1
{
get => _number1;
set
{
if (_number1 != value)
{
_number1 = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Sum));
OnPropertyChanged(nameof(Difference));
OnPropertyChanged(nameof(Product));
OnPropertyChanged(nameof(Quotient));
}
}
}
public double Number2
{
get => _number2;
set
{
if (_number2 != value)
{
_number2 = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Sum));
OnPropertyChanged(nameof(Difference));
OnPropertyChanged(nameof(Product));
OnPropertyChanged(nameof(Quotient));
}
}
}
// Обчислювані властивості
public double Sum => Number1 + Number2;
public double Difference => Number1 - Number2;
public double Product => Number1 * Number2;
public double Quotient => Number2 != 0 ? Number1 / Number2 : 0;
}
<Window x:Class="CalculatorDemo.MainWindow"
Title="Live Calculator" Width="350" Height="300">
<StackPanel Margin="20">
<TextBlock Text="Живий калькулятор" FontSize="18" FontWeight="Bold" Margin="0,0,0,20"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="20"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Число 1:" Grid.Row="0" Grid.Column="0" Margin="0,0,10,10" VerticalAlignment="Center"/>
<TextBox Text="{Binding Number1, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="0" Grid.Column="1" Margin="0,0,0,10"/>
<TextBlock Text="Число 2:" Grid.Row="1" Grid.Column="0" Margin="0,0,10,10" VerticalAlignment="Center"/>
<TextBox Text="{Binding Number2, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="1" Grid.Column="1" Margin="0,0,0,10"/>
<TextBlock Text="Сума:" Grid.Row="3" Grid.Column="0" Margin="0,0,10,5" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Sum}" Grid.Row="3" Grid.Column="1" FontWeight="Bold" Margin="0,0,0,5"/>
<TextBlock Text="Різниця:" Grid.Row="4" Grid.Column="0" Margin="0,0,10,5" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Difference}" Grid.Row="4" Grid.Column="1" FontWeight="Bold" Margin="0,0,0,5"/>
<TextBlock Text="Добуток:" Grid.Row="5" Grid.Column="0" Margin="0,0,10,5" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Product}" Grid.Row="5" Grid.Column="1" FontWeight="Bold" Margin="0,0,0,5"/>
<TextBlock Text="Частка:" Grid.Row="6" Grid.Column="0" Margin="0,0,10,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Quotient}" Grid.Row="6" Grid.Column="1" FontWeight="Bold"/>
</Grid>
</StackPanel>
</Window>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new CalculatorViewModel
{
Number1 = 10,
Number2 = 5
};
}
}
Результат: При зміні Number1 або Number2 — всі результати (Sum, Difference, Product, Quotient) миттєво перераховуються та відображаються!
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="10">
<TextBlock Text="Живий калькулятор" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="Число 1:"/>
<TextBox Text="10"/>
<TextBlock Text="Число 2:"/>
<TextBox Text="5"/>
<Separator/>
<StackPanel Spacing="5">
<TextBlock Text="Сума: 15" FontWeight="Bold"/>
<TextBlock Text="Різниця: 5" FontWeight="Bold"/>
<TextBlock Text="Добуток: 50" FontWeight="Bold"/>
<TextBlock Text="Частка: 2" FontWeight="Bold"/>
</StackPanel>
<TextBlock Text="(У реальному WPF результати оновлюються при кожній клавіші)"
FontSize="10"
Foreground="Gray"/>
</StackPanel>
UpdateSourceTrigger працює аналогічно, але з деякими відмінностями у default значеннях для різних контролів. Детальніше у статті Avalonia Compiled Bindings.Мета: Навчитися реалізовувати INotifyPropertyChanged у простому класі.
Завдання:
Створіть клас Product з властивостями:
Name (string)Price (decimal)Quantity (int)TotalCost (обчислювана: Price * Quantity)Реалізуйте INotifyPropertyChanged для всіх властивостей. Створіть форму з трьома TextBox для введення та TextBlock для відображення TotalCost.
Критерії успіху:
Price або Quantity у TextBox — TotalCost автоматично оновлюєтьсяПідказка:
public decimal TotalCost => Price * Quantity;
public decimal Price
{
get => _price;
set
{
if (_price != value)
{
_price = value;
OnPropertyChanged();
OnPropertyChanged(nameof(TotalCost)); // Не забудьте!
}
}
}
Мета: Створити складнішу форму з кількома залежними властивостями та live-preview.
Завдання:
Створіть клас UserProfile з властивостями:
FirstName, LastName, Email, PhoneFullName (обчислювана: FirstName + LastName)IsValid (обчислювана: перевірка, що всі поля заповнені)Створіть форму з двома колонками:
Додайте кнопку "Зберегти", що активна тільки коли IsValid == true (використайте IsEnabled="{Binding IsValid}").
Критерії успіху:
UpdateSourceTrigger=PropertyChanged для миттєвого оновленняПідказка для IsValid:
public bool IsValid =>
!string.IsNullOrWhiteSpace(FirstName) &&
!string.IsNullOrWhiteSpace(LastName) &&
!string.IsNullOrWhiteSpace(Email) &&
!string.IsNullOrWhiteSpace(Phone);
// У setter кожної властивості:
OnPropertyChanged(nameof(IsValid));
Мета: Створити складний приклад з кількома обчислюваними властивостями та колекціями.
Завдання:
Створіть конвертер валют з такими можливостями:
Модель CurrencyConverter:
AmountUAH (decimal) — сума у гривняхExchangeRateUSD (decimal) — курс долараExchangeRateEUR (decimal) — курс євроAmountUSD (обчислювана: AmountUAH / ExchangeRateUSD)AmountEUR (обчислювана: AmountUAH / ExchangeRateEUR)LastUpdate (DateTime) — час останнього оновленняUI:
UpdateSourceTrigger=PropertyChanged)LastUpdateДодатково (складно):
Критерії успіху:
Підказка:
private decimal _amountUAH;
public decimal AmountUAH
{
get => _amountUAH;
set
{
if (_amountUAH != value)
{
_amountUAH = value;
OnPropertyChanged();
OnPropertyChanged(nameof(AmountUSD));
OnPropertyChanged(nameof(AmountEUR));
UpdateLastUpdate();
}
}
}
private void UpdateLastUpdate()
{
LastUpdate = DateTime.Now;
OnPropertyChanged(nameof(LastUpdate));
}
У цій статті ми вирішили критичну проблему Data Binding — односторонню синхронізацію. Тепер ви знаєте, як зробити так, щоб зміни у моделі автоматично оновлювали UI.
Ключові висновки:
🔔 INotifyPropertyChanged
PropertyChanged.🔄 Двостороння синхронізація
⚡ CallerMemberName
[CallerMemberName] автоматично підставляє назву властивості — безпечно при рефакторингу.🎯 UpdateSourceTrigger
PropertyChanged (миттєво), LostFocus (при втраті фокусу), Explicit (вручну).Що далі?
ViewModelBase з реалізацією INotifyPropertyChanged, щоб не дублювати код у кожній моделі. Це стандартна практика у MVVM.System.ComponentModel, що дозволяє класу повідомляти про зміни властивостей через подію PropertyChanged.PropertyChanged event — подія, що викликається при зміні властивості. Binding Engine підписується на цю подію для оновлення UI.CallerMemberName — атрибут з System.Runtime.CompilerServices, що автоматично підставляє назву методу/властивості, з якого викликається метод.UpdateSourceTrigger — властивість Binding, що визначає, коли зміни у Target (UI) передаються у Source (модель).Backing field — приватне поле (_firstName), що зберігає значення властивості. Використовується у властивостях з INPC.Обчислювана властивість — властивість без backing field, що обчислюється на основі інших властивостей (наприклад, FullName => FirstName + LastName).Залежна властивість — властивість, що залежить від іншої властивості. При зміні батьківської властивості потрібно викликати OnPropertyChanged() для залежної.📖 Microsoft Docs: INotifyPropertyChanged
INotifyPropertyChanged з прикладами та best practices.📖 Data Binding Overview
🎓 CallerMemberName Attribute
CallerMemberName та інших Caller Info Attributes.🔧 Community Toolkit MVVM
Data Binding — Від Code-Behind до Декларативності
Розуміння концепції прив'язки даних у WPF — DataContext, режими Binding та перехід від імперативного до декларативного підходу
Compiled Bindings в Avalonia — Безпека на етапі компіляції
Ключова перевага Avalonia над WPF — compile-time перевірка Data Binding через Compiled Bindings замість Reflection