Control Themes в Avalonia — нова ера стилізації
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.
| Аспект | WPF | Avalonia |
|---|---|---|
| Шаблон контролу | ControlTemplate з TargetType | ControlTemplate без TargetType (у ControlTheme) |
| Hover-ефект | <Trigger Property="IsMouseOver"> | <Style Selector="^:pointerover"> |
| Прицільна зміна елемента | Setter TargetName="border" | Style Selector="^:pointerover /template/ Border" |
| Implicit тема | Style без x:Key + TargetType | ControlTheme 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)...
<StackPanel Margin="20" Spacing="12">
<StackPanel.Resources>
<!-- ControlTheme для кастомної кнопки (Explicit — з x:Key) -->
<ControlTheme x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="#4F46E5"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Padding" Value="16,10"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<!-- Шаблон: простий Border + ContentPresenter -->
<Setter Property="Template">
<ControlTemplate>
<Border Name="RootBorder"
Background="{TemplateBinding Background}"
CornerRadius="8"
Padding="{TemplateBinding Padding}"
Transitions="0.15s Background">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter>
<!-- Hover: темніший відтінок -->
<Style Selector="^:pointerover">
<Setter Property="Background" Value="#3730A3"/>
</Style>
<!-- Pressed: ще темніший + мікро-зум -->
<Style Selector="^:pressed">
<Setter Property="Background" Value="#312E81"/>
<Setter Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleX="0.97" ScaleY="0.97"/>
</Setter.Value>
</Setter>
</Style>
<!-- Disabled: приглушений -->
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5"/>
</Style>
</ControlTheme>
<!-- Danger варіант через BasedOn -->
<ControlTheme x:Key="DangerButton" TargetType="Button"
BasedOn="{StaticResource PrimaryButton}">
<Setter Property="Background" Value="#EF4444"/>
<Style Selector="^:pointerover">
<Setter Property="Background" Value="#DC2626"/>
</Style>
</ControlTheme>
<!-- Success варіант -->
<ControlTheme x:Key="SuccessButton" TargetType="Button"
BasedOn="{StaticResource PrimaryButton}">
<Setter Property="Background" Value="#10B981"/>
<Style Selector="^:pointerover">
<Setter Property="Background" Value="#059669"/>
</Style>
</ControlTheme>
</StackPanel.Resources>
<Button Theme="{StaticResource PrimaryButton}"
Content="Primary — наведи і натисни"
Command="{Binding ShowMessageCommand}"
CommandParameter="Primary!"/>
<Button Theme="{StaticResource DangerButton}"
Content="Danger — видалити"
Command="{Binding ShowMessageCommand}"
CommandParameter="Видалено!"/>
<Button Theme="{StaticResource SuccessButton}"
Content="Success — підтвердити"
Command="{Binding ShowMessageCommand}"
CommandParameter="Підтверджено!"/>
<Button Theme="{StaticResource PrimaryButton}"
Content="Заблоковано"
IsEnabled="False"/>
</StackPanel>
Зверніть на кілька деталей Avalonia-специфічних речей:
Transitions="0.15s Background"наBorder— Avalonia підтримує CSS-подібні transitions прямо у XAML. ЗмінаBackgroundанімується 150мс. У WPF для аналогічного потрібенStoryboardізColorAnimationуControlTemplate.Triggers.Theme="{StaticResource PrimaryButton}"наButton— Avalonia-аналог WPFStyle="{StaticResource ...}". ВластивістьTheme(неStyle) застосовуєControlThemeдо контролу.BasedOnуControlTheme— точний аналог WPFBasedOnу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 |
| Розмір шрифту/padding | Setter 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)...
<StackPanel Margin="20" Spacing="20" Orientation="Horizontal">
<StackPanel.Resources>
<ControlTheme x:Key="CircleButton" TargetType="Button">
<Setter Property="Width" Value="56"/>
<Setter Property="Height" Value="56"/>
<Setter Property="Background" Value="#4F46E5"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="18"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<ControlTemplate>
<Grid>
<!-- Ellipse читає Background через TemplateBinding -->
<Ellipse Name="Circle"
Fill="{TemplateBinding Background}"
Transitions="0.15s Background"/>
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</ControlTemplate>
</Setter>
<!-- Hover: без TargetName, просто властивість контролу -->
<Style Selector="^:pointerover">
<Setter Property="Background" Value="#3730A3"/>
</Style>
<!-- Pressed: мікро-зум (через властивість контролу) -->
<Style Selector="^:pressed">
<Setter Property="Background" Value="#312E81"/>
<Setter Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleX="0.92" ScaleY="0.92"/>
</Setter.Value>
</Setter>
</Style>
<!-- Disabled -->
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.4"/>
</Style>
</ControlTheme>
<ControlTheme x:Key="CircleDanger" TargetType="Button"
BasedOn="{StaticResource CircleButton}">
<Setter Property="Background" Value="#EF4444"/>
<Style Selector="^:pointerover">
<Setter Property="Background" Value="#DC2626"/>
</Style>
</ControlTheme>
<ControlTheme x:Key="CircleSuccess" TargetType="Button"
BasedOn="{StaticResource CircleButton}">
<Setter Property="Background" Value="#10B981"/>
<Style Selector="^:pointerover">
<Setter Property="Background" Value="#059669"/>
</Style>
</ControlTheme>
</StackPanel.Resources>
<Button Theme="{StaticResource CircleButton}" Content="+"
Command="{Binding ShowMessageCommand}" CommandParameter="Додати!"/>
<Button Theme="{StaticResource CircleDanger}" Content="✕"
Command="{Binding ShowMessageCommand}" CommandParameter="Видалити!"/>
<Button Theme="{StaticResource CircleSuccess}" Content="✓"
Command="{Binding ShowMessageCommand}" CommandParameter="Підтвердити!"/>
<Button Theme="{StaticResource CircleButton}" Content="★"
Background="#F59E0B"/>
<Button Theme="{StaticResource CircleButton}" Content="⊘"
IsEnabled="False"/>
</StackPanel>
Ключова перевага 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 є однією з найбільш цінованих особливостей фреймворку.Практичні завдання
Ціль: Зрозуміти різницю між WPF і Avalonia підходами на практиці.
Завдання: Портуйте наступний WPF ControlTemplate на Avalonia ControlTheme.
Вихідний WPF-код (TextBox зі стилем форми):
<Style TargetType="TextBox">
<Setter Property="Background" Value="#F9FAFB"/>
<Setter Property="BorderBrush" Value="#D1D5DB"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1.5"
CornerRadius="6">
<ScrollViewer x:Name="PART_ContentHost"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="BorderBrush" Value="#9CA3AF"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="border" Property="BorderBrush" Value="#4F46E5"/>
<Setter TargetName="border" Property="Background" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Кроки портингу:
- Замість
Style TargetType="TextBox"→ControlTheme x:Key="..." TargetType="TextBox". - Замість
ControlTemplate TargetType="TextBox"→ControlTemplateбезTargetType(у Setter). - Збережіть
PART_ContentHost— він обов'язковий для TextBox у Avalonia теж. IsMouseOverTrigger →Style Selector="^:pointerover"→Setter Property="BorderBrush".IsFocusedTrigger →Style Selector="^:focus"→Setter Property="BorderBrush"+Background.- Застосуйте до форми із трьох полів: «Email», «Пароль», «Підтвердження».
Перевірка: кліком на поле — рамка стає синьою. Без фокуса — сіра. Hover → злегка темніша рамка.
Підсумок
Що ми вивчили у цій статті
ControlTheme — нова архітектура Avalonia 11+ для визначення зовнішнього вигляду та станів контролу. Не замінює Style — це паралельна, але взаємодоповнювальна система.
Зіставлення понять:
| WPF | Avalonia |
|---|---|
Style з ControlTemplate | ControlTheme |
Trigger Property="IsMouseOver" | Style Selector="^:pointerover" |
Setter TargetName="border" | Style Selector="^:state /template/ Border" або Setter на контролі |
Style="{StaticResource key}" | Theme="{StaticResource key}" |
Implicit: Style без x:Key | Implicit: 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-підходом, що ми вивчили сьогодні.
Control Templates — Частина 2. Named Parts та ContentPresenter
Просунуті механізми WPF ControlTemplate. ContentPresenter та ContentSource, ItemsPresenter, Named Parts (PART_*), OnApplyTemplate та GetTemplateChild — від теорії до кастомного ProgressBar, Toggle-Switch та ComboBox.
Triggers та Visual State Manager у WPF
Property Triggers, DataTrigger, MultiTrigger, EventTrigger та Visual State Manager — повний посібник з реактивної стилізації у WPF. Порівняння зі стилями і Avalonia-підходом.