Уявіть собі кнопку з іконкою та текстом:
<Button Width="200" Height="50">
<StackPanel Orientation="Horizontal">
<Image Source="icon.png" Width="24" Height="24"/>
<TextBlock Text="Натисни мене" VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
</Button>
Тепер питання: коли користувач клікає на текст "Натисни мене", хто отримає подію Click?
TextBlock, на який безпосередньо клікнули?StackPanel, що містить текст?Button, який є логічним контролом?У WinForms відповідь проста — подія приходить тільки до TextBlock. Але у WPF елементи організовані у дерево (Visual Tree), і події можуть подорожувати по цьому дереву. Це називається Event Routing (маршрутизація подій).
У традиційних UI-фреймворках (WinForms, Windows API) події працюють просто:
// WinForms
button.Click += (sender, e) =>
{
MessageBox.Show("Кнопку натиснуто!");
};
Подія Click приходить тільки до button. Якщо всередині кнопки є інші контроли (Label, PictureBox), вони не отримують цю подію.
❌ Складність композиції
❌ Дублювання коду
icon.Click += handler; label.Click += handler;.❌ Немає перехоплення
❌ Складна ієрархія
WPF вирішує ці проблеми через маршрутизацію подій — події "подорожують" по Visual Tree, даючи можливість кожному елементу на шляху обробити їх.
WPF підтримує три типи маршрутизації:
| Стратегія | Напрямок | Naming Convention | Приклади |
|---|---|---|---|
| Tunneling | Від кореня до джерела (↓) | Preview* | PreviewMouseDown, PreviewKeyDown |
| Bubbling | Від джерела до кореня (↑) | Без префіксу | MouseDown, Click, KeyDown |
| Direct | Тільки джерело (без руху) | Різні | MouseEnter, MouseLeave |
Розглянемо, що відбувається при кліку на TextBlock всередині Button:
Preview*) та Bubbling (без префіксу). Спочатку спрацьовує Tunneling (зверху вниз), потім Bubbling (знизу вгору).Tunneling (тунелювання) — це маршрутизація від кореня до джерела. Події з префіксом Preview* спрацьовують до того, як подія досягне цільового елемента.
Tunneling дозволяє перехопити подію на вищому рівні та:
e.Handled = true)Створимо TextBox, що не дозволяє вводити цифри:
<Window x:Class="RoutedEventsDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
PreviewTextInput="Window_PreviewTextInput">
<StackPanel Margin="20">
<TextBlock Text="Введіть текст (без цифр):" Margin="0,0,0,5"/>
<TextBox x:Name="myTextBox"/>
</StackPanel>
</Window>
Code-behind:
private void Window_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
// Перевіряємо, чи введений текст містить цифри
if (e.Text.Any(char.IsDigit))
{
e.Handled = true; // Зупиняємо подію — TextBox не отримає її
}
}
Що відбувається:
PreviewTextInput на Window (Tunneling)Window перевіряє текстe.Handled = trueTextBox — символ не вводитьсяLoading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="10">
<TextBlock Text="Спробуйте ввести цифри — вони не з'являться"/>
<TextBox Text="Тільки текст"/>
<TextBlock Text="(У реальному WPF це працює через PreviewTextInput)"
FontSize="10"
Foreground="Gray"/>
</StackPanel>
PreviewTextInput не працює у Avalonia WASM, тому превью показує лише візуальний результат. У реальному WPF-проєкті цей код працює повністю.Всі Tunneling події мають префікс Preview:
| Bubbling Event | Tunneling Event | Опис |
|---|---|---|
MouseDown | PreviewMouseDown | Натискання кнопки миші |
MouseUp | PreviewMouseUp | Відпускання кнопки миші |
KeyDown | PreviewKeyDown | Натискання клавіші |
KeyUp | PreviewKeyUp | Відпускання клавіші |
TextInput | PreviewTextInput | Введення тексту |
DragEnter | PreviewDragEnter | Початок drag-and-drop |
Bubbling (спливання) — це маршрутизація від джерела до кореня. Це найчастіший тип подій у WPF.
Bubbling дозволяє:
Замість прикріплення обробника до кожної кнопки, прикріпимо один обробник до батьківського StackPanel:
<StackPanel Margin="20" Button.Click="StackPanel_ButtonClick">
<Button Content="Кнопка 1" Tag="1" Margin="0,0,0,5"/>
<Button Content="Кнопка 2" Tag="2" Margin="0,0,0,5"/>
<Button Content="Кнопка 3" Tag="3"/>
</StackPanel>
Code-behind:
private void StackPanel_ButtonClick(object sender, RoutedEventArgs e)
{
// e.Source — елемент, що ініціював подію (Button)
if (e.Source is Button button)
{
MessageBox.Show($"Натиснуто кнопку {button.Tag}");
}
}
Що відбувається:
Button генерує подію ClickStackPanel (Bubbling)StackPanel отримує подіюe.Source визначаємо, яка саме кнопка була натиснутаLoading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="10">
<Button Content="Кнопка 1" Command="{Binding ShowMessageCommand}" CommandParameter="Натиснуто кнопку 1"/>
<Button Content="Кнопка 2" Command="{Binding ShowMessageCommand}" CommandParameter="Натиснуто кнопку 2"/>
<Button Content="Кнопка 3" Command="{Binding ShowMessageCommand}" CommandParameter="Натиснуто кнопку 3"/>
<TextBlock Text="Результат з'явиться у вкладці Output"
FontSize="10"
Foreground="Gray"
Margin="0,10,0,0"/>
</StackPanel>
🎯 Менше коду
🔄 Динамічні елементи
🧩 Композитні контроли
Direct Events — це події, що не подорожують по дереву. Вони спрацьовують тільки на елементі, де сталася подія.
| Подія | Опис | Чому Direct? |
|---|---|---|
MouseEnter | Миша увійшла в межі елемента | Специфічна для конкретного елемента |
MouseLeave | Миша покинула межі елемента | Специфічна для конкретного елемента |
GotFocus | Елемент отримав фокус | Фокус може бути тільки на одному |
LostFocus | Елемент втратив фокус | Фокус може бути тільки на одному |
Loaded | Елемент завантажений та готовий до рендерингу | Lifecycle event конкретного елемента |
Деякі події логічно не мають сенсу маршрутизувати:
MouseEnter / MouseLeave — якщо миша увійшла в Button, вона автоматично увійшла у всі батьківські елементи. Bubbling створив би дублювання.GotFocus / LostFocus — фокус може бути тільки на одному елементі одночасно.Button.ClickEvent.RoutingStrategy поверне Bubble, Direct або Tunnel.Кожна Routed Event передає об'єкт RoutedEventArgs (або його підклас), що містить інформацію про подію.
public class RoutedEventArgs : EventArgs
{
// Елемент, що ініціював подію (логічний)
public object Source { get; set; }
// Елемент, де фізично сталася подія (візуальний)
public object OriginalSource { get; }
// Чи була подія оброблена (зупинка маршрутизації)
public bool Handled { get; set; }
// Сама подія (RoutedEvent)
public RoutedEvent RoutedEvent { get; }
}
Це одна з найбільш заплутаних концепцій для початківців. Розберемо різницю:
<Button Width="200" Height="50" Click="Button_Click">
<StackPanel Orientation="Horizontal">
<Image Source="icon.png" Width="24" Height="24"/>
<TextBlock Text="Натисни мене"/>
</StackPanel>
</Button>
private void Button_Click(object sender, RoutedEventArgs e)
{
// sender — завжди елемент, до якого прикріплений обробник
Console.WriteLine($"sender: {sender.GetType().Name}"); // Button
// Source — логічний елемент, що ініціював подію
Console.WriteLine($"Source: {e.Source.GetType().Name}"); // Button
// OriginalSource — візуальний елемент, де фізично клікнули
Console.WriteLine($"OriginalSource: {e.OriginalSource.GetType().Name}"); // TextBlock або Image
}
Результат при кліку на текст:
sender: Button
Source: Button
OriginalSource: TextBlock
Source — це логічний елемент (той, хто "відповідає" за подію). OriginalSource — це візуальний елемент (той, на який фізично клікнули). У більшості випадків використовуйте Source.Властивість Handled дозволяє зупинити подальшу маршрутизацію події:
private void Button_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("Кнопку натиснуто!");
// Зупиняємо подію — батьківські елементи не отримають її
e.Handled = true;
}
Коли використовувати Handled = true:
Іноді потрібно отримати подію навіть якщо вона була зупинена (Handled = true). Для цього використовуйте перевантаження AddHandler:
public MainWindow()
{
InitializeComponent();
// Третій параметр = true — отримувати навіть Handled події
myButton.AddHandler(Button.ClickEvent, new RoutedEventHandler(Button_Click), handledEventsToo: true);
}
private void Button_Click(object sender, RoutedEventArgs e)
{
// Цей обробник спрацює навіть якщо інший обробник встановив e.Handled = true
Console.WriteLine("Отримав подію, незважаючи на Handled!");
}
handledEventsToo = true порушує очікувану поведінку маршрутизації. Використовуйте тільки для діагностики або специфічних сценаріїв (наприклад, глобальне логування подій).Тепер створимо власну Routed Event для custom контролу.
Створимо контрол CustomListBox з власною подією ItemSelected:
using System.Windows;
using System.Windows.Controls;
public class CustomListBox : ListBox
{
// 1️⃣ Реєстрація Routed Event
public static readonly RoutedEvent ItemSelectedEvent =
EventManager.RegisterRoutedEvent(
name: "ItemSelected", // Назва події
routingStrategy: RoutingStrategy.Bubble, // Стратегія маршрутизації
handlerType: typeof(RoutedEventHandler), // Тип делегата
ownerType: typeof(CustomListBox) // Клас-власник
);
// 2️⃣ CLR event wrapper
public event RoutedEventHandler ItemSelected
{
add { AddHandler(ItemSelectedEvent, value); }
remove { RemoveHandler(ItemSelectedEvent, value); }
}
// 3️⃣ Генерація події
protected override void OnSelectionChanged(SelectionChangedEventArgs e)
{
base.OnSelectionChanged(e);
if (e.AddedItems.Count > 0)
{
// Створюємо RoutedEventArgs
var args = new RoutedEventArgs(ItemSelectedEvent, this);
// Генеруємо подію
RaiseEvent(args);
}
}
}
Використання у XAML:
<local:CustomListBox ItemSelected="CustomListBox_ItemSelected">
<ListBoxItem Content="Елемент 1"/>
<ListBoxItem Content="Елемент 2"/>
<ListBoxItem Content="Елемент 3"/>
</local:CustomListBox>
Code-behind:
private void CustomListBox_ItemSelected(object sender, RoutedEventArgs e)
{
var listBox = (CustomListBox)sender;
MessageBox.Show($"Обрано: {listBox.SelectedItem}");
}
| Параметр | Тип | Опис |
|---|---|---|
name | string | Назва події (convention: без префіксу "Event") |
routingStrategy | RoutingStrategy | Bubble, Tunnel, або Direct |
handlerType | Type | Тип делегата (зазвичай RoutedEventHandler) |
ownerType | Type | Клас, що володіє подією |
{EventName}Event (наприклад, ItemSelectedEvent), а CLR wrapper — {EventName} (наприклад, ItemSelected).Class Handlers — це обробники подій, що реєструються на рівні класу, а не екземпляра. Вони спрацьовують до instance handlers.
Class Handlers дозволяють:
Button має властивість IsDefault — якщо вона true, натискання Enter активує кнопку. Це реалізовано через Class Handler:
// Спрощена версія з WPF
static Button()
{
// Реєстрація Class Handler для KeyDown
EventManager.RegisterClassHandler(
typeof(Button),
Keyboard.KeyDownEvent,
new KeyEventHandler(OnKeyDown)
);
}
private static void OnKeyDown(object sender, KeyEventArgs e)
{
var button = (Button)sender;
// Якщо натиснуто Enter і IsDefault = true
if (e.Key == Key.Enter && button.IsDefault)
{
// Активуємо кнопку
button.OnClick();
e.Handled = true;
}
}
public class MyTextBox : TextBox
{
static MyTextBox()
{
// Реєстрація Class Handler для PreviewKeyDown
EventManager.RegisterClassHandler(
typeof(MyTextBox),
PreviewKeyDownEvent,
new KeyEventHandler(OnPreviewKeyDown)
);
}
private static void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
// Заборона Ctrl+V (вставка)
if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control)
{
e.Handled = true;
}
}
}
e.Handled = true, Instance Handler все одно спрацює (на відміну від звичайної маршрутизації).Завдання: Створіть вкладену структуру контролів та дослідіть порядок спрацювання обробників.
<Window x:Class="RoutedEventsDemo.MainWindow"
MouseDown="Window_MouseDown">
<Grid MouseDown="Grid_MouseDown" Background="LightGray">
<StackPanel MouseDown="StackPanel_MouseDown"
Background="LightBlue"
Width="300"
Height="200">
<Button Content="Натисни мене"
MouseDown="Button_MouseDown"
Margin="20"/>
</StackPanel>
</Grid>
</Window>
private void Button_MouseDown(object sender, MouseButtonEventArgs e)
{
Debug.WriteLine("1. Button_MouseDown");
}
private void StackPanel_MouseDown(object sender, MouseButtonEventArgs e)
{
Debug.WriteLine("2. StackPanel_MouseDown");
}
private void Grid_MouseDown(object sender, MouseButtonEventArgs e)
{
Debug.WriteLine("3. Grid_MouseDown");
}
private void Window_MouseDown(object sender, MouseButtonEventArgs e)
{
Debug.WriteLine("4. Window_MouseDown");
}
e.Handled = true у StackPanel_MouseDown — що зміниться?PreviewMouseDown обробники — як зміниться порядок?Очікуваний результат (без Handled):
1. Button_MouseDown
2. StackPanel_MouseDown
3. Grid_MouseDown
4. Window_MouseDown
Завдання: Створіть форму, де на рівні Window заборонено вводити спеціальні символи у всі TextBox.
Вимоги:
PreviewTextInput на Window@, #, $, %, &Підказка:
private void Window_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
char[] forbiddenChars = { '@', '#', '$', '%', '&' };
if (e.Text.Any(c => forbiddenChars.Contains(c)))
{
e.Handled = true;
MessageBox.Show("Спеціальні символи заборонені!");
}
}
Завдання: Створіть RatingControl з власною подією RatingChanged.
Вимоги:
public class RatingControl : Control
{
// DependencyProperty для рейтингу
public static readonly DependencyProperty RatingProperty =
DependencyProperty.Register(
nameof(Rating),
typeof(int),
typeof(RatingControl),
new PropertyMetadata(0, OnRatingChanged)
);
public int Rating
{
get => (int)GetValue(RatingProperty);
set => SetValue(RatingProperty, value);
}
// TODO: Реєстрація RatingChangedEvent
// TODO: CLR event wrapper
// TODO: Генерація події у OnRatingChanged callback
}
public static readonly RoutedEvent RatingChangedEvent =
EventManager.RegisterRoutedEvent(
"RatingChanged",
RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<int>),
typeof(RatingControl)
);
public event RoutedPropertyChangedEventHandler<int> RatingChanged
{
add { AddHandler(RatingChangedEvent, value); }
remove { RemoveHandler(RatingChangedEvent, value); }
}
private static void OnRatingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (RatingControl)d;
int oldValue = (int)e.OldValue;
int newValue = (int)e.NewValue;
var args = new RoutedPropertyChangedEventArgs<int>(oldValue, newValue, RatingChangedEvent);
control.RaiseEvent(args);
}
<local:RatingControl Rating="3" RatingChanged="RatingControl_RatingChanged"/>
У цій статті ми розібрали систему подій WPF:
EventManager.RegisterRoutedEvent()Attached Properties — Властивості без меж
Розуміння механізму Attached Properties у WPF — як Grid.Row працює на Button, і як створювати власні attached properties
Data Binding — Від Code-Behind до Декларативності
Розуміння концепції прив'язки даних у WPF — DataContext, режими Binding та перехід від імперативного до декларативного підходу