Desktop UI

Control Themes в Avalonia — нова ера стилізації

ControlTheme в Avalonia 11+ — CSS-like селектори замість Triggers, декларативна система стилізування контролів. Порівняння з WPF ControlTemplate та практичний портинг кастомної кнопки.
Нові терміни у цій статті:ControlTheme, ControlTheme.BasedOn, PseudoClass (:pressed, :pointerover), Selector у ControlTheme, Styles vs ControlTheme, SimpleTheme, FluentTheme, ThemeVariant, ціль :is().

Що змінила Avalonia 11: від ControlTemplate до ControlTheme

Якщо ви прийшли у Avalonia з WPF і вперше побачили код кастомного контролу у Avalonia 11+, ви, мабуть, зауважили: тут немає звичного ControlTemplate. Є щось нове — ControlTheme. Це не просто переіменування. Це принципово інша архітектура з іншою філософією.

У WPF, щоб змінити вигляд Button, ви пишете ControlTemplate із ControlTemplate.Triggers. Всі стани (hover, pressed, disabled) описуються через Trigger-и у XML усередині шаблону. Кожен стан — окремий блок із Setter TargetName="border". Це веде до досить обʼємного XAML навіть для простого контролу.

Avalonia 11 запропонувала ControlTheme — спеціальний клас, що поєднує ідеї WPF ControlTemplate та CSS. Замість ControlTemplate.Triggers — CSS-like Selectors із Pseudo-classes. Замість x:Name у шаблоні та TargetName у Trigger — вкладені стилі за CSS-селекторами :/template/. Результат: більш декларативний, більш лаконічний і більш знайомий веброзробникам код.

Важливо: ControlThemeне замінює стилі (Style). Це дві різні системи, що живуть паралельно. Style з CSS-like Selectors (які ми вивчили у статті 27a) — для зовнішніх правил стилізації. ControlTheme — для визначення внутрішнього шаблону контролу та його станів.


Анатомія ControlTheme

Розглянемо структуру ControlTheme порівняно зі стандартним WPF-підходом:

WPF (ControlTemplate + Triggers — 40+ рядків для простої кнопки):

<Style TargetType="Button">
    <Setter Property="Background" Value="#4F46E5"/>
    <Setter Property="Foreground" Value="White"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="Button">
                <Border x:Name="border"
                        Background="{TemplateBinding Background}"
                        CornerRadius="8"
                        Padding="{TemplateBinding Padding}">
                    <ContentPresenter HorizontalAlignment="Center"
                                      VerticalAlignment="Center"/>
                </Border>
                <ControlTemplate.Triggers>
                    <Trigger Property="IsMouseOver" Value="True">
                        <Setter TargetName="border" Property="Background" Value="#3730A3"/>
                    </Trigger>
                    <Trigger Property="IsPressed" Value="True">
                        <Setter TargetName="border" Property="Background" Value="#312E81"/>
                    </Trigger>
                    <Trigger Property="IsEnabled" Value="False">
                        <Setter TargetName="border" Property="Opacity" Value="0.5"/>
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

Avalonia 11 (ControlTheme — лаконічніший):

<ControlTheme x:Key="{x:Type Button}" TargetType="Button">
    <Setter Property="Background" Value="#4F46E5"/>
    <Setter Property="Foreground" Value="White"/>
    <Setter Property="Padding"    Value="16,10"/>
    <Setter Property="Template">
        <ControlTemplate>
            <Border Background="{TemplateBinding Background}"
                    CornerRadius="8"
                    Padding="{TemplateBinding Padding}">
                <ContentPresenter HorizontalAlignment="Center"
                                  VerticalAlignment="Center"/>
            </Border>
        </ControlTemplate>
    </Setter>

    <!-- Стани через CSS Pseudo-class Selectors — без TargetName! -->
    <Style Selector="^:pointerover">
        <Setter Property="Background" Value="#3730A3"/>
    </Style>

    <Style Selector="^:pressed">
        <Setter Property="Background" Value="#312E81"/>
    </Style>

    <Style Selector="^:disabled">
        <Setter Property="Opacity" Value="0.5"/>
    </Style>
</ControlTheme>

