Desktop UI

INotifyPropertyChanged — Живе оновлення UI

Вирішення проблеми односторонньої синхронізації через INotifyPropertyChanged — інтерфейс, що дозволяє моделі повідомляти UI про зміни

INotifyPropertyChanged: Живе оновлення UI

Вступ

У попередній статті ми створили форму з 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 — інтерфейс, що дозволяє класу повідомляти про зміни властивостей.

Для кого ця стаття? Якщо ви вже знайомі з Data Binding Part 1 та розумієте концепцію Binding, ця стаття покаже, як зробити синхронізацію двосторонньою — не тільки UI → модель, а й модель → UI.

Проблема: Односторонній зв'язок POCO

Розберемо детально, чому 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 все ще показують "Іван"
    }
}

Що відбувається?

Loading diagram...
sequenceDiagram
    participant Code as Code-Behind
    participant Model as Person Model
    participant Binding as Binding Engine
    participant UI as UI (TextBox/TextBlock)
    
    Note over Code,UI: 1️⃣ Ініціалізація
    Code->>Model: FirstName = "Іван"
    Code->>Binding: DataContext = person
    Binding->>Model: Читає FirstName
    Binding->>UI: Встановлює Text = "Іван"
    
    Note over Code,UI: 2️⃣ Користувач редагує TextBox
    UI->>Binding: Text змінився на "Петро"
    Binding->>Model: FirstName = "Петро"
    Note over Model: ✅ Модель оновлена
    
    Note over Code,UI: 3️⃣ Зміна з коду
    Code->>Model: FirstName = "Олександр"
    Note over Model: Властивість змінена
    Model-.->Binding: ❌ Не повідомляє про зміну!
    Note over UI: ❌ UI не оновлюється
    
    style Model fill:#ef4444,stroke:#b91c1c,color:#ffffff
    style Binding fill:#f59e0b,stroke:#b45309,color:#ffffff
    style UI fill:#64748b,stroke:#334155,color:#ffffff

Проблема: Binding Engine не знає, що FirstName змінився, тому що ніхто йому не сказав.

Обхідний шлях (антипатерн)

Можна примусово оновити DataContext:

private void Change_Click(object sender, RoutedEventArgs e)
{
    _person.FirstName = "Олександр";
    
    // Примусово оновлюємо DataContext
    DataContext = null;
    DataContext = _person;
    
    // ✅ Тепер UI оновлюється
}

Але це антипатерн:

❌ Неефективно

Оновлюється весь UI, навіть якщо змінилася тільки одна властивість.

❌ Втрата стану

Втрачається фокус, позиція курсора у TextBox, стан прокрутки.

❌ Не масштабується

Для 50 властивостей потрібно 50 разів оновлювати DataContext?

Рішення: INotifyPropertyChanged

INotifyPropertyChanged — це інтерфейс з простору імен System.ComponentModel, що дозволяє класу повідомляти про зміни властивостей.

🔵 Recap: Що таке інтерфейс?

Для студентів зі слабким розумінням ООП — коротке пояснення.

Інтерфейс — це контракт, який клас зобов'язується виконати. Інтерфейс визначає що клас має робити, але не як.

// Інтерфейс — контракт
public interface INotifyPropertyChanged
{
    // Подія, яку клас має викликати при зміні властивості
    event PropertyChangedEventHandler PropertyChanged;
}

// Клас, що виконує контракт
public class Person : INotifyPropertyChanged
{
    // Реалізація контракту — додаємо подію
    public event PropertyChangedEventHandler PropertyChanged;
    
    // Тепер клас "обіцяє" повідомляти про зміни
}

Аналогія: Інтерфейс — це як договір. INotifyPropertyChanged — це договір: "Я обіцяю повідомити тебе (Binding Engine), коли моя властивість зміниться".

Детальніше про інтерфейси: Якщо концепція інтерфейсів незрозуміла, рекомендую повернутися до розділу ООП: Інтерфейси для глибшого розуміння.

Анатомія INotifyPropertyChanged

Інтерфейс має лише один член — подію PropertyChanged:

public interface INotifyPropertyChanged
{
    event PropertyChangedEventHandler PropertyChanged;
}

// Делегат для події
public delegate void PropertyChangedEventHandler(
    object sender,
    PropertyChangedEventArgs e
);

// Аргументи події
public class PropertyChangedEventArgs : EventArgs
{
    public string PropertyName { get; }
}

Що це означає:

  • Клас має мати подію PropertyChanged
  • Коли властивість змінюється, клас викликає цю подію
  • Binding Engine підписується на цю подію і оновлює UI

Реалізація INotifyPropertyChanged

Розберемо покроково, як реалізувати INotifyPropertyChanged у класі.

Крок 1: Базова реалізація

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"));
        }
    }
}

Що відбувається:

  1. Клас реалізує інтерфейс INotifyPropertyChanged
  2. Додаємо подію PropertyChanged
  3. У setter властивості викликаємо подію з назвою властивості
