ControlTheme, ControlTheme.BasedOn, PseudoClass (:pressed, :pointerover), Selector у ControlTheme, Styles vs ControlTheme, SimpleTheme, FluentTheme, ThemeVariant, ціль :is().Якщо ви прийшли у 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 порівняно зі стандартним 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>
Одразу впадає в очі кілька ключових відмінностей. Розглянемо їх детально.
У Avalonia <ControlTemplate> всередині ControlTheme не потребує явного TargetType — він автоматично береться з TargetType батьківського ControlTheme. Це зменшує дублювання.
WPF використовує Trigger-и з Property="IsMouseOver" Value="True". Avalonia використовує CSS Pseudo-classes: :pointerover, :pressed, :disabled, :checked.
Синтаксис ^:pointerover у Selector означає:
^ — посилання на батьківський елемент у scope (тут — сам контрол Button).:pointerover — псевдоклас «курсор над елементом».Разом: «застосуй цей стиль до Button, що має псевдоклас :pointerover».
У 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} — це чистіше.
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}}" |
Реалізуємо повноцінну кастомну кнопку через 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-аналог WPF Style="{StaticResource ...}". Властивість Theme (не Style) застосовує ControlTheme до контролу.BasedOn у ControlTheme — точний аналог WPF BasedOn у Style. DangerButton успадковує весь шаблон і всі Setter-и PrimaryButton, перевизначаючи лише Background.Одна з найпоширеніших задач у реальних проєктах — злегка підправити зовнішній вигляд вбудованого контролу, не перевизначаючи увесь шаблон. В 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 + часткове перевизначення можна:
| Задача | Як |
|---|---|
| Змінити кольори кнопки | Setter Property="Background" + override :pointerover |
| Розмір шрифту/padding | Setter Property="FontSize", Setter Property="Padding" |
| Заокруглення кутів | Через /template/ Border Selector |
| Анімація у стані | Style Selector="^:pointerover /template/ Border" + Transitions |
| Повна заміна шаблону | Setter Property="Template" — перевизначає все |
У статті 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 теж.IsMouseOver Trigger → Style Selector="^:pointerover" → Setter Property="BorderBrush".IsFocused Trigger → Style Selector="^:focus" → Setter Property="BorderBrush" + Background.Перевірка: кліком на поле — рамка стає синьою. Без фокуса — сіра. 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:
Transitions замість Storyboard для простих анімацій.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-підходом.