Одразу впадає в очі кілька ключових відмінностей. Розглянемо їх детально.


Ключові відмінності: ControlTheme vs WPF ControlTemplate

1. Template без TargetType

У Avalonia <ControlTemplate> всередині ControlTheme не потребує явного TargetType — він автоматично береться з TargetType батьківського ControlTheme. Це зменшує дублювання.

2. Pseudo-class замість Trigger

WPF використовує Trigger-и з Property="IsMouseOver" Value="True". Avalonia використовує CSS Pseudo-classes: :pointerover, :pressed, :disabled, :checked.

Синтаксис ^:pointerover у Selector означає:

  • ^ — посилання на батьківський елемент у scope (тут — сам контрол Button).
  • :pointerover — псевдоклас «курсор над елементом».

Разом: «застосуй цей стиль до Button, що має псевдоклас :pointerover».

3. Без TargetName: стилізація через /template/ selector

У WPF Trigger може змінювати властивості конкретного Named елемента шаблону:

<Setter TargetName="border" Property="Background" Value="Red"/>

В Avalonia — через CSS /template/ selector:

<Style Selector="^:pointerover /template/ Border#border">
    <Setter Property="Background" Value="Red"/>
</Style>

Але найчастіше set-ять властивість самого контролу (як у прикладі вище), і шаблон читає їх через {TemplateBinding} — це чистіше.

4. Ключ ControlTheme: x:Key="{x:Type Button}"

Implicit ControlTheme (що застосовується до всіх Button) визначається з ключем {x:Type Button} — це спеціальний Avalonia-синтаксис для задання «типового» ControlTheme. Аналог WPF Implicit Style без x:Key.

АспектWPFAvalonia
Шаблон контролуControlTemplate з TargetTypeControlTemplate без TargetTypeControlTheme)
Hover-ефект<Trigger Property="IsMouseOver"><Style Selector="^:pointerover">
Прицільна зміна елементаSetter TargetName="border"Style Selector="^:pointerover /template/ Border"
Implicit темаStyle без x:Key + TargetTypeControlTheme x:Key="{x:Type Button}"
Успадкування темиBasedOn="{StaticResource {x:Type Button}}"ControlTheme.BasedOn="{StaticResource {x:Type Button}}"

Перший повний ControlTheme: кастомна кнопка

Реалізуємо повноцінну кастомну кнопку через Avalonia ControlTheme. Вона матиме всі стани: normal, hover, pressed, disabled.

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Зверніть на кілька деталей Avalonia-специфічних речей:

  1. Transitions="0.15s Background" на Border — Avalonia підтримує CSS-подібні transitions прямо у XAML. Зміна Background анімується 150мс. У WPF для аналогічного потрібен Storyboard із ColorAnimation у ControlTemplate.Triggers.
  2. Theme="{StaticResource PrimaryButton}" на Button — Avalonia-аналог WPF Style="{StaticResource ...}". Властивість Theme (не Style) застосовує ControlTheme до контролу.
  3. BasedOn у ControlTheme — точний аналог WPF BasedOn у Style. DangerButton успадковує весь шаблон і всі Setter-и PrimaryButton, перевизначаючи лише Background.

Перевизначення вбудованої теми Fluent

Одна з найпоширеніших задач у реальних проєктах — злегка підправити зовнішній вигляд вбудованого контролу, не перевизначаючи увесь шаблон. В Avalonia це виконується через BasedOn з посиланням на стандартний ControlTheme за ключем {x:Type ControlName}.

Ось як це виглядає: замість того, щоб писати шаблон з нуля, ми BasedOn до вбудованого Button ControlTheme і перевизначаємо лише потрібне:

<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <!-- Стандартна Fluent тема Avalonia -->
            <FluentTheme/>
        </ResourceDictionary.MergedDictionaries>

        <!-- Перевизначення: лише округлість та розмір шрифту кнопок -->
        <ControlTheme x:Key="{x:Type Button}" TargetType="Button"
                      BasedOn="{StaticResource {x:Type Button}}">
            <Setter Property="FontSize"   Value="15"/>
            <Setter Property="FontWeight" Value="Medium"/>
        </ControlTheme>

    </ResourceDictionary>
