Desktop UI

Pseudo-classes в Avalonia — замість WPF Triggers

Avalonia замінила WPF Triggers на CSS pseudo-classes. Вбудовані :pointerover, :focus, :pressed, :checked, кастомні PseudoClasses.Set(), data-driven стилізація через compiled bindings — повний посібник.
Нові терміни у цій статті: Pseudo-class (:pointerover, :focus, :pressed, :disabled, :checked, :empty, :selected), PseudoClasses.Set(), PseudoClasses.Remove(), Custom Pseudo-class, x:DataType, Compiled Bindings, {CompiledBinding}.

Avalonia обрала інший шлях

У статті 29 ми детально вивчили WPF Triggers — Property Trigger, DataTrigger, MultiTrigger, EventTrigger, VisualStateManager. Це потужна система, але вона несе значний XML-шум: навіть проста зміна кольору при hover вимагає окремого блоку <Style.Triggers> із вкладеними тегами.

Avalonia вирішила цю проблему радикально — відмовилась від Triggers взагалі. У Avalonia немає Trigger, DataTrigger, MultiTrigger, EventTrigger. Натомість — CSS pseudo-classes та селектори, які вже знайомі кожному веброзробнику.

Це рішення має принципові наслідки:

Що це дає: Лаконічність. Те, що у WPF займає 8 рядків <Style.Triggers>, в Avalonia — один <Style Selector="Button:pointerover">. Знайомий синтаксис для тих, хто знає CSS. Відсутність концепції «порядку тригерів» — замість неї Specificity (стаття 27a).

Що це означає для WPF-розробника: При портуванні WPF-проєкту на Avalonia всі Trigger-и потрібно переписати на Selector + Pseudo-class. Але ця задача значно менш болісна, ніж здається — пряма відповідність між концепціями існує майже завжди.

У цій статті ми побудуємо повне відображення WPF → Avalonia для кожного типу Trigger і відпрацюємо кастомні pseudo-classes для власних стилів.


Вбудовані pseudo-classes: повна таблиця

Avalonia надає набір вбудованих pseudo-classes, прив'язаних до внутрішніх станів контролу. Їх не потрібно «вмикати» — вони активуються автоматично відповідно до стану елемента.

Pseudo-classWPF-аналогАктивний коли
:pointeroverIsMouseOver=True TriggerКурсор над елементом
:pressedIsPressed=True TriggerЕлемент натиснутий (ліва кнопка)
:focusIsFocused=True TriggerЕлемент має keyboard focus
:focus-withinнемаєБудь-який нащадок має фокус
:disabledIsEnabled=False TriggerIsEnabled="False"
:enabledIsEnabled=TrueIsEnabled="True" (явно)
:checkedIsChecked=True TriggerCheckBox, ToggleButton позначені
:uncheckedIsChecked=FalseЗнято позначку
:indeterminateIsChecked=nullПроміжний стан
:selectedIsSelected=TrueListBoxItem, TabItem — вибрані
:emptyнемаєКолекція ItemsControl порожня
:nth-child(n)немає прямого аналогуn-й дочірній елемент
:first-childнемаєПерший дочірній
:last-childнемаєОстанній дочірній
:not(selector)немаєЕлемент, що НЕ відповідає selector

Зверніть на :focus-within та :not() — ці pseudo-classes взагалі не мають аналогів у WPF Triggers. Нарешті :nth-child, :first-child, :last-child — можливості, недоступні у WPF взагалі.


Відповідність WPF Triggers → Avalonia Pseudo-classes

Property Trigger →

WPF:

<Style TargetType="Button">
    <Setter Property="Background" Value="#4F46E5"/>
    <Style.Triggers>
        <Trigger Property="IsMouseOver" Value="True">
            <Setter Property="Background" Value="#3730A3"/>
        </Trigger>
        <Trigger Property="IsPressed" Value="True">
            <Setter Property="Background" Value="#312E81"/>
        </Trigger>
        <Trigger Property="IsEnabled" Value="False">
            <Setter Property="Opacity" Value="0.5"/>
        </Trigger>
    </Style.Triggers>
</Style>

Avalonia — той самий результат:

<Style Selector="Button">
    <Setter Property="Background" Value="#4F46E5"/>
</Style>
<Style Selector="Button:pointerover">
    <Setter Property="Background" Value="#3730A3"/>
</Style>
<Style Selector="Button:pressed">
    <Setter Property="Background" Value="#312E81"/>
</Style>
<Style Selector="Button:disabled">
    <Setter Property="Opacity" Value="0.5"/>
</Style>

WPF: 12+ рядків вкладеного XML. Avalonia: 4 плоских <Style>. Семантика ідентична.

Важлива відмінність: авто-відкочення у Avalonia

