Desktop UI

Avalonia TemplatedControl — Lookless Controls

Створення custom controls в Avalonia через TemplatedControl. StyledProperty замість DependencyProperty, Generic.axaml, ControlTheme та CSS-like стилізація з pseudo-classes.

Avalonia TemplatedControl: Lookless Controls

У попередній статті ми розглянули Custom Controls у WPF — lookless контроли з поведінкою, але без фіксованого UI. Avalonia має аналогічну концепцію через TemplatedControl, але з кількома важливими відмінностями, що роблять створення контролів простішим та зручнішим.

Ключові відмінності Avalonia від WPF:

  • StyledProperty замість DependencyProperty — простіший синтаксис
  • Generic.axaml замість Generic.xaml — інший namespace
  • ControlTheme замість Style — сучасніший підхід
  • ✅ CSS-like pseudo-classes (:pointerover, :pressed) — зручніша стилізація
  • ✅ Кросплатформність — працює на Windows, macOS, Linux, mobile

У цій статті ми портуємо NumericUpDown з WPF на Avalonia та розглянемо всі особливості створення TemplatedControl.

Для кого ця стаття? Якщо ви вже знайомі з WPF Custom Controls, ця стаття покаже кросплатформні альтернативи у Avalonia.

TemplatedControl: базовий клас для lookless контролів

TemplatedControl — це базовий клас Avalonia для створення контролів з шаблонами. Він аналогічний Control у WPF, але з деякими спрощеннями.

Порівняння з WPF

АспектWPFAvalonia
Базовий класControlTemplatedControl
ВластивостіDependencyPropertyStyledProperty
ШаблонGeneric.xamlGeneric.axaml
СтильStyleControlTheme
СтаниVisual StatesPseudo-classes
ПлатформиЛише WindowsWindows, macOS, Linux, mobile

Створення базового TemplatedControl

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:

  1. Наслідуємо TemplatedControl, а не Control
  2. StyledProperty замість DependencyProperty — простіший синтаксис
  3. AvaloniaProperty.Register<TOwner, TValue>() — типізований API
  4. Немає статичного конструктора з DefaultStyleKey
  5. defaultBindingMode замість FrameworkPropertyMetadata

StyledProperty vs DependencyProperty

Розберемо детально відмінності між WPF та Avalonia у створенні властивостей.

WPF DependencyProperty (для порівняння)

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

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

  • ✅ Типізований API — Register<TOwner, TValue>
  • ✅ Менше boilerplate коду
  • ✅ Validate та Coerce у одному місці
  • ✅ Немає потреби у статичному конструкторі

Property Changed callback

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

Generic.axaml: стиль за замовчуванням

У WPF стилі за замовчуванням зберігаються у Themes/Generic.xaml. В Avalonia — у Themes/Generic.axaml.

Створення 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:

  1. ControlTheme замість Style
  2. x:Key="{x:Type ...}" для автоматичного застосування
  3. using: замість clr-namespace: для namespace
  4. ColumnDefinitions="*,Auto" — коротший синтаксис Grid

Реєстрація Generic.axaml

App.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: пошук Template Parts

Метод 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:

  1. OnApplyTemplate(TemplateAppliedEventArgs e) — параметр з аргументами
  2. e.NameScope.Find<T>(string name) — типізований пошук
  3. Немає GetTemplateChild — використовується NameScope
  4. Key.Up замість Key.Up (той самий enum)

CSS-like стилізація: Pseudo-classes

Avalonia має потужну систему pseudo-classes для стилізації станів контролу — аналог Visual States у WPF, але набагато простіший.

Базові pseudo-classes

<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Контрол обрано
:checkedCheckbox/RadioButton обрано

Кастомні pseudo-classes

Можна створювати власні 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 для Avalonia

Зберемо все разом у повноцінний контрол.

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>

Порівняння: WPF vs Avalonia

Розберемо детально відмінності у створенні 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.

OnApplyTemplate

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 синтаксис, набагато простіший.

Порівняльна таблиця

АспектWPFAvalonia
ВластивостіDependencyPropertyStyledProperty
ТипізаціяСлабка (object)Сильна (generic)
BoilerplateБагатоМало
ШаблонGeneric.xamlGeneric.axaml
СтильStyleControlTheme
СтаниVisual StatesPseudo-classes
OnApplyTemplateGetTemplateChildNameScope.Find
ПлатформиWindowsWindows, macOS, Linux, mobile

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

Рівень 1: Портувати NumericUpDown з WPF на Avalonia

Мета: Навчитися портувати Custom Control з WPF на Avalonia.

Завдання:

Портуйте NumericUpDown з WPF статті на Avalonia:

  1. Створити клас:
    • Наслідувати TemplatedControl
    • Замінити DependencyProperty на StyledProperty
    • Використати AvaloniaProperty.Register<TOwner, TValue>
  2. Створити Generic.axaml:
    • Папка Themes/Generic.axaml
    • ControlTheme замість Style
    • Template Parts: PART_UpButton, PART_DownButton, PART_TextBox
  3. OnApplyTemplate:
    • Параметр TemplateAppliedEventArgs e
    • Пошук через e.NameScope.Find<T>
  4. Додати pseudo-classes:
    • :pointerover — зміна BorderBrush
    • :focus — збільшення BorderThickness
    • :disabled — зменшення Opacity

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

  • Контрол працює ідентично WPF версії
  • StyledProperty замість DependencyProperty
  • Pseudo-classes для станів
  • Кросплатформність (тестувати на Windows та macOS якщо можливо)