</Application.Resources>
BasedOn="{StaticResource {x:Type Button}}" — ця конструкція є потенційно рекурсивною. Щоб уникнути нескінченного циклу, переконайтесь, що вбудована тема (FluentTheme) вже завантажена перед вашим перевизначенням у MergedDictionaries. Порядок злиття ResourceDictionary важливий: перший — базова тема, другий — ваші перевизначення.

Що можна перевизначити через BasedOn

Через BasedOn + часткове перевизначення можна:

ЗадачаЯк
Змінити кольори кнопкиSetter Property="Background" + override :pointerover
Розмір шрифту/paddingSetter Property="FontSize", Setter Property="Padding"
Заокруглення кутівЧерез /template/ Border Selector
Анімація у станіStyle Selector="^:pointerover /template/ Border" + Transitions
Повна заміна шаблонуSetter Property="Template" — перевизначає все

Портинг WPF кнопки на Avalonia ControlTheme

У статті 28 ми реалізували CircleButton у WPF — кругла кнопка через Ellipse + ContentPresenter у ControlTemplate. Тепер портуємо її в Avalonia. Порівняйте підходи пліч-о-пліч:

WPF-версія (із статті 28):

<Style x:Key="CircleButton" TargetType="Button">
  <Setter Property="Width"   Value="56"/>
  <Setter Property="Height"  Value="56"/>
  <Setter Property="Background" Value="#4F46E5"/>
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="Button">
        <Grid>
          <Ellipse Fill="{TemplateBinding Background}"/>
          <ContentPresenter HorizontalAlignment="Center"
                            VerticalAlignment="Center"/>
        </Grid>
        <ControlTemplate.Triggers>
          <Trigger Property="IsMouseOver" Value="True">
            <Setter TargetName="???" Property="???"/>
            <!-- Не можемо TargetName на Ellipse без x:Name -->
          </Trigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

Avalonia-версія (ControlTheme):

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Ключова перевага Avalonia-версії над WPF: у WPF для hover-ефекту на Ellipse потрібно було б додати x:Name="circle" і використати Setter TargetName="circle". В Avalonia — просто встановлюємо Background самого контролу, і шаблон через {TemplateBinding Background} автоматично передає нове значення в Ellipse.Fill. Шаблон чистіший, без іменованих залежностей.

Transitions="0.15s Background" на Ellipse у Avalonia — це плавна анімація зміни кольору при hover. В WPF для аналогічного ефекту потрібен EventTrigger із Storyboard та ColorAnimation — значно більше коду. Avalonia Transitions є однією з найбільш цінованих особливостей фреймворку.

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


Підсумок

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

ControlTheme — нова архітектура Avalonia 11+ для визначення зовнішнього вигляду та станів контролу. Не замінює Style — це паралельна, але взаємодоповнювальна система.

Зіставлення понять:

WPFAvalonia
Style з ControlTemplateControlTheme
Trigger Property="IsMouseOver"Style Selector="^:pointerover"
Setter TargetName="border"Style Selector="^:state /template/ Border" або Setter на контролі
Style="{StaticResource key}"Theme="{StaticResource key}"
Implicit: Style без x:KeyImplicit: ControlTheme x:Key="{x:Type Button}"

Переваги Avalonia ControlTheme:

  • Лаконічніший синтаксис для станів (no Triggers XML noise).
  • Transitions замість Storyboard для простих анімацій.
  • CSS-like мислення: вже знайоме веброзробникам.
  • BasedOn — те саме, що у WPF, але для тем.

Обмеження / різниця:

  • ControlTheme — тільки Avalonia 11+. Older Avalonia 0.10.x використовувала WPF-подібний підхід.
  • Theme={} — Avalonia атрибут. У WPF — Style={}.
  • Порядок MergedDictionaries критичний для BasedOn на вбудовані теми.

Що далі

Наступна стаття — Triggers у WPF (Style.Triggers, DataTrigger, MultiTrigger). Ви побачите WPF-механізм управління станами у повному обсязі — і зможете свідомо порівняти його з Avalonia ControlTheme-підходом, що ми вивчили сьогодні.