У попередній статті ми розглянули Custom Controls у WPF — lookless контроли з поведінкою, але без фіксованого UI. Avalonia має аналогічну концепцію через TemplatedControl, але з кількома важливими відмінностями, що роблять створення контролів простішим та зручнішим.
Ключові відмінності Avalonia від WPF:
StyledProperty замість DependencyProperty — простіший синтаксисGeneric.axaml замість Generic.xaml — інший namespaceControlTheme замість Style — сучасніший підхід:pointerover, :pressed) — зручніша стилізаціяУ цій статті ми портуємо NumericUpDown з WPF на Avalonia та розглянемо всі особливості створення TemplatedControl.
TemplatedControl — це базовий клас Avalonia для створення контролів з шаблонами. Він аналогічний Control у WPF, але з деякими спрощеннями.
| Аспект | WPF | Avalonia |
|---|---|---|
| Базовий клас | Control | TemplatedControl |
| Властивості | DependencyProperty | StyledProperty |
| Шаблон | Generic.xaml | Generic.axaml |
| Стиль | Style | ControlTheme |
| Стани | Visual States | Pseudo-classes |
| Платформи | Лише Windows | Windows, macOS, Linux, mobile |
NumericUpDown.cs:
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
namespace MyApp.Controls;
public class NumericUpDown : TemplatedControl
{
// StyledProperty для Value
public static readonly StyledProperty<double> ValueProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Value),
defaultValue: 0.0,
defaultBindingMode: Avalonia.Data.BindingMode.TwoWay
);
public double Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
// StyledProperty для Minimum
public static readonly StyledProperty<double> MinimumProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Minimum),
defaultValue: double.MinValue
);
public double Minimum
{
get => GetValue(MinimumProperty);
set => SetValue(MinimumProperty, value);
}
// StyledProperty для Maximum
public static readonly StyledProperty<double> MaximumProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Maximum),
defaultValue: double.MaxValue
);
public double Maximum
{
get => GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
// StyledProperty для Increment
public static readonly StyledProperty<double> IncrementProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Increment),
defaultValue: 1.0
);
public double Increment
{
get => GetValue(IncrementProperty);
set => SetValue(IncrementProperty, value);
}
}
Ключові відмінності від WPF:
TemplatedControl, а не ControlStyledProperty замість DependencyProperty — простіший синтаксисAvaloniaProperty.Register<TOwner, TValue>() — типізований APIDefaultStyleKeydefaultBindingMode замість FrameworkPropertyMetadataРозберемо детально відмінності між WPF та Avalonia у створенні властивостей.
// WPF — багато boilerplate коду
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);
}
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (NumericUpDown)d;
// Логіка
}
private static object CoerceValue(DependencyObject d, object baseValue)
{
var control = (NumericUpDown)d;
double value = (double)baseValue;
// Валідація
return value;
}
// Avalonia — простіший синтаксис
public static readonly StyledProperty<double> ValueProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Value),
defaultValue: 0.0,
defaultBindingMode: BindingMode.TwoWay,
validate: ValidateValue,
coerce: CoerceValue
);
public double Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
// Валідація (опціонально)
private static bool ValidateValue(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
// Coerce (опціонально)
private static double CoerceValue(AvaloniaObject instance, double value)
{
var control = (NumericUpDown)instance;
if (value < control.Minimum)
return control.Minimum;
if (value > control.Maximum)
return control.Maximum;
return value;
}
Переваги Avalonia:
Register<TOwner, TValue>public static readonly StyledProperty<double> ValueProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Value),
defaultValue: 0.0,
defaultBindingMode: BindingMode.TwoWay
);
public double Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
// Підписка на зміни у конструкторі
public NumericUpDown()
{
ValueProperty.Changed.AddClassHandler<NumericUpDown>((sender, e) =>
{
sender.OnValueChanged(e);
});
}
private void OnValueChanged(AvaloniaPropertyChangedEventArgs<double> e)
{
// e.OldValue — старе значення
// e.NewValue — нове значення
// Валідація
if (e.NewValue < Minimum || e.NewValue > Maximum)
{
Value = CoerceValue(this, e.NewValue);
}
}
У WPF стилі за замовчуванням зберігаються у Themes/Generic.xaml. В Avalonia — у Themes/Generic.axaml.
Themes/Generic.axaml:
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:MyApp.Controls">
<!-- ControlTheme для NumericUpDown -->
<ControlTheme x:Key="{x:Type controls:NumericUpDown}" TargetType="controls:NumericUpDown">
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid ColumnDefinitions="*,Auto">
<!-- TextBox для відображення значення -->
<TextBox Grid.Column="0"
x:Name="PART_TextBox"
Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}}"
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>
</ControlTheme>
</ResourceDictionary>
Ключові відмінності від WPF:
ControlTheme замість Stylex:Key="{x:Type ...}" для автоматичного застосуванняusing: замість clr-namespace: для namespaceColumnDefinitions="*,Auto" — коротший синтаксис GridApp.axaml:
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MyApp.App">
<Application.Styles>
<FluentTheme />
<!-- Підключення Generic.axaml -->
<StyleInclude Source="/Themes/Generic.axaml"/>
</Application.Styles>
</Application>
Або у .csproj:
<ItemGroup>
<AvaloniaResource Include="Themes\**\*.axaml" />
</ItemGroup>
Метод OnApplyTemplate працює аналогічно WPF, але з деякими відмінностями.
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
namespace MyApp.Controls;
public class NumericUpDown : TemplatedControl
{
private Button? _upButton;
private Button? _downButton;
private TextBox? _textBox;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
// ВАЖЛИВО: спочатку викликати base
base.OnApplyTemplate(e);
// Відписатись від старих елементів
if (_upButton != null)
_upButton.Click -= UpButton_Click;
if (_downButton != null)
_downButton.Click -= DownButton_Click;
if (_textBox != null)
_textBox.KeyDown -= TextBox_KeyDown;
// Знайти нові елементи через TemplateAppliedEventArgs
_upButton = e.NameScope.Find<Button>("PART_UpButton");
_downButton = e.NameScope.Find<Button>("PART_DownButton");
_textBox = e.NameScope.Find<TextBox>("PART_TextBox");
// Підписатись на події
if (_upButton != null)
_upButton.Click += UpButton_Click;
if (_downButton != null)
_downButton.Click += DownButton_Click;
if (_textBox != null)
_textBox.KeyDown += TextBox_KeyDown;
}
private void UpButton_Click(object? sender, RoutedEventArgs e)
{
Value += Increment;
}
private void DownButton_Click(object? sender, RoutedEventArgs e)
{
Value -= Increment;
}
private void TextBox_KeyDown(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;
}
}
}
Ключові відмінності від WPF:
OnApplyTemplate(TemplateAppliedEventArgs e) — параметр з аргументамиe.NameScope.Find<T>(string name) — типізований пошукGetTemplateChild — використовується NameScopeKey.Up замість Key.Up (той самий enum)Avalonia має потужну систему pseudo-classes для стилізації станів контролу — аналог Visual States у WPF, але набагато простіший.
<ControlTheme x:Key="{x:Type controls:NumericUpDown}" TargetType="controls:NumericUpDown">
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="PART_Border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<!-- Вміст -->
</Border>
</ControlTemplate>
</Setter>
<!-- Стилі для різних станів -->
<Style Selector="^:pointerover /template/ Border#PART_Border">
<Setter Property="BorderBrush" Value="#3b82f6"/>
</Style>
<Style Selector="^:focus /template/ Border#PART_Border">
<Setter Property="BorderBrush" Value="#2563eb"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="^:disabled /template/ Border#PART_Border">
<Setter Property="Opacity" Value="0.5"/>
</Style>
</ControlTheme>
Доступні pseudo-classes:
| Pseudo-class | Опис |
|---|---|
:pointerover | Курсор над контролом |
:pressed | Контрол натиснуто |
:focus | Контрол у фокусі |
:disabled | Контрол вимкнено |
:selected | Контрол обрано |
:checked | Checkbox/RadioButton обрано |
Можна створювати власні pseudo-classes для специфічних станів:
public class NumericUpDown : TemplatedControl
{
// Кастомний pseudo-class для стану "значення на максимумі"
private static readonly PseudoClasses s_atMaximumPseudoClass = new(":at-maximum");
public NumericUpDown()
{
ValueProperty.Changed.AddClassHandler<NumericUpDown>((sender, e) =>
{
sender.UpdatePseudoClasses();
});
MaximumProperty.Changed.AddClassHandler<NumericUpDown>((sender, e) =>
{
sender.UpdatePseudoClasses();
});
}
private void UpdatePseudoClasses()
{
// Встановити/зняти pseudo-class залежно від умови
PseudoClasses.Set(":at-maximum", Value >= Maximum);
}
}
Використання у стилі:
<Style Selector="^:at-maximum /template/ Border#PART_Border">
<Setter Property="BorderBrush" Value="#ef4444"/>
</Style>
Зберемо все разом у повноцінний контрол.
NumericUpDown.cs:
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
namespace MyApp.Controls;
public class NumericUpDown : TemplatedControl
{
private Button? _upButton;
private Button? _downButton;
private TextBox? _textBox;
// StyledProperty для Value
public static readonly StyledProperty<double> ValueProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Value),
defaultValue: 0.0,
defaultBindingMode: Avalonia.Data.BindingMode.TwoWay,
coerce: CoerceValue
);
public double Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
// StyledProperty для Minimum
public static readonly StyledProperty<double> MinimumProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Minimum),
defaultValue: double.MinValue
);
public double Minimum
{
get => GetValue(MinimumProperty);
set => SetValue(MinimumProperty, value);
}
// StyledProperty для Maximum
public static readonly StyledProperty<double> MaximumProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Maximum),
defaultValue: double.MaxValue
);
public double Maximum
{
get => GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
// StyledProperty для Increment
public static readonly StyledProperty<double> IncrementProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Increment),
defaultValue: 1.0
);
public double Increment
{
get => GetValue(IncrementProperty);
set => SetValue(IncrementProperty, value);
}
// Coerce для обмеження значення
private static double CoerceValue(AvaloniaObject instance, double value)
{
var control = (NumericUpDown)instance;
if (value < control.Minimum)
return control.Minimum;
if (value > control.Maximum)
return control.Maximum;
return value;
}
public NumericUpDown()
{
// Підписка на зміни Minimum/Maximum для перевалідації Value
MinimumProperty.Changed.AddClassHandler<NumericUpDown>((sender, e) =>
{
sender.CoerceValue(ValueProperty);
});
MaximumProperty.Changed.AddClassHandler<NumericUpDown>((sender, e) =>
{
sender.CoerceValue(ValueProperty);
});
// Підтримка клавіатури на рівні контролу
this.KeyDown += (s, e) =>
{
if (e.Key == Key.Up)
{
Value += Increment;
e.Handled = true;
}
else if (e.Key == Key.Down)
{
Value -= Increment;
e.Handled = true;
}
};
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
// Відписатись від старих елементів
if (_upButton != null)
_upButton.Click -= UpButton_Click;
if (_downButton != null)
_downButton.Click -= DownButton_Click;
// Знайти нові елементи
_upButton = e.NameScope.Find<Button>("PART_UpButton");
_downButton = e.NameScope.Find<Button>("PART_DownButton");
_textBox = e.NameScope.Find<TextBox>("PART_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;
}
}
Themes/Generic.axaml:
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:MyApp.Controls">
<ControlTheme x:Key="{x:Type controls:NumericUpDown}" TargetType="controls:NumericUpDown">
<Setter Property="Background" Value="{DynamicResource TextControlBackground}"/>
<Setter Property="BorderBrush" Value="{DynamicResource TextControlBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="MinHeight" Value="32"/>
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="PART_Border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid ColumnDefinitions="*,Auto">
<!-- TextBox для відображення значення -->
<TextBox Grid.Column="0"
x:Name="PART_TextBox"
Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}}"
VerticalContentAlignment="Center"
Padding="8,4"
BorderThickness="0"
Background="Transparent"/>
<!-- Кнопки +/- -->
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="0">
<Button x:Name="PART_UpButton"
Content="▲"
FontSize="8"
Padding="8,2"
BorderThickness="0"
Background="Transparent"
CornerRadius="0,4,0,0"/>
<Button x:Name="PART_DownButton"
Content="▼"
FontSize="8"
Padding="8,2"
BorderThickness="0"
Background="Transparent"
CornerRadius="0,0,4,0"/>
</StackPanel>
</Grid>
</Border>
</ControlTemplate>
</Setter>
<!-- Стилі для різних станів -->
<Style Selector="^:pointerover /template/ Border#PART_Border">
<Setter Property="BorderBrush" Value="{DynamicResource TextControlBorderBrushPointerOver}"/>
</Style>
<Style Selector="^:focus /template/ Border#PART_Border">
<Setter Property="BorderBrush" Value="{DynamicResource TextControlBorderBrushFocused}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="^:disabled /template/ Border#PART_Border">
<Setter Property="Opacity" Value="0.5"/>
</Style>
</ControlTheme>
</ResourceDictionary>
Використання:
<Window xmlns="https://github.com/avaloniaui"
xmlns:controls="using:MyApp.Controls">
<StackPanel Margin="20" Spacing="12">
<TextBlock Text="Кількість:"/>
<controls:NumericUpDown Value="{Binding Quantity}"
Minimum="0"
Maximum="100"
Increment="1"
Width="150"
HorizontalAlignment="Left"/>
</StackPanel>
</Window>
Розберемо детально відмінності у створенні Custom Controls.
WPF:
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(
nameof(Value),
typeof(double),
typeof(NumericUpDown),
new FrameworkPropertyMetadata(
0.0,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
)
);
public double Value
{
get => (double)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
Avalonia:
public static readonly StyledProperty<double> ValueProperty =
AvaloniaProperty.Register<NumericUpDown, double>(
nameof(Value),
defaultValue: 0.0,
defaultBindingMode: BindingMode.TwoWay
);
public double Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
Переваги Avalonia: Типізований API, менше boilerplate.
WPF:
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_upButton = GetTemplateChild("PART_UpButton") as Button;
if (_upButton != null)
_upButton.Click += UpButton_Click;
}
Avalonia:
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_upButton = e.NameScope.Find<Button>("PART_UpButton");
if (_upButton != null)
_upButton.Click += UpButton_Click;
}
Переваги Avalonia: Типізований пошук через Find<T>().
WPF (Visual States):
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal"/>
<VisualState x:Name="MouseOver">
<Storyboard>
<ColorAnimation Storyboard.TargetName="PART_Border"
Storyboard.TargetProperty="(Border.BorderBrush).(SolidColorBrush.Color)"
To="#3b82f6"
Duration="0:0:0.2"/>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
Avalonia (Pseudo-classes):
<Style Selector="^:pointerover /template/ Border#PART_Border">
<Setter Property="BorderBrush" Value="#3b82f6"/>
</Style>
Переваги Avalonia: CSS-like синтаксис, набагато простіший.
| Аспект | WPF | Avalonia |
|---|---|---|
| Властивості | DependencyProperty | StyledProperty |
| Типізація | Слабка (object) | Сильна (generic) |
| Boilerplate | Багато | Мало |
| Шаблон | Generic.xaml | Generic.axaml |
| Стиль | Style | ControlTheme |
| Стани | Visual States | Pseudo-classes |
| OnApplyTemplate | GetTemplateChild | NameScope.Find |
| Платформи | Windows | Windows, macOS, Linux, mobile |
Мета: Навчитися портувати Custom Control з WPF на Avalonia.
Завдання:
Портуйте NumericUpDown з WPF статті на Avalonia:
TemplatedControlDependencyProperty на StyledPropertyAvaloniaProperty.Register<TOwner, TValue>Themes/Generic.axamlControlTheme замість StylePART_UpButton, PART_DownButton, PART_TextBoxTemplateAppliedEventArgs ee.NameScope.Find<T>:pointerover — зміна BorderBrush:focus — збільшення BorderThickness:disabled — зменшення OpacityКритерії успіху:
Мета: Навчитися створювати кастомні pseudo-classes.
Завдання:
Створіть RatingControl для Avalonia з кастомними станами:
Rating (double, 0-5)MaxRating (int, за замовчуванням 5)IsReadOnly (bool):empty — рейтинг = 0:half — рейтинг містить дробову частину:full — рейтинг = MaxRatingКритерії успіху:
Підказка для pseudo-classes:
public class RatingControl : TemplatedControl
{
public RatingControl()
{
RatingProperty.Changed.AddClassHandler<RatingControl>((sender, e) =>
{
sender.UpdatePseudoClasses();
});
}
private void UpdatePseudoClasses()
{
PseudoClasses.Set(":empty", Rating == 0);
PseudoClasses.Set(":half", Rating % 1 != 0);
PseudoClasses.Set(":full", Rating == MaxRating);
}
}
Мета: Навчитися використовувати власний рендеринг у Avalonia.
Завдання:
Створіть круговий прогрес-бар з власним рендерингом:
Value (double, 0-100)Thickness (double, товщина кільця)ProgressBrush (IBrush)TrackBrush (IBrush)Render(DrawingContext context)DoubleTransitionКритерії успіху:
Підказка для Render:
public override void Render(DrawingContext context)
{
base.Render(context);
var bounds = Bounds;
var center = new Point(bounds.Width / 2, bounds.Height / 2);
var radius = Math.Min(bounds.Width, bounds.Height) / 2 - Thickness / 2;
// Малюємо трек (повне коло)
context.DrawEllipse(
null,
new Pen(TrackBrush, Thickness),
center,
radius,
radius
);
// Малюємо прогрес (дуга)
if (Value > 0)
{
var angle = (Value / 100.0) * 360.0;
var geometry = CreateArcGeometry(center, radius, -90, angle);
context.DrawGeometry(null, new Pen(ProgressBrush, Thickness), geometry);
}
// Малюємо текст
var text = new FormattedText(
$"{Value:F0}%",
CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
Typeface.Default,
bounds.Height / 4,
Foreground
);
context.DrawText(text, new Point(
center.X - text.Width / 2,
center.Y - text.Height / 2
));
}
Avalonia TemplatedControl — це сучасніший та простіший підхід до створення lookless контролів порівняно з WPF.
Ключові висновки:
🎯 StyledProperty
📁 Generic.axaml
🎨 Pseudo-classes
🔍 NameScope.Find
🌍 Кросплатформність
⚡ Простота
Переваги Avalonia:
Порівняння з WPF:
| Аспект | WPF | Avalonia |
|---|---|---|
| Складність | Висока | Середня |
| Boilerplate | Багато | Мало |
| Типізація | Слабка | Сильна |
| Стилізація | Visual States | Pseudo-classes |
| Платформи | Windows | Всі |
Що далі?
Ви завершили статтю про Avalonia TemplatedControl! Наступні теми:
:pointerover, :pressed).NameScope — область видимості імен елементів у шаблоні (для пошуку Template Parts).TemplateAppliedEventArgs — аргументи події OnApplyTemplate з доступом до NameScope.Coerce — функція для валідації та корекції значення властивості.Validate — функція для перевірки валідності значення властивості.Custom Controls: Lookless Controls у WPF
Різниця між UserControl та Custom Control. Створення lookless контролів з Template Parts, DefaultStyleKey, OnApplyTemplate та Automation Peers для accessibility.
Анімації у WPF: Storyboard та Easing Functions
Створення плавних анімацій для інтерактивних інтерфейсів. Storyboard, DoubleAnimation, ColorAnimation, Easing Functions, Event Triggers та code-behind анімації.