У WPF Trigger автоматично відкочує зміни, коли умова перестає виконуватись. В Avalonia — CSS Specificity та механізм Selector-ів гарантують те саме: коли :pointerover неактивний — відповідний <Style> не застосовується, і базові значення повертаються автоматично. Поведінка ідентична, механізм — CSS-like.

DataTrigger → Binding + Classes або CompiledBinding

Це найбільш значуща відмінність між WPF і Avalonia. У WPF DataTrigger дозволяє реагувати на Binding-значення безпосередньо у XAML без жодного C#:

<!-- WPF DataTrigger -->
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
    <Setter Property="Background" Value="#ECFDF5"/>
</DataTrigger>

В Avalonia прямого аналогу DataTrigger немає. Натомість використовуються два підходи:

Підхід 1: Style Classes (рекомендований)

Замість того, щоб реагувати на значення Binding прямо у стилях, динамічно додаємо/видаляємо CSS-клас у code-behind або через спеціальні Behaviors:

// У ViewModel або code-behind
isCompletedBinding.Subscribe(isCompleted =>
{
    if (isCompleted)
        border.Classes.Add("completed");
    else
        border.Classes.Remove("completed");
});
<!-- У XAML — стиль реагує на клас -->
<Style Selector="Border.completed">
    <Setter Property="Background" Value="#ECFDF5"/>
</Style>

Підхід 2: Avalonia Binding Classes (декларативний)

Avalonia підтримує спеціальний синтаксис Classes.className="{Binding PropertyName}" прямо у XAML:

<Border>
    <Border.Classes>
        <!-- Клас "completed" активний коли IsCompleted = True -->
        <Classes>completed</Classes>
    </Border.Classes>

<!-- Або через атрибутний синтаксис Avalonia 11.1+: -->
<Border Classes.completed="{Binding IsCompleted}"
        Classes.urgent="{Binding IsUrgent}">

Це найближчий аналог WPF DataTrigger — декларативно у XAML, без C#.

Classes.className="{Binding BoolProp}" — найпотужніший інструмент data-driven стилізації у Avalonia. Коли BoolProp = True — клас додається. Коли False — видаляється. Стилі реагують автоматично через CSS Selector.

Повний приклад: стилізація картки через pseudo-classes

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...


Custom Pseudo-classes: PseudoClasses.Set()

Вбудовані pseudo-classes охоплюють більшість стандартних сценаріїв, але іноді потрібно визначити власний стан для кастомного контролу. Наприклад: :loading, :active, :selected, :dragging.

В Avalonia для цього використовується API PseudoClasses.Set() та PseudoClasses.Remove() на рівні коду контролу. Цей метод доступний у класах, що успадковують StyledElement (тобто будь-який UI-елемент).

Реалізація кастомного pseudo-class у Custom Control

// CustomControl.cs
public class StatusCard : ContentControl
{
    public static readonly StyledProperty<bool> IsLoadingProperty =
        AvaloniaProperty.Register<StatusCard, bool>(nameof(IsLoading));

    public bool IsLoading
    {
        get => GetValue(IsLoadingProperty);
        set => SetValue(IsLoadingProperty, value);
    }

    protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs e)
    {
        base.OnPropertyChanged(e);

        if (e.Property == IsLoadingProperty)
        {
            // Встановлюємо або знімаємо pseudo-class :loading
            PseudoClasses.Set(":loading", (bool)e.NewValue!);
        }
    }
}
<!-- Використання у XAML: -->
<local:StatusCard IsLoading="True">
    <TextBlock Text="Контент картки"/>
</local:StatusCard>

<!-- Стиль реагує на :loading -->
<Style Selector="local|StatusCard:loading">
    <Setter Property="Opacity" Value="0.6"/>
</Style>
<Style Selector="local|StatusCard:loading /template/ Border">
    <Setter Property="Background" Value="#EEF2FF"/>
</Style>
Зверніть на синтаксис local|StatusCard — для кастомних типів у Avalonia Selector використовується namespace|TypeName замість WPF TargetType="local:StatusCard". Це прямо відповідає CSS-синтаксису для namespace-qualified типів.

MultiTrigger та EventTrigger: аналоги

MultiTrigger → Combining Selectors

WPF MultiTrigger вимагає ANDкомбінації умов. В Avalonia ця ж логіка виражається ланцюжком pseudo-classes в одному Selector:

<!-- WPF MultiTrigger: IsSelected AND IsMouseOver -->
<MultiTrigger>
    <MultiTrigger.Conditions>
        <Condition Property="IsSelected"  Value="True"/>
        <Condition Property="IsMouseOver" Value="True"/>
    </MultiTrigger.Conditions>
    <Setter Property="Background" Value="#FEF3C7"/>
</MultiTrigger>

