Уявіть, що ви створюєте бібліотеку UI-компонентів для всієї компанії. Дизайнери кожного проєкту хочуть використовувати ваші компоненти, але з різним зовнішнім виглядом: один проєкт має Material Design, інший — Fluent Design, третій — власний корпоративний стиль. Як створити компонент, що має поведінку, але дозволяє повністю змінити зовнішність?
Відповідь — Custom Control (також відомий як Lookless Control). На відміну від UserControl, що має фіксовану розмітку, Custom Control — це лише логіка та поведінка. Зовнішній вигляд визначається через ControlTemplate, який можна повністю замінити без зміни коду.
Це фундаментальна різниця у філософії. UserControl каже: "Ось мій UI, використовуй його як є". Custom Control каже: "Ось моя поведінка, намалюй мені будь-який UI". Всі вбудовані WPF-контроли (Button, TextBox, ListBox) — це Custom Controls. Ви можете повністю змінити їхній вигляд через Style та ControlTemplate, але поведінка залишається незмінною.
У цій статті ми детально розберемо створення Custom Controls: від базової структури до складних концепцій як Template Parts, OnApplyTemplate та Automation Peers. Ви навчитесь створювати професійні контроли, що можуть використовуватись у бібліотеках компонентів та дизайн-системах.
Перш ніж занурюватись у створення Custom Control, важливо зрозуміти, чим він відрізняється від UserControl і коли використовувати кожен.
UserControl — це контейнер, що складається з інших контролів. Він має фіксовану розмітку у XAML-файлі.
Характеристики:
Приклад: SearchBox з TextBox та Button — завжди має однакову структуру.
Custom Control — це клас з логікою, але без фіксованого UI. Зовнішній вигляд визначається через ControlTemplate.
Характеристики:
Приклад: Button — може виглядати як завгодно (плоска кнопка, 3D-кнопка, іконка), але поведінка (Click, IsPressed) незмінна.
| Аспект | UserControl | Custom Control |
|---|---|---|
| Структура | XAML + code-behind | Клас + Generic.xaml |
| UI | Фіксований | Змінний через ControlTemplate |
| Складність | Низька | Висока |
| Перевикористовуваність | Середня | Висока |
| Тематизація | Обмежена | Повна |
| Використання | Конкретний додаток | Бібліотеки компонентів |
| Приклади | SearchBox, HeaderPanel | Button, Slider, DatePicker |
Використовуйте UserControl коли:
Використовуйте Custom Control коли:
Розберемо покроковий процес створення Custom Control на прикладі NumericUpDown — контролу для введення чисел з кнопками +/-.
У Visual Studio:
NumericUpDown.csThemes з файлом Generic.xamlВручну (для розуміння структури):
Створіть клас NumericUpDown.cs:
using System.Windows;
using System.Windows.Controls;
namespace MyApp.Controls;
public class NumericUpDown : Control
{
// Статичний конструктор — викликається один раз при завантаженні типу
static NumericUpDown()
{
// Реєструємо стиль за замовчуванням
DefaultStyleKeyProperty.OverrideMetadata(
typeof(NumericUpDown),
new FrameworkPropertyMetadata(typeof(NumericUpDown))
);
}
// Звичайний конструктор — викликається для кожного екземпляра
public NumericUpDown()
{
// Ініціалізація
}
}
Ключові моменти:
Control, а не UserControlDefaultStyleKeyDefaultStyleKey вказує WPF, де шукати стиль за замовчуваннямСтворіть папку Themes у корені проєкту та файл Generic.xaml:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MyApp.Controls">
<!-- Стиль за замовчуванням для NumericUpDown -->
<Style TargetType="{x:Type local:NumericUpDown}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:NumericUpDown}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- TextBox для відображення значення -->
<TextBox Grid.Column="0"
x:Name="PART_TextBox"
Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}, UpdateSourceTrigger=PropertyChanged}"
VerticalContentAlignment="Center"
Padding="8,4"
BorderThickness="0"/>
<!-- Кнопки +/- -->
<StackPanel Grid.Column="1" Orientation="Vertical">
<Button x:Name="PART_UpButton"
Content="▲"
FontSize="8"
Padding="8,2"
BorderThickness="0"/>
<Button x:Name="PART_DownButton"
Content="▼"
FontSize="8"
Padding="8,2"
BorderThickness="0"/>
</StackPanel>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
Важливо: Файл Generic.xaml має бути у папці Themes і мати Build Action = "Page".
Переконайтесь, що Generic.xaml має правильні властивості:
У .csproj:
<ItemGroup>
<Page Include="Themes\Generic.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
</ItemGroup>
Або у властивостях файлу:
Додаємо властивість Value для зберігання числа:
public class NumericUpDown : Control
{
static NumericUpDown()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(NumericUpDown),
new FrameworkPropertyMetadata(typeof(NumericUpDown))
);
}
// DependencyProperty для значення
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(
nameof(Value),
typeof(double),
typeof(NumericUpDown),
new FrameworkPropertyMetadata(
0.0,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue
)
);
public double Value
{
get => (double)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
// Callback при зміні значення
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (NumericUpDown)d;
double newValue = (double)e.NewValue;
// Можна додати логіку при зміні
control.RaiseValueChangedEvent(newValue);
}
// Coerce — валідація та корекція значення
private static object CoerceValue(DependencyObject d, object baseValue)
{
var control = (NumericUpDown)d;
double value = (double)baseValue;
// Обмежуємо значення між Minimum та Maximum
if (value < control.Minimum)
return control.Minimum;
if (value > control.Maximum)
return control.Maximum;
return value;
}
// Додаткові властивості
public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register(nameof(Minimum), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(double.MinValue, OnMinMaxChanged));
public double Minimum
{
get => (double)GetValue(MinimumProperty);
set => SetValue(MinimumProperty, value);
}
public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register(nameof(Maximum), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(double.MaxValue, OnMinMaxChanged));
public double Maximum
{
get => (double)GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
public static readonly DependencyProperty IncrementProperty =
DependencyProperty.Register(nameof(Increment), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(1.0));
public double Increment
{
get => (double)GetValue(IncrementProperty);
set => SetValue(IncrementProperty, value);
}
private static void OnMinMaxChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (NumericUpDown)d;
// Перевалідувати Value при зміні Minimum/Maximum
control.CoerceValue(ValueProperty);
}
}
<Window xmlns:controls="clr-namespace:MyApp.Controls">
<StackPanel Margin="20">
<TextBlock Text="Кількість:" Margin="0,0,0,4"/>
<controls:NumericUpDown Value="{Binding Quantity}"
Minimum="0"
Maximum="100"
Increment="1"
Width="150"
HorizontalAlignment="Left"/>
</StackPanel>
</Window>
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<Border Background="White"
BorderBrush="#e2e8f0"
BorderThickness="1"
CornerRadius="4"
Width="150">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="42"
VerticalContentAlignment="Center"
Padding="8,4"
BorderThickness="0"/>
<StackPanel Grid.Column="1" Orientation="Vertical">
<Button Content="▲"
FontSize="8"
Padding="8,2"
BorderThickness="0"
Command="{Binding ShowMessageCommand}"
CommandParameter="Value збільшено на Increment"/>
<Button Content="▼"
FontSize="8"
Padding="8,2"
BorderThickness="0"
Command="{Binding ShowMessageCommand}"
CommandParameter="Value зменшено на Increment"/>
</StackPanel>
</Grid>
</Border>
У попередньому прикладі ми створили шаблон з кнопками PART_UpButton та PART_DownButton, але вони ще не працюють. Щоб код контролу міг взаємодіяти з елементами шаблону, використовуються Template Parts.
Template Part — це іменований елемент у ControlTemplate, до якого звертається код контролу. Це контракт між кодом та шаблоном: "Якщо у шаблоні є елемент з таким іменем — я буду з ним працювати".
Конвенція іменування: PART_ + описова назва (наприклад, PART_UpButton, PART_TextBox).
Атрибут [TemplatePart] документує, які частини шаблону очікує контрол:
[TemplatePart(Name = "PART_UpButton", Type = typeof(Button))]
[TemplatePart(Name = "PART_DownButton", Type = typeof(Button))]
[TemplatePart(Name = "PART_TextBox", Type = typeof(TextBox))]
public class NumericUpDown : Control
{
// ...
}
Важливо: Атрибут [TemplatePart] — це лише документація. Він не змушує шаблон містити ці елементи. Код контролу має перевіряти наявність частин.
Метод OnApplyTemplate() викликається WPF, коли шаблон застосовується до контролу. Тут ми шукаємо Template Parts та підписуємось на їхні події:
[TemplatePart(Name = "PART_UpButton", Type = typeof(Button))]
[TemplatePart(Name = "PART_DownButton", Type = typeof(Button))]
[TemplatePart(Name = "PART_TextBox", Type = typeof(TextBox))]
public class NumericUpDown : Control
{
private Button? _upButton;
private Button? _downButton;
private TextBox? _textBox;
public override void OnApplyTemplate()
{
// ВАЖЛИВО: спочатку викликати base
base.OnApplyTemplate();
// Відписатись від старих елементів (якщо шаблон змінився)
if (_upButton != null)
_upButton.Click -= UpButton_Click;
if (_downButton != null)
_downButton.Click -= DownButton_Click;
if (_textBox != null)
_textBox.PreviewKeyDown -= TextBox_PreviewKeyDown;
// Знайти нові елементи
_upButton = GetTemplateChild("PART_UpButton") as Button;
_downButton = GetTemplateChild("PART_DownButton") as Button;
_textBox = GetTemplateChild("PART_TextBox") as TextBox;
// Підписатись на події нових елементів
if (_upButton != null)
_upButton.Click += UpButton_Click;
if (_downButton != null)
_downButton.Click += DownButton_Click;
if (_textBox != null)
_textBox.PreviewKeyDown += TextBox_PreviewKeyDown;
}
private void UpButton_Click(object sender, RoutedEventArgs e)
{
Value += Increment;
}
private void DownButton_Click(object sender, RoutedEventArgs e)
{
Value -= Increment;
}
private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Up)
{
Value += Increment;
e.Handled = true;
}
else if (e.Key == Key.Down)
{
Value -= Increment;
e.Handled = true;
}
}
}
GetTemplateChild(string name) повертає DependencyObject? — потрібно приведення типу:
// ✅ Правильно — перевірка типу
_upButton = GetTemplateChild("PART_UpButton") as Button;
if (_upButton != null)
{
_upButton.Click += UpButton_Click;
}
// ❌ Неправильно — може кинути InvalidCastException
_upButton = (Button)GetTemplateChild("PART_UpButton");
Чому Template Part може бути null:
Правило: Завжди перевіряйте null перед використанням Template Part.
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace MyApp.Controls;
[TemplatePart(Name = "PART_UpButton", Type = typeof(Button))]
[TemplatePart(Name = "PART_DownButton", Type = typeof(Button))]
[TemplatePart(Name = "PART_TextBox", Type = typeof(TextBox))]
public class NumericUpDown : Control
{
private Button? _upButton;
private Button? _downButton;
private TextBox? _textBox;
static NumericUpDown()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(NumericUpDown),
new FrameworkPropertyMetadata(typeof(NumericUpDown))
);
}
public NumericUpDown()
{
// Підтримка клавіатури на рівні контролу
this.PreviewKeyDown += (s, e) =>
{
if (e.Key == Key.Up)
{
Value += Increment;
e.Handled = true;
}
else if (e.Key == Key.Down)
{
Value -= Increment;
e.Handled = true;
}
};
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
// Відписатись від старих елементів
if (_upButton != null)
_upButton.Click -= UpButton_Click;
if (_downButton != null)
_downButton.Click -= DownButton_Click;
// Знайти нові елементи
_upButton = GetTemplateChild("PART_UpButton") as Button;
_downButton = GetTemplateChild("PART_DownButton") as Button;
_textBox = GetTemplateChild("PART_TextBox") as TextBox;
// Підписатись на події
if (_upButton != null)
_upButton.Click += UpButton_Click;
if (_downButton != null)
_downButton.Click += DownButton_Click;
}
private void UpButton_Click(object sender, RoutedEventArgs e)
{
Value += Increment;
}
private void DownButton_Click(object sender, RoutedEventArgs e)
{
Value -= Increment;
}
// DependencyProperty (з попереднього розділу)
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(
nameof(Value),
typeof(double),
typeof(NumericUpDown),
new FrameworkPropertyMetadata(
0.0,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
null,
CoerceValue
)
);
public double Value
{
get => (double)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register(nameof(Minimum), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(double.MinValue, OnMinMaxChanged));
public double Minimum
{
get => (double)GetValue(MinimumProperty);
set => SetValue(MinimumProperty, value);
}
public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register(nameof(Maximum), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(double.MaxValue, OnMinMaxChanged));
public double Maximum
{
get => (double)GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
public static readonly DependencyProperty IncrementProperty =
DependencyProperty.Register(nameof(Increment), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(1.0));
public double Increment
{
get => (double)GetValue(IncrementProperty);
set => SetValue(IncrementProperty, value);
}
private static object CoerceValue(DependencyObject d, object baseValue)
{
var control = (NumericUpDown)d;
double value = (double)baseValue;
if (value < control.Minimum)
return control.Minimum;
if (value > control.Maximum)
return control.Maximum;
return value;
}
private static void OnMinMaxChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (NumericUpDown)d;
control.CoerceValue(ValueProperty);
}
}
Головна перевага Custom Control — можливість повністю змінити зовнішній вигляд без зміни коду. Розберемо кілька прикладів.
<Window.Resources>
<Style x:Key="HorizontalNumericUpDown" TargetType="{x:Type local:NumericUpDown}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:NumericUpDown}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Кнопка - зліва -->
<Button Grid.Column="0"
x:Name="PART_DownButton"
Content="−"
FontSize="16"
Padding="12,4"
BorderThickness="0"/>
<!-- TextBox по центру -->
<TextBox Grid.Column="1"
x:Name="PART_TextBox"
Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}, UpdateSourceTrigger=PropertyChanged}"
TextAlignment="Center"
VerticalContentAlignment="Center"
Padding="8,4"
BorderThickness="0"/>
<!-- Кнопка + справа -->
<Button Grid.Column="2"
x:Name="PART_UpButton"
Content="+"
FontSize="16"
Padding="12,4"
BorderThickness="0"/>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<local:NumericUpDown Style="{StaticResource HorizontalNumericUpDown}"
Value="{Binding Quantity}"
Width="200"/>
Ключовий момент: Код контролу не змінився — ми лише замінили ControlTemplate. Кнопки PART_UpButton та PART_DownButton все ще працюють, бо OnApplyTemplate знаходить їх за іменем.
<Style x:Key="MinimalNumericUpDown" TargetType="{x:Type local:NumericUpDown}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:NumericUpDown}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<!-- Лише TextBox — без кнопок -->
<TextBox x:Name="PART_TextBox"
Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}, UpdateSourceTrigger=PropertyChanged}"
VerticalContentAlignment="Center"
Padding="8,4"
BorderThickness="0"/>
<!-- PART_UpButton та PART_DownButton відсутні -->
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Що відбувається:
OnApplyTemplate викликаєтьсяGetTemplateChild("PART_UpButton") повертає nullif (_upButton != null) — falseЦе демонструє гнучкість Template Parts — вони опціональні. Контрол має працювати навіть якщо частини шаблону відсутні.
<Style x:Key="SliderNumericUpDown" TargetType="{x:Type local:NumericUpDown}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:NumericUpDown}">
<StackPanel>
<!-- Відображення значення -->
<TextBlock Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}, StringFormat='{}{0:F2}'}"
FontSize="24"
FontWeight="Bold"
HorizontalAlignment="Center"
Margin="0,0,0,8"/>
<!-- Slider для зміни значення -->
<Slider Minimum="{Binding Minimum, RelativeSource={RelativeSource TemplatedParent}}"
Maximum="{Binding Maximum, RelativeSource={RelativeSource TemplatedParent}}"
Value="{Binding Value, RelativeSource={RelativeSource TemplatedParent}}"
TickFrequency="{Binding Increment, RelativeSource={RelativeSource TemplatedParent}}"
IsSnapToTickEnabled="True"/>
</StackPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Тут взагалі немає Template Parts — весь UI побудований на Binding до DependencyProperty контролу. Це теж валідний підхід для простих випадків.
AutomationPeer — це клас, що надає інформацію про контрол для accessibility-технологій (screen readers) та UI Automation (автоматизоване тестування).
Для accessibility:
Для UI Automation:
using System.Windows.Automation.Peers;
using System.Windows.Automation.Provider;
namespace MyApp.Controls;
// AutomationPeer для NumericUpDown
public class NumericUpDownAutomationPeer : FrameworkElementAutomationPeer, IRangeValueProvider
{
public NumericUpDownAutomationPeer(NumericUpDown owner) : base(owner)
{
}
private NumericUpDown NumericUpDown => (NumericUpDown)Owner;
// Ім'я типу контролу для UI Automation
protected override string GetClassNameCore()
{
return "NumericUpDown";
}
// Тип контролу (Spinner — найближчий стандартний тип)
protected override AutomationControlType GetAutomationControlTypeCore()
{
return AutomationControlType.Spinner;
}
// Підтримувані патерни (IRangeValueProvider для числових значень)
public override object GetPattern(PatternInterface patternInterface)
{
if (patternInterface == PatternInterface.RangeValue)
return this;
return base.GetPattern(patternInterface);
}
// Реалізація IRangeValueProvider
public bool IsReadOnly => false;
public double LargeChange => NumericUpDown.Increment * 10;
public double Maximum => NumericUpDown.Maximum;
public double Minimum => NumericUpDown.Minimum;
public double SmallChange => NumericUpDown.Increment;
public double Value => NumericUpDown.Value;
public void SetValue(double value)
{
if (!IsEnabled())
throw new ElementNotEnabledException();
NumericUpDown.Value = value;
}
}
public class NumericUpDown : Control
{
// ... інший код ...
// Перевизначаємо метод для створення AutomationPeer
protected override AutomationPeer OnCreateAutomationPeer()
{
return new NumericUpDownAutomationPeer(this);
}
}
// UI Automation тест
[Test]
public void NumericUpDown_SetValue_UpdatesControl()
{
// Arrange
var window = new MainWindow();
window.Show();
var numericUpDown = window.FindName("MyNumericUpDown") as NumericUpDown;
var peer = UIElementAutomationPeer.CreatePeerForElement(numericUpDown);
var rangeValueProvider = peer.GetPattern(PatternInterface.RangeValue) as IRangeValueProvider;
// Act
rangeValueProvider.SetValue(42);
// Assert
Assert.AreEqual(42, numericUpDown.Value);
}
З правильним AutomationPeer, screen reader озвучить контрол:
"NumericUpDown, spinner, value 42, minimum 0, maximum 100"
Користувач може:
Мета: Навчитися створювати базовий Custom Control з Template Parts.
Завдання:
Створіть повноцінний контрол NumericUpDown:
ControlDefaultStyleKey у статичному конструкторіValue, Minimum, Maximum, IncrementPART_UpButton (Button) — збільшити значенняPART_DownButton (Button) — зменшити значенняPART_TextBox (TextBox) — відображення та редагування значенняКритерії успіху:
Підказка:
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (_upButton != null)
_upButton.Click -= UpButton_Click;
if (_downButton != null)
_downButton.Click -= DownButton_Click;
_upButton = GetTemplateChild("PART_UpButton") as Button;
_downButton = GetTemplateChild("PART_DownButton") as Button;
if (_upButton != null)
_upButton.Click += UpButton_Click;
if (_downButton != null)
_downButton.Click += DownButton_Click;
}
Мета: Навчитися створювати Custom Control з динамічним UI.
Завдання:
Створіть контрол для відображення та редагування рейтингу (1-5 зірочок):
Rating (double, 0-5)MaxRating (int, за замовчуванням 5)IsReadOnly (bool)Критерії успіху:
Підказка для шаблону:
<ItemsControl ItemsSource="{Binding Stars, RelativeSource={RelativeSource TemplatedParent}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="★"
FontSize="24"
Cursor="Hand"
MouseLeftButtonDown="Star_Click"
MouseEnter="Star_MouseEnter"
MouseLeave="Star_MouseLeave">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#cbd5e1"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsFilled}" Value="True">
<Setter Property="Foreground" Value="#fbbf24"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
Мета: Навчитися створювати Custom Control з власним рендерингом через OnRender.
Завдання:
Створіть круговий прогрес-бар (як у мобільних додатках):
Value (double, 0-100)Thickness (double, товщина кільця)StartAngle (double, кут початку)ProgressBrush (Brush, колір прогресу)TrackBrush (Brush, колір треку)OnRender(DrawingContext dc)Критерії успіху:
Підказка для OnRender:
protected override void OnRender(DrawingContext dc)
{
base.OnRender(dc);
double width = ActualWidth;
double height = ActualHeight;
double size = Math.Min(width, height);
double radius = (size - Thickness) / 2;
Point center = new Point(width / 2, height / 2);
// Малюємо трек (повне коло)
dc.DrawEllipse(
null,
new Pen(TrackBrush, Thickness),
center,
radius,
radius
);
// Малюємо прогрес (дуга)
double angle = (Value / 100.0) * 360.0;
if (angle > 0)
{
var geometry = CreateArcGeometry(center, radius, StartAngle, angle);
dc.DrawGeometry(null, new Pen(ProgressBrush, Thickness), geometry);
}
// Малюємо текст по центру
var text = new FormattedText(
$"{Value:F0}%",
CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface("Segoe UI"),
size / 4,
Foreground,
VisualTreeHelper.GetDpi(this).PixelsPerDip
);
dc.DrawText(text, new Point(
center.X - text.Width / 2,
center.Y - text.Height / 2
));
}
private PathGeometry CreateArcGeometry(Point center, double radius, double startAngle, double sweepAngle)
{
double startRad = (startAngle - 90) * Math.PI / 180;
double endRad = (startAngle + sweepAngle - 90) * Math.PI / 180;
Point startPoint = new Point(
center.X + radius * Math.Cos(startRad),
center.Y + radius * Math.Sin(startRad)
);
Point endPoint = new Point(
center.X + radius * Math.Cos(endRad),
center.Y + radius * Math.Sin(endRad)
);
bool isLargeArc = sweepAngle > 180;
var figure = new PathFigure
{
StartPoint = startPoint,
Segments = new PathSegmentCollection
{
new ArcSegment
{
Point = endPoint,
Size = new Size(radius, radius),
IsLargeArc = isLargeArc,
SweepDirection = SweepDirection.Clockwise
}
}
};
return new PathGeometry { Figures = new PathFigureCollection { figure } };
}
Підказка для анімації:
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (CircularProgressBar)d;
// Анімація зміни Value
var animation = new DoubleAnimation
{
From = (double)e.OldValue,
To = (double)e.NewValue,
Duration = TimeSpan.FromMilliseconds(300),
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
};
control.BeginAnimation(ValueProperty, animation);
}
Custom Controls — це професійний інструмент для створення перевикористовуваних компонентів з повною свободою зміни зовнішнього вигляду.
Ключові висновки:
🎭 Lookless
🧩 Template Parts
📁 Generic.xaml
♿ Accessibility
🎨 Customization
📚 Libraries
Переваги Custom Control:
Порівняння підходів:
| Аспект | UserControl | Custom Control |
|---|---|---|
| Складність створення | Низька | Висока |
| Гнучкість UI | Низька | Висока |
| Тематизація | Обмежена | Повна |
| Accessibility | Базова | Повна (через AutomationPeer) |
| Використання | Конкретний додаток | Бібліотеки компонентів |
| Приклади | SearchBox, HeaderPanel | Button, Slider, DatePicker |
Що далі?
Ви завершили статтю про Custom Controls! Наступні теми:
UserControl: компонентний підхід у WPF
Створення перевикористовуваних UI-компонентів через UserControl. DependencyProperty як public API, кастомні RoutedEvent, DataContext gotcha та патерни для складних контролів з власними ViewModel.
Avalonia TemplatedControl — Lookless Controls
Створення custom controls в Avalonia через TemplatedControl. StyledProperty замість DependencyProperty, Generic.axaml, ControlTheme та CSS-like стилізація з pseudo-classes.