Уявіть, що ви створюєте застосунок з десятками екранів. На кожному екрані — панель пошуку: TextBox для введення запиту, кнопка "Шукати", іконка лупи, placeholder "Введіть текст для пошуку...". Ви копіюєте цю розмітку з екрану на екран. Потім дизайнер просить змінити колір кнопки. Тепер вам потрібно відредагувати 30 файлів XAML.
Це класична проблема дублювання коду. У C# ми розв'язуємо її через функції та класи. У WPF — через UserControl. UserControl — це перевикористовуваний UI-компонент, що інкапсулює розмітку, логіку та публічний API для зовнішнього використання.
UserControl — це не просто "шматок XAML". Це повноцінний компонент з власними властивостями (DependencyProperty), подіями (RoutedEvent), командами та навіть власною ViewModel для складних випадків. Це будівельний блок для створення бібліотек UI-компонентів, дизайн-систем та переносу коду між проєктами.
У цій статті ми детально розберемо всі аспекти створення UserControl: від базового синтаксису до складних патернів з ViewModel. Ви навчитесь створювати компоненти, що виглядають та поводяться як вбудовані WPF-контроли — з повною підтримкою Binding, Commands та MVVM.
UserControl — це клас WPF, що наслідує Control і дозволяє створювати власні перевикористовувані компоненти. Технічно, UserControl — це контейнер, всередині якого ви розміщуєте інші контроли (TextBox, Button, StackPanel тощо) та визначаєте їхню взаємодію.
✅ Використовуйте UserControl коли:
❌ НЕ використовуйте UserControl коли:
Замість копіювання цієї розмітки на кожен екран:
<!-- ❌ Дублювання коду на кожному екрані -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<TextBox x:Name="SearchTextBox"
Width="300"
Margin="0,0,8,0"
VerticalContentAlignment="Center"/>
<Button Content="🔍 Шукати"
Click="Search_Click"
Padding="12,6"/>
</StackPanel>
Створюємо UserControl один раз і використовуємо скрізь:
<!-- ✅ Перевикористовуваний компонент -->
<local:SearchBox SearchText="{Binding SearchQuery}"
SearchCommand="{Binding SearchCommand}"
Margin="0,0,0,16"/>
Тепер зміна дизайну панелі пошуку вимагає редагування лише одного файлу — SearchBox.xaml.
UserControl складається з двох файлів:
1. XAML-файл (SearchBox.xaml) — розмітка UI:
<UserControl x:Class="MyApp.Controls.SearchBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel Orientation="Horizontal">
<TextBox Width="300" Margin="0,0,8,0"/>
<Button Content="🔍 Шукати" Padding="12,6"/>
</StackPanel>
</UserControl>
2. Code-behind файл (SearchBox.xaml.cs) — логіка та публічний API:
namespace MyApp.Controls;
public partial class SearchBox : UserControl
{
public SearchBox()
{
InitializeComponent();
}
// Тут будуть DependencyProperty, події, методи
}
Розберемо покроковий процес створення UserControl на прикладі панелі пошуку.
У Visual Studio:
SearchBox.xamlВручну (для розуміння структури):
Створіть папку Controls у проєкті та два файли:
Controls/SearchBox.xaml:
<UserControl x:Class="MyApp.Controls.SearchBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="40" d:DesignWidth="400">
<!-- Розмітка тут -->
</UserControl>
Controls/SearchBox.xaml.cs:
using System.Windows.Controls;
namespace MyApp.Controls;
public partial class SearchBox : UserControl
{
public SearchBox()
{
InitializeComponent();
}
}
Важливі деталі:
x:Class — повне ім'я класу з namespaced:DesignHeight та d:DesignWidth — розміри для дизайнера (не впливають на runtime)InitializeComponent() — метод, що завантажує XAML (генерується автоматично)Додаємо розмітку всередину UserControl:
<UserControl x:Class="MyApp.Controls.SearchBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
d:DesignHeight="40" d:DesignWidth="400">
<Border Background="White"
BorderBrush="#e2e8f0"
BorderThickness="1"
CornerRadius="8"
Padding="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Іконка пошуку -->
<TextBlock Grid.Column="0"
Text="🔍"
FontSize="16"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<!-- Поле введення -->
<TextBox Grid.Column="1"
x:Name="SearchTextBox"
BorderThickness="0"
Background="Transparent"
VerticalContentAlignment="Center"
FontSize="14"/>
<!-- Кнопка очищення -->
<Button Grid.Column="2"
x:Name="ClearButton"
Content="✕"
Background="Transparent"
BorderThickness="0"
Padding="8,4"
Margin="8,0,0,0"
Cursor="Hand"
Click="ClearButton_Click"/>
</Grid>
</Border>
</UserControl>
Code-behind для кнопки очищення:
public partial class SearchBox : UserControl
{
public SearchBox()
{
InitializeComponent();
}
private void ClearButton_Click(object sender, RoutedEventArgs e)
{
SearchTextBox.Text = "";
SearchTextBox.Focus();
}
}
Тепер використовуємо наш компонент у MainWindow:
<Window x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:MyApp.Controls"
Title="My App" Width="600" Height="400">
<StackPanel Margin="20">
<TextBlock Text="Пошук файлів"
FontSize="18" FontWeight="Bold"
Margin="0,0,0,12"/>
<!-- Використання нашого UserControl -->
<controls:SearchBox/>
<ListBox Margin="0,16,0,0" Height="300"/>
</StackPanel>
</Window>
Ключові моменти:
xmlns:controls="clr-namespace:MyApp.Controls" — реєстрація namespace для доступу до UserControl<controls:SearchBox/> — використання як звичайного контролуMargin, Width, Height, HorizontalAlignment тощоLoading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<Border Background="White"
BorderBrush="#e2e8f0"
BorderThickness="1"
CornerRadius="8"
Padding="8"
Width="400">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="🔍"
FontSize="16"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBox Grid.Column="1"
BorderThickness="0"
Background="Transparent"
VerticalContentAlignment="Center"
FontSize="14"
Text="Введіть текст для пошуку..."/>
<Button Grid.Column="2"
Content="✕"
Background="Transparent"
BorderThickness="0"
Padding="8,4"
Margin="8,0,0,0"
Cursor="Hand"
Command="{Binding ShowMessageCommand}"
CommandParameter="Текст очищено"/>
</Grid>
</Border>
Базовий UserControl з попереднього розділу працює, але він ізольований — зовнішній код не може отримати текст пошуку або підписатись на зміни. Щоб зробити компонент справді перевикористовуваним, потрібен публічний API через DependencyProperty.
DependencyProperty (скорочено DP) — це спеціальна властивість WPF з розширеними можливостями:
{Binding} — можна прив'язувати до ViewModel{StaticResource} та {DynamicResource}Звичайна C# властивість (public string Text { get; set; }) не підтримує жодної з цих можливостей.
DependencyProperty складається з трьох частин:
1. Статичне поле DependencyProperty:
public static readonly DependencyProperty SearchTextProperty =
DependencyProperty.Register(
name: "SearchText", // Ім'я властивості
propertyType: typeof(string), // Тип властивості
ownerType: typeof(SearchBox), // Клас-власник
typeMetadata: new PropertyMetadata("") // Метадані (значення за замовчуванням)
);
2. CLR-обгортка (wrapper):
public string SearchText
{
get => (string)GetValue(SearchTextProperty);
set => SetValue(SearchTextProperty, value);
}
3. (Опціонально) Callback при зміні:
new PropertyMetadata("", OnSearchTextChanged)
private static void OnSearchTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (SearchBox)d;
string newValue = (string)e.NewValue;
// Логіка при зміні
}
Додаємо DependencyProperty до нашого SearchBox:
public partial class SearchBox : UserControl
{
// 1. Статичне поле DependencyProperty
public static readonly DependencyProperty SearchTextProperty =
DependencyProperty.Register(
nameof(SearchText),
typeof(string),
typeof(SearchBox),
new PropertyMetadata("", OnSearchTextChanged)
);
// 2. CLR-обгортка
public string SearchText
{
get => (string)GetValue(SearchTextProperty);
set => SetValue(SearchTextProperty, value);
}
// 3. Callback при зміні
private static void OnSearchTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (SearchBox)d;
string newValue = (string)e.NewValue;
// Синхронізувати з внутрішнім TextBox
if (control.SearchTextBox.Text != newValue)
{
control.SearchTextBox.Text = newValue;
}
}
public SearchBox()
{
InitializeComponent();
// Синхронізація у зворотний бік: TextBox → DependencyProperty
SearchTextBox.TextChanged += (s, e) =>
{
SearchText = SearchTextBox.Text;
};
}
private void ClearButton_Click(object sender, RoutedEventArgs e)
{
SearchText = ""; // Тепер змінюємо DependencyProperty, а не TextBox напряму
SearchTextBox.Focus();
}
}
Тепер можна прив'язувати SearchText до ViewModel:
<!-- У MainWindow -->
<controls:SearchBox SearchText="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"/>
// У MainViewModel
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _searchQuery = "";
partial void OnSearchQueryChanged(string value)
{
// Автоматично викликається при зміні SearchQuery
Console.WriteLine($"Пошуковий запит: {value}");
FilterItems(value);
}
}
Що відбувається:
TextChanged event оновлює SearchText DependencyPropertySearchQuery ViewModelOnSearchQueryChanged викликається автоматичноДодаємо ще кілька властивостей для повного API:
public partial class SearchBox : UserControl
{
// SearchText
public static readonly DependencyProperty SearchTextProperty =
DependencyProperty.Register(nameof(SearchText), typeof(string), typeof(SearchBox),
new PropertyMetadata("", OnSearchTextChanged));
public string SearchText
{
get => (string)GetValue(SearchTextProperty);
set => SetValue(SearchTextProperty, value);
}
// Placeholder
public static readonly DependencyProperty PlaceholderProperty =
DependencyProperty.Register(nameof(Placeholder), typeof(string), typeof(SearchBox),
new PropertyMetadata("Введіть текст для пошуку..."));
public string Placeholder
{
get => (string)GetValue(PlaceholderProperty);
set => SetValue(PlaceholderProperty, value);
}
// IsSearching (для індикатора завантаження)
public static readonly DependencyProperty IsSearchingProperty =
DependencyProperty.Register(nameof(IsSearching), typeof(bool), typeof(SearchBox),
new PropertyMetadata(false));
public bool IsSearching
{
get => (bool)GetValue(IsSearchingProperty);
set => SetValue(IsSearchingProperty, value);
}
// ShowClearButton
public static readonly DependencyProperty ShowClearButtonProperty =
DependencyProperty.Register(nameof(ShowClearButton), typeof(bool), typeof(SearchBox),
new PropertyMetadata(true));
public bool ShowClearButton
{
get => (bool)GetValue(ShowClearButtonProperty);
set => SetValue(ShowClearButtonProperty, value);
}
}
Оновлена розмітка з прив'язкою до DP:
<UserControl x:Class="MyApp.Controls.SearchBox"
x:Name="Root">
<Border Background="White" BorderBrush="#e2e8f0" BorderThickness="1" CornerRadius="8" Padding="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Іконка пошуку -->
<TextBlock Grid.Column="0" Text="🔍" FontSize="16" VerticalAlignment="Center" Margin="0,0,8,0"/>
<!-- Поле введення з Placeholder -->
<Grid Grid.Column="1">
<TextBox x:Name="SearchTextBox"
BorderThickness="0"
Background="Transparent"
VerticalContentAlignment="Center"
FontSize="14"/>
<!-- Placeholder (показується коли текст порожній) -->
<TextBlock Text="{Binding Placeholder, ElementName=Root}"
Foreground="#94a3b8"
FontSize="14"
VerticalAlignment="Center"
IsHitTestVisible="False">
<TextBlock.Visibility>
<MultiBinding Converter="{StaticResource TextEmptyToVisibilityConverter}">
<Binding Path="SearchText" ElementName="Root"/>
</MultiBinding>
</TextBlock.Visibility>
</TextBlock>
</Grid>
<!-- Індикатор завантаження -->
<TextBlock Grid.Column="2"
Text="⏳"
FontSize="16"
VerticalAlignment="Center"
Margin="8,0"
Visibility="{Binding IsSearching, ElementName=Root, Converter={StaticResource BoolToVisibilityConverter}}"/>
<!-- Кнопка очищення -->
<Button Grid.Column="3"
x:Name="ClearButton"
Content="✕"
Background="Transparent"
BorderThickness="0"
Padding="8,4"
Cursor="Hand"
Click="ClearButton_Click"
Visibility="{Binding ShowClearButton, ElementName=Root, Converter={StaticResource BoolToVisibilityConverter}}"/>
</Grid>
</Border>
</UserControl>
Ключовий момент: x:Name="Root" на UserControl дозволяє прив'язуватись до його DependencyProperty через ElementName=Root.
<controls:SearchBox SearchText="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"
Placeholder="Шукати файли..."
IsSearching="{Binding IsSearching}"
ShowClearButton="True"
Margin="0,0,0,16"/>
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _searchQuery = "";
[ObservableProperty]
private bool _isSearching;
partial void OnSearchQueryChanged(string value)
{
SearchAsync(value);
}
private async void SearchAsync(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
Items.Clear();
return;
}
IsSearching = true;
try
{
await Task.Delay(500); // Debounce
var results = await _searchService.SearchAsync(query);
Items = new ObservableCollection<Item>(results);
}
finally
{
IsSearching = false;
}
}
}
DependencyProperty дозволяє зовнішньому коду передавати дані всередину компонента. Але як компонент може повідомити зовнішній код про події? Наприклад, "користувач натиснув Enter у полі пошуку" або "користувач натиснув кнопку Шукати".
Для цього використовуються RoutedEvent — події WPF, що "спливають" (bubbling) або "тонуть" (tunneling) по дереву елементів.
Структура схожа на DependencyProperty:
1. Статичне поле RoutedEvent:
public static readonly RoutedEvent SearchRequestedEvent =
EventManager.RegisterRoutedEvent(
name: "SearchRequested",
routingStrategy: RoutingStrategy.Bubble,
handlerType: typeof(RoutedEventHandler),
ownerType: typeof(SearchBox)
);
2. CLR-обгортка (wrapper):
public event RoutedEventHandler SearchRequested
{
add => AddHandler(SearchRequestedEvent, value);
remove => RemoveHandler(SearchRequestedEvent, value);
}
3. Метод для виклику події:
private void RaiseSearchRequestedEvent()
{
RoutedEventArgs args = new RoutedEventArgs(SearchRequestedEvent);
RaiseEvent(args);
}
public partial class SearchBox : UserControl
{
// RoutedEvent для події пошуку
public static readonly RoutedEvent SearchRequestedEvent =
EventManager.RegisterRoutedEvent(
nameof(SearchRequested),
RoutingStrategy.Bubble,
typeof(RoutedEventHandler),
typeof(SearchBox)
);
public event RoutedEventHandler SearchRequested
{
add => AddHandler(SearchRequestedEvent, value);
remove => RemoveHandler(SearchRequestedEvent, value);
}
private void RaiseSearchRequestedEvent()
{
RoutedEventArgs args = new RoutedEventArgs(SearchRequestedEvent);
RaiseEvent(args);
}
public SearchBox()
{
InitializeComponent();
// Викликати подію при натисканні Enter
SearchTextBox.KeyDown += (s, e) =>
{
if (e.Key == Key.Enter)
{
RaiseSearchRequestedEvent();
}
};
}
private void SearchButton_Click(object sender, RoutedEventArgs e)
{
RaiseSearchRequestedEvent();
}
}
Варіант 1: Підписка у XAML:
<controls:SearchBox SearchText="{Binding SearchQuery}"
SearchRequested="SearchBox_SearchRequested"/>
// Code-behind MainWindow
private void SearchBox_SearchRequested(object sender, RoutedEventArgs e)
{
var searchBox = (SearchBox)sender;
string query = searchBox.SearchText;
PerformSearch(query);
}
Варіант 2: Підписка у коді:
public MainWindow()
{
InitializeComponent();
MySearchBox.SearchRequested += (s, e) =>
{
PerformSearch(MySearchBox.SearchText);
};
}
Часто потрібно передати додаткові дані разом з подією. Для цього створюємо власний клас аргументів:
// Кастомні аргументи події
public class SearchRequestedEventArgs : RoutedEventArgs
{
public string SearchText { get; }
public SearchOptions Options { get; }
public SearchRequestedEventArgs(RoutedEvent routedEvent, string searchText, SearchOptions options)
: base(routedEvent)
{
SearchText = searchText;
Options = options;
}
}
// Делегат для події
public delegate void SearchRequestedEventHandler(object sender, SearchRequestedEventArgs e);
public partial class SearchBox : UserControl
{
// RoutedEvent з кастомним делегатом
public static readonly RoutedEvent SearchRequestedEvent =
EventManager.RegisterRoutedEvent(
nameof(SearchRequested),
RoutingStrategy.Bubble,
typeof(SearchRequestedEventHandler),
typeof(SearchBox)
);
public event SearchRequestedEventHandler SearchRequested
{
add => AddHandler(SearchRequestedEvent, value);
remove => RemoveHandler(SearchRequestedEvent, value);
}
private void RaiseSearchRequestedEvent()
{
var options = new SearchOptions
{
CaseSensitive = CaseSensitiveCheckBox.IsChecked == true,
WholeWord = WholeWordCheckBox.IsChecked == true
};
var args = new SearchRequestedEventArgs(SearchRequestedEvent, SearchText, options);
RaiseEvent(args);
}
}
Використання:
MySearchBox.SearchRequested += (s, e) =>
{
Console.WriteLine($"Пошук: {e.SearchText}");
Console.WriteLine($"Case Sensitive: {e.Options.CaseSensitive}");
Console.WriteLine($"Whole Word: {e.Options.WholeWord}");
PerformSearch(e.SearchText, e.Options);
};
WPF підтримує три стратегії маршрутизації подій:
| Стратегія | Напрямок | Використання |
|---|---|---|
Bubble | Знизу вгору (від дочірнього до батьківського) | Більшість подій (Click, MouseDown) |
Tunnel | Зверху вниз (від батьківського до дочірнього) | Preview-події (PreviewMouseDown) |
Direct | Лише на елементі-джерелі | Рідко використовується |
Приклад Bubble:
Button (Click) → StackPanel → Grid → Window
Приклад Tunnel:
Window (PreviewMouseDown) → Grid → StackPanel → Button
Для UserControl зазвичай використовується RoutingStrategy.Bubble — подія спливає від компонента до батьківських елементів.
Це найважливіший розділ статті. Більшість початківців роблять одну критичну помилку при створенні UserControl — встановлюють DataContext всередині компонента. Це ламає Binding і робить компонент непридатним для використання.
<!-- ❌ НЕПРАВИЛЬНО -->
<UserControl x:Class="MyApp.Controls.SearchBox"
DataContext="{Binding RelativeSource={RelativeSource Self}}">
<!-- Розмітка -->
</UserControl>
Або у code-behind:
// ❌ НЕПРАВИЛЬНО
public SearchBox()
{
InitializeComponent();
DataContext = this; // Це зламає зовнішній Binding!
}
Коли ви встановлюєте DataContext всередині UserControl, зовнішній Binding перестає працювати:
<!-- У MainWindow -->
<controls:SearchBox DataContext="{Binding SearchViewModel}"/>
<!-- SearchViewModel НЕ буде доступний всередині SearchBox! -->
Що відбувається:
DataContext SearchBox = SearchViewModelDataContext = thisDataContext = SearchBox, а не SearchViewModelSearchBox, а не у SearchViewModelЗамість встановлення DataContext, використовуйте RelativeSource Self або ElementName для прив'язки до DependencyProperty UserControl.
Варіант 1: ElementName (рекомендовано):
<UserControl x:Class="MyApp.Controls.SearchBox"
x:Name="Root">
<Border>
<TextBox Text="{Binding SearchText, ElementName=Root}"/>
<!-- ElementName=Root вказує на сам UserControl -->
</Border>
</UserControl>
Варіант 2: RelativeSource Self:
<UserControl x:Class="MyApp.Controls.SearchBox">
<Border>
<TextBox Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType=UserControl}}"/>
<!-- Шукає найближчий батьківський UserControl -->
</Border>
</UserControl>
Варіант 3: RelativeSource FindAncestor (для вкладених елементів):
<UserControl x:Class="MyApp.Controls.SearchBox">
<Border>
<StackPanel>
<TextBox Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType={x:Type local:SearchBox}}}"/>
<!-- Явно вказуємо тип предка -->
</StackPanel>
</Border>
</UserControl>
| Підхід | Переваги | Недоліки |
|---|---|---|
ElementName=Root | Простий, читабельний | Потрібно x:Name="Root" |
RelativeSource AncestorType=UserControl | Не потрібен x:Name | Довший синтаксис |
RelativeSource AncestorType={x:Type local:SearchBox} | Явна типізація | Найдовший синтаксис |
Рекомендація: Використовуйте ElementName=Root — це найпростіший та найчитабельніший варіант.
<UserControl x:Class="MyApp.Controls.SearchBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root">
<!-- НЕ встановлюємо DataContext! -->
<Border Background="White" BorderBrush="#e2e8f0" BorderThickness="1" CornerRadius="8" Padding="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="🔍" FontSize="16" VerticalAlignment="Center" Margin="0,0,8,0"/>
<!-- Прив'язка до DependencyProperty через ElementName -->
<TextBox Grid.Column="1"
x:Name="SearchTextBox"
Text="{Binding SearchText, ElementName=Root, UpdateSourceTrigger=PropertyChanged}"
BorderThickness="0"
Background="Transparent"
VerticalContentAlignment="Center"
FontSize="14"/>
<Button Grid.Column="2"
Content="✕"
Background="Transparent"
BorderThickness="0"
Padding="8,4"
Cursor="Hand"
Click="ClearButton_Click"
Visibility="{Binding ShowClearButton, ElementName=Root, Converter={StaticResource BoolToVisibilityConverter}}"/>
</Grid>
</Border>
</UserControl>
Code-behind:
public partial class SearchBox : UserControl
{
// DependencyProperty
public static readonly DependencyProperty SearchTextProperty =
DependencyProperty.Register(nameof(SearchText), typeof(string), typeof(SearchBox),
new PropertyMetadata(""));
public string SearchText
{
get => (string)GetValue(SearchTextProperty);
set => SetValue(SearchTextProperty, value);
}
public SearchBox()
{
InitializeComponent();
// НЕ встановлюємо DataContext!
}
private void ClearButton_Click(object sender, RoutedEventArgs e)
{
SearchText = "";
}
}
Використання:
<!-- У MainWindow — DataContext працює правильно -->
<Window DataContext="{Binding MainViewModel}">
<StackPanel>
<!-- SearchBox отримує DataContext від Window -->
<controls:SearchBox SearchText="{Binding SearchQuery}"/>
<!-- Інші контроли також бачать MainViewModel -->
<ListBox ItemsSource="{Binding Items}"/>
</StackPanel>
</Window>
DataContext всередині UserControlElementName або RelativeSource для прив'язки до DependencyPropertyЧому це важливо:<UserControl x:Name="Root">
<TextBox Text="{Binding MyProperty, ElementName=Root}"/>
</UserControl>
<UserControl DataContext="{Binding RelativeSource={RelativeSource Self}}">
<TextBox Text="{Binding MyProperty}"/>
</UserControl>
Для складних UserControl з багатьма властивостями та логікою зручно створити окрему ViewModel. Але як це зробити правильно, не порушуючи DataContext?
Уявімо складний компонент PaginationControl з логікою:
Вся ця логіка у code-behind буде громіздкою. Краще винести її у ViewModel.
Створюємо ViewModel для внутрішньої логіки компонента:
// ViewModels/PaginationViewModel.cs
public partial class PaginationViewModel : ObservableObject
{
[ObservableProperty]
private int _currentPage = 1;
[ObservableProperty]
private int _totalPages = 1;
[ObservableProperty]
private int _pageSize = 10;
// Обчислювана властивість: видимі сторінки
public ObservableCollection<int> VisiblePages { get; } = new();
partial void OnCurrentPageChanged(int value)
{
UpdateVisiblePages();
}
partial void OnTotalPagesChanged(int value)
{
UpdateVisiblePages();
}
private void UpdateVisiblePages()
{
VisiblePages.Clear();
// Логіка: показувати 5 сторінок навколо поточної
int start = Math.Max(1, CurrentPage - 2);
int end = Math.Min(TotalPages, CurrentPage + 2);
for (int i = start; i <= end; i++)
{
VisiblePages.Add(i);
}
}
[RelayCommand(CanExecute = nameof(CanGoToPreviousPage))]
private void GoToPreviousPage()
{
if (CurrentPage > 1)
CurrentPage--;
}
private bool CanGoToPreviousPage() => CurrentPage > 1;
[RelayCommand(CanExecute = nameof(CanGoToNextPage))]
private void GoToNextPage()
{
if (CurrentPage < TotalPages)
CurrentPage++;
}
private bool CanGoToNextPage() => CurrentPage < TotalPages;
[RelayCommand]
private void GoToFirstPage()
{
CurrentPage = 1;
}
[RelayCommand]
private void GoToLastPage()
{
CurrentPage = TotalPages;
}
[RelayCommand]
private void GoToPage(int page)
{
if (page >= 1 && page <= TotalPages)
CurrentPage = page;
}
}
// Controls/PaginationControl.xaml.cs
public partial class PaginationControl : UserControl
{
private readonly PaginationViewModel _viewModel;
// DependencyProperty для зовнішнього API
public static readonly DependencyProperty CurrentPageProperty =
DependencyProperty.Register(nameof(CurrentPage), typeof(int), typeof(PaginationControl),
new FrameworkPropertyMetadata(1, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnCurrentPageChanged));
public int CurrentPage
{
get => (int)GetValue(CurrentPageProperty);
set => SetValue(CurrentPageProperty, value);
}
private static void OnCurrentPageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (PaginationControl)d;
control._viewModel.CurrentPage = (int)e.NewValue;
}
public static readonly DependencyProperty TotalPagesProperty =
DependencyProperty.Register(nameof(TotalPages), typeof(int), typeof(PaginationControl),
new PropertyMetadata(1, OnTotalPagesChanged));
public int TotalPages
{
get => (int)GetValue(TotalPagesProperty);
set => SetValue(TotalPagesProperty, value);
}
private static void OnTotalPagesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (PaginationControl)d;
control._viewModel.TotalPages = (int)e.NewValue;
}
// RoutedEvent для повідомлення про зміну сторінки
public static readonly RoutedEvent PageChangedEvent =
EventManager.RegisterRoutedEvent(nameof(PageChanged), RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<int>), typeof(PaginationControl));
public event RoutedPropertyChangedEventHandler<int> PageChanged
{
add => AddHandler(PageChangedEvent, value);
remove => RemoveHandler(PageChangedEvent, value);
}
public PaginationControl()
{
InitializeComponent();
// Створюємо внутрішню ViewModel
_viewModel = new PaginationViewModel();
// Встановлюємо DataContext для КОРЕНЕВОГО елемента, а не UserControl
RootGrid.DataContext = _viewModel;
// Підписуємось на зміни у ViewModel
_viewModel.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(_viewModel.CurrentPage))
{
// Синхронізуємо назад до DependencyProperty
if (CurrentPage != _viewModel.CurrentPage)
{
CurrentPage = _viewModel.CurrentPage;
// Викликаємо RoutedEvent
var args = new RoutedPropertyChangedEventArgs<int>(
CurrentPage, _viewModel.CurrentPage, PageChangedEvent);
RaiseEvent(args);
}
}
};
}
}
Ключовий момент: RootGrid.DataContext = _viewModel — встановлюємо DataContext для кореневого Grid, а не для UserControl. Це дозволяє зовнішньому коду встановлювати свій DataContext для UserControl.
<UserControl x:Class="MyApp.Controls.PaginationControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root">
<!-- DataContext встановлюється для RootGrid, а не для UserControl -->
<Grid x:Name="RootGrid">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="4">
<!-- Перша сторінка -->
<Button Content="⏮️"
Command="{Binding GoToFirstPageCommand}"
Padding="8,4"
ToolTip="Перша сторінка"/>
<!-- Попередня сторінка -->
<Button Content="◀️"
Command="{Binding GoToPreviousPageCommand}"
Padding="8,4"
ToolTip="Попередня сторінка"/>
<!-- Кнопки сторінок -->
<ItemsControl ItemsSource="{Binding VisiblePages}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="4"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding}"
Command="{Binding DataContext.GoToPageCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
Padding="8,4"
MinWidth="32">
<Button.Style>
<Style TargetType="Button">
<Style.Triggers>
<DataTrigger Binding="{Binding}" Value="{Binding DataContext.CurrentPage, RelativeSource={RelativeSource AncestorType=UserControl}}">
<Setter Property="Background" Value="#3b82f6"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Наступна сторінка -->
<Button Content="▶️"
Command="{Binding GoToNextPageCommand}"
Padding="8,4"
ToolTip="Наступна сторінка"/>
<!-- Остання сторінка -->
<Button Content="⏭️"
Command="{Binding GoToLastPageCommand}"
Padding="8,4"
ToolTip="Остання сторінка"/>
<!-- Інформація про сторінки -->
<TextBlock Text="{Binding CurrentPage, StringFormat='Сторінка {0}'}"
VerticalAlignment="Center"
Margin="12,0,0,0"/>
<TextBlock Text="{Binding TotalPages, StringFormat='з {0}'}"
VerticalAlignment="Center"
Margin="4,0,0,0"/>
</StackPanel>
</Grid>
</UserControl>
<controls:PaginationControl CurrentPage="{Binding CurrentPage, Mode=TwoWay}"
TotalPages="{Binding TotalPages}"
PageChanged="PaginationControl_PageChanged"/>
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private int _currentPage = 1;
[ObservableProperty]
private int _totalPages = 10;
[ObservableProperty]
private ObservableCollection<Item> _items = new();
partial void OnCurrentPageChanged(int value)
{
LoadPage(value);
}
private async void LoadPage(int page)
{
var items = await _dataService.GetPageAsync(page, pageSize: 20);
Items = new ObservableCollection<Item>(items);
}
}
Мета: Навчитися створювати базовий UserControl з DependencyProperty.
Завдання:
Створіть перевикористовуваний компонент SearchBox:
SearchText (string) — текст пошукуPlaceholder (string) — placeholder для TextBoxКритерії успіху:
Підказка:
public partial class SearchBox : UserControl
{
public static readonly DependencyProperty SearchTextProperty =
DependencyProperty.Register(
nameof(SearchText),
typeof(string),
typeof(SearchBox),
new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)
);
public string SearchText
{
get => (string)GetValue(SearchTextProperty);
set => SetValue(SearchTextProperty, value);
}
public static readonly DependencyProperty PlaceholderProperty =
DependencyProperty.Register(
nameof(Placeholder),
typeof(string),
typeof(SearchBox),
new PropertyMetadata("Введіть текст для пошуку...")
);
public string Placeholder
{
get => (string)GetValue(PlaceholderProperty);
set => SetValue(PlaceholderProperty, value);
}
public SearchBox()
{
InitializeComponent();
}
private void ClearButton_Click(object sender, RoutedEventArgs e)
{
SearchText = "";
}
}
XAML підказка:
<UserControl x:Name="Root">
<Border BorderBrush="#e2e8f0" BorderThickness="1" CornerRadius="8" Padding="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding SearchText, ElementName=Root, UpdateSourceTrigger=PropertyChanged}"
BorderThickness="0"/>
<Button Grid.Column="1"
Content="✕"
Click="ClearButton_Click"/>
</Grid>
</Border>
</UserControl>
Мета: Навчитися створювати складніший UserControl з кількома DependencyProperty.
Завдання:
Створіть компонент HeaderControl для заголовків екранів:
Icon (string) — emoji або символ іконкиTitle (string) — основний заголовокSubtitle (string) — підзаголовокShowActionButton (bool) — показувати кнопку діїActionButtonText (string) — текст кнопки діїActionButtonClicked — подія при натисканні кнопки діїКритерії успіху:
Підказка для RoutedEvent:
public static readonly RoutedEvent ActionButtonClickedEvent =
EventManager.RegisterRoutedEvent(
nameof(ActionButtonClicked),
RoutingStrategy.Bubble,
typeof(RoutedEventHandler),
typeof(HeaderControl)
);
public event RoutedEventHandler ActionButtonClicked
{
add => AddHandler(ActionButtonClickedEvent, value);
remove => RemoveHandler(ActionButtonClickedEvent, value);
}
private void ActionButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ActionButtonClickedEvent));
}
Використання:
<controls:HeaderControl Icon="📁"
Title="Мої файли"
Subtitle="125 файлів, 2.4 GB"
ShowActionButton="True"
ActionButtonText="Додати файл"
ActionButtonClicked="HeaderControl_ActionButtonClicked"/>
Мета: Навчитися створювати складний UserControl з власною ViewModel.
Завдання:
Створіть повноцінний компонент пагінації:
CurrentPage (int) — поточна сторінкаTotalPages (int) — загальна кількість сторінокVisiblePages (ObservableCollectionCurrentPage (int, TwoWay) — синхронізується з ViewModelTotalPages (int) — синхронізується з ViewModelPageSize (int) — кількість елементів на сторінціPageChanged — подія при зміні сторінкиКритерії успіху:
Підказка для ViewModel:
public partial class PaginationViewModel : ObservableObject
{
[ObservableProperty]
private int _currentPage = 1;
[ObservableProperty]
private int _totalPages = 1;
public ObservableCollection<int> VisiblePages { get; } = new();
partial void OnCurrentPageChanged(int value)
{
UpdateVisiblePages();
GoToPreviousPageCommand.NotifyCanExecuteChanged();
GoToNextPageCommand.NotifyCanExecuteChanged();
}
partial void OnTotalPagesChanged(int value)
{
UpdateVisiblePages();
}
private void UpdateVisiblePages()
{
VisiblePages.Clear();
// Показувати 5 сторінок навколо поточної
int start = Math.Max(1, CurrentPage - 2);
int end = Math.Min(TotalPages, CurrentPage + 2);
// Якщо менше 5 сторінок — показати всі
if (end - start < 4)
{
if (CurrentPage <= 3)
end = Math.Min(5, TotalPages);
else
start = Math.Max(1, TotalPages - 4);
}
for (int i = start; i <= end; i++)
{
VisiblePages.Add(i);
}
}
[RelayCommand(CanExecute = nameof(CanGoToPreviousPage))]
private void GoToPreviousPage()
{
if (CurrentPage > 1)
CurrentPage--;
}
private bool CanGoToPreviousPage() => CurrentPage > 1;
[RelayCommand(CanExecute = nameof(CanGoToNextPage))]
private void GoToNextPage()
{
if (CurrentPage < TotalPages)
CurrentPage++;
}
private bool CanGoToNextPage() => CurrentPage < TotalPages;
}
Використання:
<controls:PaginationControl CurrentPage="{Binding CurrentPage, Mode=TwoWay}"
TotalPages="{Binding TotalPages}"
PageChanged="PaginationControl_PageChanged"
Margin="0,16,0,0"/>
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private int _currentPage = 1;
[ObservableProperty]
private int _totalPages;
[ObservableProperty]
private ObservableCollection<Item> _items = new();
partial void OnCurrentPageChanged(int value)
{
LoadPageAsync(value);
}
private async void LoadPageAsync(int page)
{
var result = await _dataService.GetPageAsync(page, pageSize: 20);
Items = new ObservableCollection<Item>(result.Items);
TotalPages = result.TotalPages;
}
}
UserControl — це потужний інструмент для створення перевикористовуваних UI-компонентів у WPF. Правильне використання DependencyProperty, RoutedEvent та уникнення DataContext gotcha робить компоненти професійними та зручними.
Ключові висновки:
🧩 Composition
🔌 DependencyProperty
📡 RoutedEvent
⚠️ DataContext Gotcha
🏗️ Internal ViewModel
♻️ Reusability
Переваги UserControl:
Порівняння підходів:
| Підхід | Складність | Перевикористовуваність | Тестованість | Використання |
|---|---|---|---|---|
| Inline XAML | Низька | Низька | Низька | Унікальний UI |
| UserControl | Середня | Висока | Середня | Повторюваний UI |
| UserControl + ViewModel | Висока | Висока | Висока | Складні компоненти |
| CustomControl | Дуже висока | Дуже висока | Висока | Бібліотеки контролів |
Що далі?
Ви завершили статтю про UserControl! Наступні теми:
Діалоги та File Pickers у WPF
Стандартні діалогові вікна WPF: MessageBox, OpenFileDialog, SaveFileDialog, FolderBrowserDialog. Custom Dialogs через Window.ShowDialog() та MVVM-friendly Dialog Service pattern.
Custom Controls: Lookless Controls у WPF
Різниця між UserControl та Custom Control. Створення lookless контролів з Template Parts, DefaultStyleKey, OnApplyTemplate та Automation Peers для accessibility.