?. оператор: Це null-conditional operator. PropertyChanged?.Invoke(...) означає "якщо PropertyChanged не null, викликати Invoke". Це безпечно, бо на початку ніхто не підписаний на подію.

Крок 2: Метод OnPropertyChanged

Щоб не дублювати код виклику події, створимо метод:

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");  // Викликаємо метод
        }
    }
}

Крок 3: CallerMemberName (рекомендовано)

Проблема попереднього коду — рядок "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();  // Назва властивості підставиться автоматично!
        }
    }
}
Магія CallerMemberName: Компілятор автоматично підставляє назву методу/властивості, з якого викликається OnPropertyChanged(). Це безпечно при рефакторингу — перейменували властивість → назва оновиться автоматично.

Крок 4: Перевірка на зміну (оптимізація)

Щоб уникнути зайвих оновлень 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 = "Іван", коли він вже "Іван" — подія не викликається
  • Уникаємо зайвих оновлень UI (перемалювання, layout passes)
  • Уникаємо нескінченних циклів (якщо дві властивості залежать одна від одної)

Повний приклад з INPC

Створимо повноцінний клас з кількома властивостями.

Модель з INotifyPropertyChanged

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)) вручну.

XAML з живим оновленням

<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>

Code-Behind

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)...

Що відбувається під капотом?

Loading diagram...
sequenceDiagram
    participant Code as Code-Behind
    participant Model as Contact (INPC)
    participant Binding as Binding Engine
    participant UI as UI (TextBox/TextBlock)
    
    Note over Code,UI: Ініціалізація
    Code->>Binding: DataContext = contact
    Binding->>Model: Підписується на PropertyChanged
    
    Note over Code,UI: Зміна з коду
    Code->>Model: FirstName = "Олександр"
    Model->>Model: Setter викликає OnPropertyChanged("FirstName")
    Model->>Binding: PropertyChanged event (FirstName)
    Binding->>Model: Читає нове значення FirstName
    Binding->>UI: Оновлює TextBox.Text = "Олександр"
    
    Model->>Binding: PropertyChanged event (FullName)
    Binding->>Model: Читає нове значення FullName
    Binding->>UI: Оновлює TextBlock.Text = "Олександр Петренко"
    
    style Model fill:#10b981,stroke:#059669,color:#ffffff
    style Binding fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style UI fill:#f59e0b,stroke:#b45309,color:#ffffff

UpdateSourceTrigger: Коли оновлювати Source?

UpdateSourceTrigger визначає, коли зміни у Target (UI) передаються у Source (модель).

Таблиця режимів

UpdateSourceTriggerКоли оновлюється Source?Use Case
PropertyChangedПри кожній зміні (кожна клавіша у TextBox)Live search, real-time validation
LostFocusПри втраті фокусу (default для TextBox)Форми введення даних
ExplicitТільки при виклику UpdateSource()Ручний контроль оновлення
DefaultЗалежить від контролуАвтоматичний вибір

PropertyChanged: Миттєве оновлення

<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="{Binding SearchQuery}"/>

Поведінка: Кожна клавіша у TextBox миттєво оновлює SearchQueryTextBlock оновлюється миттєво.

Коли використовувати:

  • Live search (пошук при введенні)
  • Real-time валідація
  • Калькулятори, конвертери

LostFocus: Оновлення при втраті фокусу

<TextBox Text="{Binding FirstName, UpdateSourceTrigger=LostFocus}"/>

Поведінка: FirstName оновлюється тільки коли користувач клікає поза TextBox (втрата фокусу).

Коли використовувати:

  • Форми введення даних (default)
  • Коли не потрібне миттєве оновлення
  • Оптимізація продуктивності (менше оновлень)
Default для TextBox: За замовчуванням TextBox.Text має UpdateSourceTrigger=LostFocus. Це розумний вибір для більшості форм — не перевантажує систему оновленнями при кожній клавіші.

Explicit: Ручне оновлення

<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();
}

Коли використовувати:

  • Форми з кнопкою "Застосувати"
  • Коли потрібен повний контроль над оновленням
  • Складні сценарії валідації

Практичний приклад: Live Calculator

Створимо калькулятор, що миттєво перераховує результат при зміні операндів.

Модель з INPC

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;
}

XAML

<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>

Code-Behind

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)...

Avalonia: У Avalonia UpdateSourceTrigger працює аналогічно, але з деякими відмінностями у default значеннях для різних контролів. Детальніше у статті Avalonia Compiled Bindings.

Практичні завдання

Рівень 1: Базова реалізація INPC

Мета: Навчитися реалізовувати INotifyPropertyChanged у простому класі.

Завдання:

Створіть клас Product з властивостями:

  • Name (string)
  • Price (decimal)
  • Quantity (int)
  • TotalCost (обчислювана: Price * Quantity)

Реалізуйте INotifyPropertyChanged для всіх властивостей. Створіть форму з трьома TextBox для введення та TextBlock для відображення TotalCost.

