ControlTemplate, TemplateBinding, ContentPresenter, Setter Property="Template", Template="{StaticResource}", Lookless Control, Visual Tree vs Logical Tree, TargetType у ControlTemplate.Відкрийте будь-який WPF-проєкт і подивіться на кнопку. Ви бачите прямокутник із заокругленими кутами, сірий фон, напис по центру. Це здається очевидним: кнопка — це такий прямокутник із текстом. Але це ілюзія. Те, що ви бачите — лише шаблон за замовчуванням (DefaultTemplate), одягнений на об'єкт Button.
Сам об'єкт Button — це поведінка. Він знає, як реагувати на клік (подія Click), як обробляти натискання клавіш (Enter, Пробіл), як передавати фокус, як виконати команду ICommand. Але він не знає, як він виглядає. Це принципова архітектурна рішення WPF, яка отримала назву Lookless Controls — контроли без фіксованого вигляду.
Кожен стандартний контрол WPF (Button, TextBox, ListBox, ComboBox, Slider, ProgressBar) постачається зі своїм шаблоном за замовчуванням — але цей шаблон повністю замінний. Хочете круглу кнопку? Замініть шаблон на Ellipse. Хочете кнопку у вигляді зірки? Замініть на Path із зірчастою геометрією. Хочете кнопку, яка виглядає як посилання у браузері — підкреслений текст без фону? Замініть шаблон на мінімальний TextBlock. Жодних обмежень, жодного успадкування від нового базового класу — просто новий ControlTemplate.
Ця архітектура пояснює, чому WPF є настільки потужним для кастомізації UI. Бібліотеки тем (MahApps.Metro, MaterialDesignInXaml, Fluent.Ribbon) реалізовані саме через заміну ControlTemplate у всіх стандартних контролів. Коли ви підключаєте MahApps — ви не отримуєте нові класи кнопок. Ви отримуєте нові ControlTemplate-и для старих.
ControlTemplate — це опис візуального дерева (Visual Tree) контролу. Він вказує: «коли WPF відображає цей контрол, побудуй ось таке дерево XAML-елементів». Шаблон може містити будь-які FrameworkElement-и: Border, Grid, StackPanel, Ellipse, Path, TextBlock — що завгодно.
Важливо розрізняти два дерева у WPF:
Logical Tree — дерево логічних об'єктів, яке ви описуєте у XAML. Window → StackPanel → Button. Це дерево відображає структуру вашого додатку.
Visual Tree — повне дерево всіх візуальних об'єктів, включаючи внутрішні елементи шаблонів. Button → Chrome → ContentPresenter → TextBlock (усередині DefaultTemplate). Це дерево відображає те, що реально рендериться на екрані.
ControlTemplate визначає, що знаходиться у Visual Tree всередині контролу — ту частину, яку ви зазвичай не бачите і не змінюєте. Коли ви пишете Template="{StaticResource myTemplate}" або <Setter Property="Template"> у стилі — ви замінюєте цю внутрішню частину повністю.
Є два способи вказати шаблон для контролу:
Спосіб 1: Вбудований у Style — найпоширеніший у реальних проєктах:
<Style TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<!-- Ваше дерево елементів -->
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Спосіб 2: Окремий ресурс із ключем — якщо шаблон використовується кількома різними контролами або потрібно посилатися на нього вибірково:
<Window.Resources>
<ControlTemplate x:Key="MyButtonTemplate" TargetType="Button">
<!-- Ваше дерево елементів -->
</ControlTemplate>
</Window.Resources>
<!-- Застосування: -->
<Button Template="{StaticResource MyButtonTemplate}" Content="Click"/>
У більшості випадків перший спосіб є кращим — він тримає шаблон і стиль разом, що спрощує супровід.
Почнімо з найпростішого можливого випадку. Замінимо стандартний шаблон Button на найпростіший власний: Border із ContentPresenter всередині.
Але спочатку — що таке ContentPresenter? Це спеціальний елемент-проксі, призначений виключно для використання всередині ControlTemplate. Він говорить: «відобрази сюди вміст контролу (властивість Content)». Саме через ContentPresenter ваш Button Content="Click me" відображає текст або будь-який інший об'єкт.
Без ContentPresenter у шаблоні — Content кнопки буде проігнорований. Кнопка відобразиться, але без жодного тексту чи іконки всередині.
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="14">
<StackPanel.Resources>
<Style TargetType="Button">
<!-- Інші властивості -->
<Setter Property="Padding" Value="16,10"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Background" Value="#4F46E5"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<!-- Замінюємо Template повністю -->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="#4F46E5"
CornerRadius="8"
Padding="16,10">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</StackPanel.Resources>
<Button Content="Кнопка з кастомним шаблоном"
Command="{Binding ShowMessageCommand}"
CommandParameter="Шаблон працює!"/>
<Button Content="Ще одна кнопка"/>
<Button Content="Третя"/>
</StackPanel>
Подивіться на ці кнопки. Вони мають заокруглені кути (CornerRadius="8") та синій фон — не через Setter Property="Background" та Setter Property="CornerRadius", а тому що сам шаблон жорстко задає Background="#4F46E5" та CornerRadius="8".
Але тут є прихована проблема. Спробуйте додати Background="Red" безпосередньо на одну з кнопок:
<Button Content="Я хочу бути червоною" Background="Red"/>
Кнопка залишиться синьою. Чому? Тому що шаблон жорстко прописав Background="#4F46E5" у Border — і не «слухає» зовнішнє значення Background. Саме тут і виникає потреба у TemplateBinding.
TemplateBinding — це спеціальний вид прив'язки даних, доступний тільки всередині ControlTemplate. Він прив'язує властивість елемента шаблону до властивості самого контролу (хост-елементу).
Синтаксис: {TemplateBinding PropertyName}, де PropertyName — ім'я DependencyProperty контролу-хоста (Button, TextBox тощо).
Повернімося до нашого прикладу і замінимо жорсткі значення на TemplateBinding:
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="12">
<StackPanel.Resources>
<Style TargetType="Button">
<!-- Значення через Setter — шаблон їх прочитає через TemplateBinding -->
<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="HorizontalAlignment" Value="Left"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ContentPresenter
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</StackPanel.Resources>
<!-- Всі три кнопки — один шаблон, різний Background -->
<Button Content="Синя (зі стилю)"
Command="{Binding ShowMessageCommand}"
CommandParameter="Синя!"/>
<Button Content="Зелена (локальне Background)" Background="#10B981"/>
<Button Content="Червона (локальне Background)" Background="#EF4444"/>
<Button Content="Великий Padding" Padding="30,16"/>
</StackPanel>
Тепер шаблон живий — він читає Background з кнопки, а кнопка може змінити свій фон через локальне значення або Setter. Зелена кнопка зелена тому, що локальне Background="..." перемагає Setter (пріоритети зі статті 27), і шаблон через TemplateBinding Background бере це нове значення.
Всередині ControlTemplate недоступний DataContext (або він — DataContext хост-контролу, а не батьківського елемента). TemplateBinding — це оптимізований варіант Binding з явним джерелом (хост-контрол). Він:
Source або RelativeSourceOneWay){Binding RelativeSource={RelativeSource TemplatedParent}})Якщо вам потрібен двоспрямований binding або конвертер — замість TemplateBinding використовуйте {Binding PropertyName, RelativeSource={RelativeSource TemplatedParent}}.
Перш ніж писати власний шаблон, корисно зрозуміти — а що саме ми замінюємо? Яким є стандартний шаблон Button у WPF?
Стандартний шаблон Button у темі Aero (Windows 7+) базується на ButtonChrome — внутрішньому chrome-елементі теми. У Windows 8+ (Aero2) структура спростилась до Border + ContentPresenter зі Trigger-ами:
<ControlTemplate TargetType="Button">
<Border x:Name="border"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Background="{TemplateBinding Background}"
SnapsToDevicePixels="true">
<ContentPresenter x:Name="contentPresenter"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
Margin="{TemplateBinding Padding}"
RecognizesAccessKey="True"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="true">
<Setter TargetName="border" Property="Background" Value="#FFBEE6FD"/>
</Trigger>
<Trigger Property="IsPressed" Value="true">
<Setter TargetName="border" Property="Background" Value="#FFC4E5F6"/>
</Trigger>
<!-- ...ще Trigger'и для IsEnabled=False, IsFocused тощо -->
</ControlTemplate.Triggers>
</ControlTemplate>
Зверніть: ось звідки беруться hover-ефекти у стандартній WPF-кнопці — ControlTemplate.Triggers. Це внутрішні Trigger-и шаблону, не Style.Triggers. Їх ми детально розберемо у Частині 2.
src/Microsoft.DotNet.Wpf/src/Themes/XAML/.ComboBox, DataGrid, TreeView.Тепер реалізуємо першу повноцінну кастомізацію — кругла кнопка (FAB-стиль, як у Material Design). Замість Border використаємо Grid із Ellipse та ContentPresenter поверх:
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="20" Orientation="Horizontal">
<StackPanel.Resources>
<Style 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">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<!-- Ellipse займає весь Grid — дає кругову форму -->
<Ellipse Fill="{TemplateBinding Background}"/>
<!-- ContentPresenter відображає Content по центру -->
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="CircleButtonDanger" TargetType="Button"
BasedOn="{StaticResource CircleButton}">
<Setter Property="Background" Value="#EF4444"/>
</Style>
<Style x:Key="CircleButtonSuccess" TargetType="Button"
BasedOn="{StaticResource CircleButton}">
<Setter Property="Background" Value="#10B981"/>
</Style>
</StackPanel.Resources>
<Button Content="+" Style="{StaticResource CircleButton}"
Command="{Binding ShowMessageCommand}" CommandParameter="Додати!"/>
<Button Content="✕" Style="{StaticResource CircleButtonDanger}"
Command="{Binding ShowMessageCommand}" CommandParameter="Видалити!"/>
<Button Content="✓" Style="{StaticResource CircleButtonSuccess}"
Command="{Binding ShowMessageCommand}" CommandParameter="Підтвердити!"/>
<!-- TemplateBinding в дії: локальний жовтий фон через TemplateBinding Background -->
<Button Content="★" Style="{StaticResource CircleButton}"
Background="#F59E0B"/>
</StackPanel>
Зверніть на останню кнопку із зірочкою: Background="#F59E0B" задається локально, і шаблон через {TemplateBinding Background} передає його у Ellipse.Fill — кнопка стає жовтою. Це і є сила TemplateBinding: один шаблон, різний вигляд залежно від властивостей контролу.
Ellipse + ContentPresenter у Grid дає ідеально круглу кнопку. Важливо: Width і Height мають бути рівними, щоб коло не деформувалося у еліпс.Другий класичний сценарій — кнопка без фону, яка виглядає як просте посилання або іконка. Панелі інструментів, кнопки «Закрити» у заголовку вікна, кнопки «Поділитися» — все це зазвичай кнопки з мінімальним або відсутнім фоном.
Ключова ідея: ControlTemplate, що містить лише ContentPresenter — без будь-якого Border чи Background. Контрол залишається функціональним (Click, Command, фокус), але не має жодного власного вигляду — лише відображає Content.
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="16">
<StackPanel.Resources>
<!-- GhostButton: прозорий, лише ContentPresenter -->
<Style x:Key="GhostButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#374151"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Opacity="{TemplateBinding Opacity}"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- LinkButton: виглядає як посилання -->
<Style x:Key="LinkButton" TargetType="Button">
<Setter Property="Foreground" Value="#4F46E5"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
TextBlock.Foreground="{TemplateBinding Foreground}"
TextBlock.TextDecorations="Underline"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</StackPanel.Resources>
<!-- Панель інструментів із ghost-кнопками -->
<Border Background="#F9FAFB" CornerRadius="8" Padding="8">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="📋" Style="{StaticResource GhostButton}" FontSize="18"
Command="{Binding ShowMessageCommand}" CommandParameter="Копіювати"/>
<Button Content="✂️" Style="{StaticResource GhostButton}" FontSize="18"
Command="{Binding ShowMessageCommand}" CommandParameter="Вирізати"/>
<Button Content="📎" Style="{StaticResource GhostButton}" FontSize="18"
Command="{Binding ShowMessageCommand}" CommandParameter="Вставити"/>
<Button Content="🗑️" Style="{StaticResource GhostButton}" FontSize="18"
Foreground="#EF4444"
Command="{Binding ShowMessageCommand}" CommandParameter="Видалити"/>
</StackPanel>
</Border>
<!-- LinkButton -->
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="Немає акаунту?" FontSize="14" Foreground="#6B7280"
VerticalAlignment="Center"/>
<Button Content="Зареєструватись" Style="{StaticResource LinkButton}"
Command="{Binding ShowMessageCommand}" CommandParameter="Реєстрація!"/>
</StackPanel>
</StackPanel>
GhostButton — мінімальна обгортка. Він поводиться точно як Button (клікабельний, фокусований, Command-сумісний), але виглядає як простий зміст. У реальних проєктах ghost- та icon-кнопки часто доповнюються Trigger-ом для hover-ефекту (зміна Opacity або появлення легкого фону) прямо всередині ControlTemplate.Triggers — це тема Частини 2.Ціль: Написати перший власний ControlTemplate з нуля.
Завдання: Реалізуйте панель дій для картки профілю — три круглі кнопки «Edit», «Share», «Delete».
CircleButton із шаблоном Ellipse + ContentPresenter у Grid.Width="44", Height="44", FontSize="16".TemplateBinding Background у Ellipse.Fill — обов'язково.Background та Content (емодзі або символ).Command="{Binding ShowMessageCommand}" з різним CommandParameter спрацьовує при кліку.Перевірка: змініть Width і Height стилю з 44 на 64 — всі три кнопки одночасно стали більшими.
Ціль: Написати GhostButton з hover-ефектом через ControlTemplate.Triggers.
Завдання: Кнопка, яка виглядає як текстове посилання, але при наведенні підкреслюється.
LinkButton стиль із шаблоном, що містить лише TextBlock → Run → зміст кнопки.Foreground="{TemplateBinding Foreground}", Cursor="Hand".<ControlTemplate.Triggers>:
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="text" Property="TextDecorations" Value="Underline"/>
</Trigger>
LinkButton у формі входу: «Забули пароль?», «Зареєструватись», «Умови використання».Перевірка: наводьте курсор — підкреслення з'являється та зникає без жодного C#.
Ця стаття відкрила фундаментальну концепцію WPF-архітектури, без якої неможливо зрозуміти, як влаштовані бібліотеки тем та кастомні UI-компоненти.
Lookless Controls — контроли WPF не мають фіксованого вигляду. Вони — чиста поведінка. Зовнішній вигляд визначається ControlTemplate, який можна повністю замінити.
ControlTemplate — опис Visual Tree контролу. Може містити будь-які FrameworkElement-и. Визначається через <Setter Property="Template"><Setter.Value><ControlTemplate ...> всередині Style або як окремий ресурс.
ContentPresenter — обов'язковий елемент у шаблоні будь-якого ContentControl (Button, Label тощо). Відображає значення властивості Content. Без нього вміст кнопки не відображається.
TemplateBinding — прив'язка всередині шаблону до властивостей хост-контролу. {TemplateBinding Background} читає Background з Button і передає у Ellipse.Fill. Робить шаблон живим і гнучким. Односпрямований, без конвертерів.
Default Template — у WPF (Aero2/Windows 8+) стандартний шаблон Button — це Border + ContentPresenter зі ControlTemplate.Triggers для hover та pressed станів.
Кругла кнопка: Ellipse + ContentPresenter у Grid — класичний патерн для FAB та іконкових круглих кнопок.
Ghost/Link Button: мінімальний шаблон із лише ContentPresenter або TextBlock — поведінка (клік, фокус) зберігається, вигляд повністю кастомний.
У Частині 2 цієї статті ми глибоко занурюємося у ControlTemplate.Triggers — внутрішні тригери шаблону. Це той механізм, що дозволяє hover/pressed анімації без C#. Ви також дізнаєтесь про Named Parts (x:Name у шаблоні, GetTemplateChild<T>()) — спосіб, яким контрол знаходить свої частини всередині шаблону. Без цих знань неможливо написати шаблон для ComboBox, Slider або власного кастомного контролу.
CSS-like стилі Avalonia
Система стилізації Avalonia — CSS-селектори, Style Classes, Nesting, Combinators та Specificity. Порівняння з WPF Style і практичні приклади hover-ефектів, zebra-рядків та динамічних класів.
Control Templates — Частина 2. Named Parts та ContentPresenter
Просунуті механізми WPF ControlTemplate. ContentPresenter та ContentSource, ItemsPresenter, Named Parts (PART_*), OnApplyTemplate та GetTemplateChild — від теорії до кастомного ProgressBar, Toggle-Switch та ComboBox.