Рівень 2: RatingControl з кастомними pseudo-classes

Мета: Навчитися створювати кастомні pseudo-classes.

Завдання:

Створіть RatingControl для Avalonia з кастомними станами:

  1. Властивості:
    • Rating (double, 0-5)
    • MaxRating (int, за замовчуванням 5)
    • IsReadOnly (bool)
  2. Кастомні pseudo-classes:
    • :empty — рейтинг = 0
    • :half — рейтинг містить дробову частину
    • :full — рейтинг = MaxRating
  3. Стилізація:
    • Різні кольори для різних станів
    • Анімація при зміні рейтингу (через Transitions)
  4. Функціональність:
    • Клік на зірочку встановлює рейтинг
    • Hover показує попередній перегляд
    • IsReadOnly вимикає редагування

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

  • Кастомні pseudo-classes працюють
  • Стилі змінюються залежно від стану
  • Transitions для плавної анімації
  • IsReadOnly працює правильно

Підказка для 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);
    }
}

Рівень 3: CircularProgressBar з OnRender

Мета: Навчитися використовувати власний рендеринг у Avalonia.

Завдання:

Створіть круговий прогрес-бар з власним рендерингом:

  1. Властивості:
    • Value (double, 0-100)
    • Thickness (double, товщина кільця)
    • ProgressBrush (IBrush)
    • TrackBrush (IBrush)
  2. Rendering:
    • Перевизначити Render(DrawingContext context)
    • Намалювати коло-трек
    • Намалювати дугу прогресу
    • Відобразити відсоток по центру
  3. Transitions:
    • Плавна анімація при зміні Value
    • Використати DoubleTransition
  4. Адаптивність:
    • Автоматичне масштабування під розмір контролу
    • Підтримка різних DPI

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

  • Власний рендеринг через Render
  • Плавна анімація через Transitions
  • Адаптивність до розміру
  • Підтримка різних DPI

Підказка для 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

Типізований API замість DependencyProperty. Менше boilerplate, більше type safety.

📁 Generic.axaml

Аналог Generic.xaml з ControlTheme замість Style. Сучасніший підхід.

🎨 Pseudo-classes

CSS-like стилізація станів. Набагато простіше за Visual States у WPF.

🔍 NameScope.Find

Типізований пошук Template Parts. Безпечніше за GetTemplateChild.

🌍 Кросплатформність

Працює на Windows, macOS, Linux, Android, iOS. Один код для всіх платформ.

⚡ Простота

Менше коду, простіший API, швидша розробка. Сучасний підхід.

Переваги Avalonia:

  • ✅ Простіший синтаксис StyledProperty
  • ✅ Типізований API
  • ✅ CSS-like pseudo-classes
  • ✅ Кросплатформність
  • ✅ Менше boilerplate коду
  • ✅ Сучасніший підхід

Порівняння з WPF:

АспектWPFAvalonia
СкладністьВисокаСередня
BoilerplateБагатоМало
ТипізаціяСлабкаСильна
СтилізаціяVisual StatesPseudo-classes
ПлатформиWindowsВсі
Рекомендація: Якщо створюєте нові проєкти — використовуйте Avalonia TemplatedControl. Якщо портуєте з WPF — більшість концепцій переносяться напряму з мінімальними змінами.

Що далі?

Ви завершили статтю про Avalonia TemplatedControl! Наступні теми:

  • Animations (стаття 39) — анімації у WPF через Storyboard
  • Avalonia Animations (стаття 39a) — Transitions та KeyFrame анімації
  • Media & Graphics (стаття 40) — 2D/3D графіка та мультимедіа

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

TemplatedControl — базовий клас Avalonia для створення lookless контролів з шаблонами.StyledProperty — типізована властивість Avalonia з підтримкою Binding та стилів (аналог DependencyProperty).AvaloniaProperty — статичний клас для реєстрації StyledProperty та AttachedProperty.Generic.axaml — файл у папці Themes зі стилями за замовчуванням для TemplatedControl.ControlTheme — сучасний спосіб визначення стилів у Avalonia (замість Style).Pseudo-classes — CSS-like селектори для стилізації станів контролу (:pointerover, :pressed).NameScope — область видимості імен елементів у шаблоні (для пошуку Template Parts).TemplateAppliedEventArgs — аргументи події OnApplyTemplate з доступом до NameScope.Coerce — функція для валідації та корекції значення властивості.Validate — функція для перевірки валідності значення властивості.

Додаткові ресурси

📖 Avalonia TemplatedControl Docs

Офіційна документація про створення TemplatedControl.

🎯 StyledProperty Guide

Повний гайд з StyledProperty та AvaloniaProperty.

🎨 Pseudo-classes Tutorial

Детальна стаття про pseudo-classes та ControlTheme.

🔍 Template Parts

Гайд з OnApplyTemplate та NameScope.Find.

📚 Попередня стаття: WPF Custom Controls

Повернутися до WPF Custom Controls.

📚 Наступна стаття: WPF Animations

Дізнатися про анімації у WPF.