Критерії успіху:

  • При зміні Price або Quantity у TextBox — TotalCost автоматично оновлюється
  • При зміні властивостей з коду (кнопка "Встановити тестові дані") — UI оновлюється

Підказка:

public decimal TotalCost => Price * Quantity;

public decimal Price
{
    get => _price;
    set
    {
        if (_price != value)
        {
            _price = value;
            OnPropertyChanged();
            OnPropertyChanged(nameof(TotalCost)); // Не забудьте!
        }
    }
}

Рівень 2: Форма профілю з live-preview

Мета: Створити складнішу форму з кількома залежними властивостями та live-preview.

Завдання:

Створіть клас UserProfile з властивостями:

  • FirstName, LastName, Email, Phone
  • FullName (обчислювана: FirstName + LastName)
  • IsValid (обчислювана: перевірка, що всі поля заповнені)

Створіть форму з двома колонками:

  • Ліва колонка: TextBox-и для введення даних
  • Права колонка: Live-preview профілю (TextBlock-и з форматованим виводом)

Додайте кнопку "Зберегти", що активна тільки коли IsValid == true (використайте IsEnabled="{Binding IsValid}").

Критерії успіху:

  • При введенні у TextBox — preview оновлюється миттєво
  • Кнопка "Зберегти" активна тільки коли всі поля заповнені
  • UpdateSourceTrigger=PropertyChanged для миттєвого оновлення

Підказка для IsValid:

public bool IsValid => 
    !string.IsNullOrWhiteSpace(FirstName) &&
    !string.IsNullOrWhiteSpace(LastName) &&
    !string.IsNullOrWhiteSpace(Email) &&
    !string.IsNullOrWhiteSpace(Phone);

// У setter кожної властивості:
OnPropertyChanged(nameof(IsValid));

Рівень 3: Конвертер валют з історією

Мета: Створити складний приклад з кількома обчислюваними властивостями та колекціями.

Завдання:

Створіть конвертер валют з такими можливостями:

Модель CurrencyConverter:

  • AmountUAH (decimal) — сума у гривнях
  • ExchangeRateUSD (decimal) — курс долара
  • ExchangeRateEUR (decimal) — курс євро
  • AmountUSD (обчислювана: AmountUAH / ExchangeRateUSD)
  • AmountEUR (обчислювана: AmountUAH / ExchangeRateEUR)
  • LastUpdate (DateTime) — час останнього оновлення

UI:

  • TextBox для введення суми у гривнях (UpdateSourceTrigger=PropertyChanged)
  • TextBox-и для курсів валют
  • TextBlock-и для відображення конвертованих сум
  • TextBlock для відображення часу останнього оновлення
  • Кнопка "Оновити курси" — встановлює нові курси та оновлює LastUpdate

Додатково (складно):

  • Додайте можливість вводити суму у будь-якій валюті (UAH, USD, EUR)
  • При зміні однієї валюти — автоматично перераховуються інші дві
  • Додайте валідацію: курси мають бути > 0

Критерії успіху:

  • Зміна суми або курсу миттєво оновлює всі конвертовані значення
  • Час останнього оновлення відображається у форматі "dd.MM.yyyy HH:mm:ss"
  • Всі обчислення коректні (без ділення на нуль)

Підказка:

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.

🔄 Двостороння синхронізація

POCO + INPC = повноцінний Data Binding. Зміни у моделі → UI оновлюється. Зміни у UI → модель оновлюється.

⚡ CallerMemberName

Атрибут [CallerMemberName] автоматично підставляє назву властивості — безпечно при рефакторингу.

🎯 UpdateSourceTrigger

Контролює, коли UI оновлює модель: PropertyChanged (миттєво), LostFocus (при втраті фокусу), Explicit (вручну).

Що далі?

  • Avalonia Compiled Bindings (наступна стаття) — як Avalonia покращує Data Binding через compile-time перевірку
  • MVVM Pattern (Блок 7) — архітектурний патерн, що використовує INPC для повного розділення UI та логіки
  • ObservableCollection (Блок 6) — колекція з INPC для динамічних списків
Базовий клас для INPC: У реальних проектах створюють базовий клас ViewModelBase з реалізацією INotifyPropertyChanged, щоб не дублювати код у кожній моделі. Це стандартна практика у MVVM.

Словник термінів

INotifyPropertyChanged (INPC) — інтерфейс з 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

Повний огляд Data Binding у WPF — від базових концепцій до складних сценаріїв.

🎓 CallerMemberName Attribute

Детальна документація атрибута CallerMemberName та інших Caller Info Attributes.

🔧 Community Toolkit MVVM

Сучасна бібліотека для MVVM, що автоматизує реалізацію INPC через Source Generators (C# 9+).

📚 Попередня стаття: Data Binding Part 1

Повернутися до основ Data Binding — DataContext, Binding Modes, перший робочий приклад.

📚 Наступна стаття: Avalonia Compiled Bindings

Дізнатися, як Avalonia покращує Data Binding через compile-time перевірку та кращу продуктивність.