<!-- Avalonia: ланцюжок pseudo-classes = AND -->
<Style Selector="ListBoxItem:selected:pointerover">
    <Setter Property="Background" Value="#FEF3C7"/>
</Style>

Якщо потрібно AND по власних класах та pseudo-classes — просто об'єднуємо:

<Style Selector="Border.card.urgent:pointerover">
    <Setter Property="Background" Value="#FEE2E2"/>
</Style>

EventTrigger → анімації через Transitions або Animation

WPF EventTrigger запускає Storyboard у відповідь на RoutedEvent. В Avalonia:

  • Для переходів між станамиTransitions на елементі (найпростіше, без анімаційного коду).
  • Для складної анімаціїAnimation + KeyFrames (аналог WPF Storyboard, але у Avalonia стилі).
<!-- Avalonia: плавна зміна Background при :pointerover через Transition -->
<Style Selector="Button">
    <Setter Property="Transitions">
        <Setter.Value>
            <Transitions>
                <BrushTransition Property="Background" Duration="0:0:0.2"/>
            </Transitions>
        </Setter.Value>
    </Setter>
</Style>
<Style Selector="Button:pointerover">
    <Setter Property="Background" Value="#3730A3"/>
</Style>

Avalonia Transitions — значно менше коду порівняно з WPF EventTrigger + Storyboard + ColorAnimation.


Зведена таблиця відповідностей: WPF → Avalonia

WPF концепціяAvalonia еквівалентПримітка
Trigger Property="IsMouseOver"Style Selector="...:pointerover"Пряма відповідність
Trigger Property="IsPressed"Style Selector="...:pressed"Пряма відповідність
Trigger Property="IsFocused"Style Selector="...:focus"Пряма відповідність
Trigger Property="IsEnabled" Value="False"Style Selector="...:disabled"Пряма відповідність
Trigger Property="IsChecked" Value="True"Style Selector="...:checked"Пряма відповідність
Trigger Property="IsSelected" Value="True"Style Selector="...:selected"Пряма відповідність
DataTrigger Binding Value="True"Classes.myClass="{Binding BoolProp}"Непряма відповідність — через Classes
DataTrigger Binding Value="someString"Behaviors або code-behindНемає прямого XAML-аналогу
MultiTrigger (AND)Ланцюжок Type:class1:class2Пряма відповідність
EventTrigger + StoryboardTransitions або AnimationЗначно лаконічніше
VisualStateManagerControlTheme + pseudo-classesАрхітектурна відповідність
Кастомний VSM станPseudoClasses.Set(":state", val)API-рівень
TargetName у Trigger/template/ ElementName у SelectorСинтаксична відповідність
DataTrigger Binding Value="someString" — реакція на рядкове або числове значення Binding — не має прямого XAML-аналогу в Avalonia. Це справжня «діра» у функціональності. Рекомендоване рішення: IValueConverter у ViewModel, що перетворює значення на bool, або Behaviors із пакета Avalonia.Xaml.Behaviors. У більшості реальних сценаріїв це не проблема, але варто знати заздалегідь при плануванні портингу.

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


Підсумок

Що ми вивчили у цій статті

Avalonia замінила всю систему WPF Triggers на CSS pseudo-classes — рішення більш лаконічне, більш знайоме і більш CSS-like.

Вбудовані pseudo-classes автоматично відображають стан контролу: :pointerover (hover), :pressed, :focus, :focus-within, :disabled, :checked, :unchecked, :selected, :empty, :nth-child(). Деякі не мають аналогів у WPF взагалі.

DataTrigger → Classes.prop="{Binding bool}" — Avalonia 11.1+ підтримує прив'язку класів прямо у XAML. Це найближчий аналог WPF DataTrigger для boolean-умов.

MultiTrigger → ланцюжок pseudo-classes у одному Selector: Button:pressed:focus — AND-умова автоматично.

EventTrigger → Transitions — CSS-like transition на властивостях елемента замість складного Storyboard.

Custom pseudo-class: PseudoClasses.Set(":stateName", value) у класі контролу → стиль Selector="local|Type:stateName" реагує автоматично.

Sentinel limitation: відсутній прямий аналог DataTrigger для нe-boolean значень — потрібен Converter або Behaviors.

Зіставлення підходів

ЗадачаWPF рядківAvalonia рядків
Hover-ефект кнопки~8~3
3 стани (hover/pressed/disabled)~12~6
DataTrigger на bool~5~2 (Classes binding)
MultiTrigger (AND 2 умови)~8~2
Кастомний станVSM + GoToState (~20)PseudoClasses.Set (~5)

Що далі

Наступна стаття — Data Binding: режими та конвертери. OneWay, TwoWay, IValueConverter, StringFormat, TargetNullValue, FallbackValue, UpdateSourceTrigger — деталі, що відрізняють поверхневе знайомство з Binding від справжнього розуміння.