ContentPresenter, ContentSource, ContentTemplate, ItemsPresenter, Named Parts, PART_* конвенція, OnApplyTemplate(), GetTemplateChild<T>(), TemplatePartAttribute, VisualStateManager.У Частині 1 ми вже познайомились із ContentPresenter як з елементом, що «відображає вміст». Але якщо зупинитися лише на цьому визначенні — рано чи пізно виникне нерозуміння. Чому іноді ContentPresenter показує дані, а іноді — типове ім'я об'єкта? Чому у ListBox замість ContentPresenter використовується ItemsPresenter? Чому ContentPresenter потрібно розміщати саме у певному місці шаблону?
Щоб відповісти на ці питання, потрібно розуміти, що ContentPresenter — це не просто «дірка для вставки дітей». Це механізм вирішення Content з повноцінною логікою відображення. Він знає, як відобразити рядок (через TextBlock), як відобразити UIElement (безпосередньо), як відобразити довільний .NET-об'єкт (через DataTemplate). І саме через ContentPresenter у WPF реалізована вся потужність DataTemplate-ів.
Коли ви пишете <Button Content="Hello"/> — ContentPresenter у шаблоні бачить рядок "Hello" і відображає його через вбудований TextBlock. Коли ви пишете <Button Content="{Binding CurrentUser}"/> — ContentPresenter бачить об'єкт User і шукає відповідний DataTemplate для його відображення. Якщо у Button.ContentTemplate або у ресурсах є DataTemplate для типу User — він використовується. Якщо ні — ContentPresenter виводить ToString() об'єкта.
Це означає, що кнопка може містити не лише текст, а й складний UI-компонент: зображення, іконку з текстом, будь-який XAML-граф — і ContentPresenter відобразить його точно у тому місці шаблону, де ви його розмістите.
Розглянемо властивості ContentPresenter, які реально використовуються у практиці.
За замовчуванням ContentPresenter читає значення властивості Content хост-контролу. Але що, якщо ваш кастомний контрол має дві контентні зони — наприклад, заголовок та основний вміст? У такому разі ContentSource дозволяє вказати, яку саме властивість відображати.
ContentSource="Header" — означає: замість стандартного Content відображай значення з властивості Header хост-контролу. При цьому ContentPresenter також автоматично прив'язується до HeaderTemplate та HeaderTemplateSelector (якщо вони є), а шаблон даних шукається для типу хост-контролу.
Ця властивість найчастіше зустрічається у шаблонах GroupBox та TabItem:
<!-- Фрагмент DefaultTemplate для GroupBox -->
<ControlTemplate TargetType="GroupBox">
<Grid>
<!-- Заголовок через ContentSource="Header" -->
<ContentPresenter ContentSource="Header"
RecognizesAccessKey="True"/>
<!-- Основний вміст через звичайний ContentPresenter -->
<ContentPresenter/>
</Grid>
</ControlTemplate>
ContentPresenter реагує на ContentTemplate хост-контролу автоматично. Якщо встановити <Button ContentTemplate="{StaticResource MyTemplate}"/> — ContentPresenter використає цей шаблон для відображення Content. Це означає, що у вашому кастомному шаблоні ContentPresenter вже «вміє» працювати з ContentTemplate без жодних додаткових налаштувань — просто завдяки прив'язкам, що ContentPresenter встановлює автоматично при зв'язку з хост-контролом.
RecognizesAccessKey="True" — важлива властивість у шаблонах Button та кнопкоподібних контролів. Вона дозволяє ContentPresenter розпізнавати символ підкреслення _ у Content як AccessKey (гарячу клавішу). Наприклад, Content="_Зберегти" при RecognizesAccessKey="True" відображається як Зберегти з підкресленим «З», і натискання Alt+З клацає кнопку. Без цієї властивості символ підкреслення просто відображається як текст.
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="16">
<StackPanel.Resources>
<!-- DataTemplate для відображення "badge" у кнопці -->
<DataTemplate x:Key="BadgeButtonContent">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Background="White" CornerRadius="10"
Padding="6,2" VerticalAlignment="Center">
<TextBlock Text="{Binding}" Foreground="#4F46E5"
FontSize="11" FontWeight="Bold"/>
</Border>
<TextBlock Text="Сповіщення" Foreground="White"
FontSize="14" VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
<!-- Кастомний шаблон кнопки -->
<Style x:Key="BadgeButton" TargetType="Button">
<Setter Property="Background" Value="#4F46E5"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Padding" Value="16,10"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="8"
Padding="{TemplateBinding Padding}">
<!-- ContentPresenter автоматично відобразить ContentTemplate -->
<ContentPresenter/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</StackPanel.Resources>
<!-- Content="5" + ContentTemplate → DataTemplate відображує badge з числом -->
<Button Style="{StaticResource BadgeButton}"
Content="5"
ContentTemplate="{StaticResource BadgeButtonContent}"
Command="{Binding ShowMessageCommand}"
CommandParameter="Сповіщення!"/>
<!-- Звичайний текстовий Content -->
<Button Style="{StaticResource BadgeButton}"
Content="Кнопка з текстом"/>
</StackPanel>
Якщо ContentPresenter призначений для ContentControl-ів (контролів з одним вмістом), то ItemsPresenter — його аналог для ItemsControl-ів (контролів із колекцією): ListBox, ComboBox, TreeView, ItemsControl, DataGrid.
ItemsPresenter говорить шаблону: «відобрази тут всі елементи колекції Items». Без нього у шаблоні ListBox не буде жодного списку — контрол буде відображатися, але пустим.
Ключова відмінність від ContentPresenter: ItemsPresenter використовує ItemsPanel контролу для визначення, як розміщувати елементи (StackPanel, WrapPanel, VirtualizingStackPanel — залежно від налаштувань контролу). ItemsPresenter є обов'язковою Named Part з іменем PART_ItemsPresenter у шаблонах ItemsControl.
<!-- Мінімальний шаблон ListBox -->
<ControlTemplate TargetType="ListBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ScrollViewer>
<!-- ItemsPresenter — місце для відображення рядків списку -->
<ItemsPresenter/>
</ScrollViewer>
</Border>
</ControlTemplate>
Тут ми підходимо до одного з найбільш архітектурно важливих аспектів WPF — Named Parts або PART_-конвенція.
Уявімо, що ви пишете кастомний ComboBox. У стандартному ComboBox є дві принципово важливі частини:
TextBox, де відображається вибране значення.Popup, що відкривається при кліку.Клас ComboBox у C# очікує знайти ці частини всередині свого шаблону. Коли поле відображується, ComboBox викликає метод OnApplyTemplate(), де шукає внутрішні елементи за іменами. Якщо він знайшов TextBox з іменем PART_EditableTextBox — підключає до нього логіку вводу. Якщо знайшов Popup з іменем PART_Popup — підключає логіку відкриття/закриття.
Якщо ви напишете кастомний шаблон, де Popup називається myPopup а не PART_Popup — ComboBox просто не знайде його і не підключить логіку. Контрол буде візуально верно виглядати, але кліки не відкриватимуть список. І що важливо — WPF не кине виняток. Він просто тихо пропустить підключення відсутньої частини.
Named Parts — це угода (конвенція), задокументована через атрибут [TemplatePart] на класі контролу. Ось як це виглядає у вихідному коді WPF:
[TemplatePart(Name = "PART_EditableTextBox", Type = typeof(TextBox))]
[TemplatePart(Name = "PART_Popup", Type = typeof(Popup))]
public class ComboBox : Selector
{
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
// Знаходимо частини шаблону за іменем
_textBox = GetTemplateChild("PART_EditableTextBox") as TextBox;
_popup = GetTemplateChild("PART_Popup") as Popup;
// Підключаємо логіку тільки якщо елемент знайдено
if (_textBox != null)
{
_textBox.TextChanged += OnTextBoxTextChanged;
}
if (_popup != null)
{
_popup.Opened += OnPopupOpened;
}
}
}
Навіщо if (_textBox != null)? Тому що WPF специфікація дозволяє шаблони без усіх частин. Контрол деградує граційно: без PART_EditableTextBox він не буде редагованим, але це не помилка — просто функціональність вимкнута.
| Контрол | Named Part | Тип | Призначення |
|---|---|---|---|
TextBox | PART_ContentHost | ScrollViewer | Хостить текстовий вміст |
PasswordBox | PART_ContentHost | ScrollViewer | Хостить замасковані символи |
ComboBox | PART_EditableTextBox | TextBox | Поле вводу (editable mode) |
ComboBox | PART_Popup | Popup | Спадаючий список |
Slider | PART_Track | Track | Повзунок (thumb + rail) |
ProgressBar | PART_Indicator | FrameworkElement | Заповнена частина |
ProgressBar | PART_Track | FrameworkElement | Трек (повна довжина) |
ScrollBar | PART_Track | Track | Трек прокрутки |
ListBoxItem | PART_Header | ContentPresenter | — |
Expander | PART_HeaderSite | ToggleButton | Кнопка розгортання |
TextBox, ComboBox, Slider), і прибираєте або перейменовуєте PART-елементи — відповідна функціональність мовчки перестане працювати. WPF не кидає Exception, не видає попереджень. Це класичне джерело важко знайдених помилок у WPF-проєктах.Розберемо найпоширеніші помилки, з якими стикається кожен, хто починає писати кастомні шаблони.
<!-- ❌ Помилка: немає ContentPresenter -->
<ControlTemplate TargetType="Button">
<Border Background="Blue" Padding="10">
<!-- Content кнопки нікуди не рендеруватиметься! -->
</Border>
</ControlTemplate>
Кнопка відображатиметься як синій прямокутник — без жодного тексту чи іконки, незалежно від Content. Виправлення — додати <ContentPresenter/> всередині Border.
<!-- ❌ Помилка у шаблоні TextBox: PART_ContentHost відсутній -->
<ControlTemplate TargetType="TextBox">
<Border>
<TextBlock Text="Фіктивний вміст"/>
<!-- Немає ScrollViewer з Name="PART_ContentHost" -->
<!-- TextBox не зможе відображати введений текст -->
</Border>
</ControlTemplate>
TextBox без PART_ContentHost виглядатиме як просте поле, але ввід не відображатиметься і курсор не з'явиться. Жодного Exception.
<!-- ❌ Помилка: TargetType у ControlTemplate ≠ TargetType у Style -->
<Style TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContentControl"> <!-- ← Неправильно! -->
...
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Якщо TargetType у ControlTemplate не збігається з типом контролу — TemplateBinding для властивостей, специфічних для Button (наприклад, IsDefault, IsCancel), не працюватиме.
ProgressBar — ідеальний контрол для першого знайомства з Named Parts. Він має два Named Parts: PART_Track (повна ширина/висота) та PART_Indicator (заповнена частина, ширина якої визначається Value). Замінимо стандартний шаблон на сучасний градієнтний вигляд.
Перш ніж писати шаблон, розуміємо механізм ProgressBar: власний клас у WPF вираховує відсоток заповнення і програматично встановлює ширину PART_Indicator. Тому ми повинні дати x:Name="PART_Indicator" елементу, що відображає прогрес — інакше WPF не знатиме, що масштабувати.
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="20" Width="350">
<StackPanel.Resources>
<Style TargetType="ProgressBar">
<Setter Property="Height" Value="20"/>
<Setter Property="Background" Value="#E5E7EB"/>
<Setter Property="Foreground" Value="#4F46E5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid x:Name="PART_Track" Height="{TemplateBinding Height}">
<!-- Фоновий трек (сірий) -->
<Border Background="{TemplateBinding Background}"
CornerRadius="10"/>
<!-- Заповнена частина: ОБОВ'ЯЗКОВО Name="PART_Indicator" -->
<Border x:Name="PART_Indicator"
CornerRadius="10"
HorizontalAlignment="Left">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#6366F1" Offset="0"/>
<GradientStop Color="#8B5CF6" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
<!-- Текст по центру -->
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="11"
FontWeight="SemiBold"
Foreground="White">
<Run Text="{Binding Value,
RelativeSource={RelativeSource TemplatedParent},
StringFormat={}{0:0}%}"/>
</TextBlock>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</StackPanel.Resources>
<TextBlock Text="Завантаження файлів:" FontSize="13" Foreground="#374151"/>
<ProgressBar Value="35"/>
<TextBlock Text="Обробка даних:" FontSize="13" Foreground="#374151"/>
<ProgressBar Value="68" Foreground="#10B981">
<ProgressBar.Foreground>
<!-- Нічого: Foreground TemplateBinding не використовується у цьому шаблоні -->
<!-- Background градієнту жорстко задано. Для зміни — потрібен BasedOn + override -->
</ProgressBar.Foreground>
</ProgressBar>
<TextBlock Text="Завершення:" FontSize="13" Foreground="#374151"/>
<ProgressBar Value="100"/>
</StackPanel>
ProgressBar із цим шаблоном буде ідентичним. Ключовий момент: x:Name="PART_Indicator" на Border — без цього WPF не знатиме, яку частину масштабувати при зміні Value. Перевірте: змініть Value з 35 на 80 — заповнена смуга розшириться.Foreground, потрібно замість LinearGradientBrush використати {TemplateBinding Foreground} на BackgroundPART_Indicator. Для різних кольорів прогресу — параметризований підхід через BasedOn або окреме DependencyProperty у кастомному CustomControl.Toggle-Switch — це компонент, що стало асоціюється з мобільними та сучасними десктопними UI. У WPF немає вбудованого ToggleSwitchControl, але CheckBox — ідеальна основа для його реалізації через ControlTemplate. CheckBox вже має властивість IsChecked (bool?) та всю логіку перемикання. Нам залишається лише замінити його візуальне дерево.
Ключова деталь цієї реалізації — внутрішній ControlTemplate.Trigger на властивості IsChecked. Саме він перемикає позицію «великого пальця» та колір фону між станами.
Loading Avalonia WebAssembly...
Downloading .NET runtime (10MB)...
<StackPanel Margin="20" Spacing="16">
<StackPanel.Resources>
<Style TargetType="CheckBox">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Spacing="10">
<!-- Трек перемикача -->
<Border x:Name="Track"
Width="44" Height="24"
CornerRadius="12"
Background="#D1D5DB">
<!-- Великий палець (thumb) -->
<Border x:Name="Thumb"
Width="18" Height="18"
CornerRadius="9"
Background="White"
Margin="3,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"/>
</Border>
<!-- Підпис -->
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<!-- Стан IsChecked=True: зелений трек, thumb праворуч -->
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Track" Property="Background" Value="#4F46E5"/>
<Setter TargetName="Thumb" Property="HorizontalAlignment" Value="Right"/>
<Setter TargetName="Thumb" Property="Margin" Value="0,0,3,0"/>
</Trigger>
<!-- Hover: трохи темніший фон -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Track" Property="Opacity" Value="0.85"/>
</Trigger>
<!-- Disabled: приглушений вигляд -->
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Track" Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</StackPanel.Resources>
<CheckBox Content="Сповіщення електронною поштою" IsChecked="True"/>
<CheckBox Content="Push-сповіщення" IsChecked="False"/>
<CheckBox Content="Звукові сповіщення" IsChecked="True"/>
<CheckBox Content="(заблоковано)" IsChecked="False" IsEnabled="False"/>
</StackPanel>
ControlTemplate.Triggers — внутрішні тригери шаблону — це єдиний правильний спосіб анімувати зміну стану елемента в межах шаблону. Вони мають доступ до TargetName — можуть прицільно змінювати властивості конкретних Named елементів шаблону. Style.Triggers такого доступу не мають (лише до властивостей самого контролу). Саме тому hover-ефекти, що змінюють вигляд внутрішнього Border — рентабельніше писати у ControlTemplate.Triggers.Зверніть на техніку: Trigger переміщує Thumb через зміну HorizontalAlignment та Margin, а не через анімацію. Це простіше для розуміння, але менш «гладко». Для плавної анімації переходу (Transition) використовують VisualStateManager із Storyboard — тема Блоку 9 (Анімації).
Ціль: Написати перший шаблон із Named Part.
Завдання: Горизонтальний ProgressBar «Заряд батареї».
ProgressBar зі своїм шаблоном.Grid з PART_Track, усередині два Border — сірий фон і PART_Indicator (зелений).TextBlock по центру: {Binding Value, RelativeSource=TemplatedParent, StringFormat={}{0:0}%}.#22C55E → 100% #16A34A.ProgressBar: 20%, 50%, 80%, 100%.x:Name="PART_Indicator" обов'язковий — видаліть його та переконайтесь, що смуга зникає.Ціль: Повна заміна CheckBox на Toggle-Switch дизайн.
Завдання: Панель налаштувань із 5 toggle-перемикачами.
CheckBox із шаблоном Toggle-Switch.StackPanel (Horizontal) → Border Track + Border Thumb + ContentPresenter.ControlTemplate.Triggers:
IsChecked=True → Track.Background="#6366F1", Thumb.HorizontalAlignment=Right.IsMouseOver=True → Track.Opacity=0.9.IsEnabled=False → весь Border Track Opacity=0.4.small та large через x:Key.Ціль: Замінити шаблон ComboBox зі збереженням PART_Popup і PART_EditableTextBox.
Завдання: ComboBox у стилі сучасного UI з заокругленими кутами.
PART_Popup і PART_EditableTextBox — змінюйте лише їх вигляд.ToggleButton (стрілка вниз) на Border із ▾ символом.CornerRadius="8" та BorderBrush="#D1D5DB" до зовнішнього Border.Popup — Border із CornerRadius="8" та BoxShadow.ComboBox повинен:
Ця стаття завершила вивчення ControlTemplate — від базової концепції до повної кастомізації реальних контролів.
ContentPresenter глибоко — не просто «placeholder», а механізм вирішення Content. Підтримує DataTemplate, ContentTemplate, ContentSource="Header" для двохзонних шаблонів. RecognizesAccessKey="True" — важлива деталь для кнопкоподібних контролів.
ItemsPresenter — аналог ContentPresenter для ItemsControl. Де розміщений ItemsPresenter — там з'являється список елементів. Використовує ItemsPanel контролу для визначення layout.
Named Parts (PART_*) — контракт між класом контролу та його шаблоном. Задокументовані через [TemplatePart] атрибут. Порушення іменування → мовчазне вимкнення функціоналу (без Exception).
OnApplyTemplate / GetTemplateChild — механізм, яким клас контролу знаходить PART-елементи після застосування шаблону. if (part != null) — обов'язкова перевірка.
Типові помилки: забутий ContentPresenter, відсутній PART, невідповідний TargetType.
ProgressBar шаблон: PART_Indicator — обов'язковий іменований елемент. WPF програматично встановлює його ширину на основі Value.
CheckBox → Toggle-Switch: ControlTemplate.Triggers для IsChecked → зміна положення Thumb та кольору Track. TargetName дозволяє прицільно змінювати конкретний Named елемент шаблону.
Наступна стаття — Triggers у WPF: Style.Triggers, DataTrigger, MultiTrigger, EventTrigger. Ви дізнаєтесь, чим Style.Triggers відрізняються від ControlTemplate.Triggers (і чому це важливо), як DataTrigger реагує на дані (а не лише на властивості контролу), і як MultiTrigger дозволяє комбінувати умови.
Control Templates — Частина 1. Концепція та TemplateBinding
Повне розуміння ControlTemplate у WPF. Як замінити зовнішній вигляд будь-якого контролу повністю — від мінімального прикладу до кастомної кнопки з TemplateBinding.
Control Themes в Avalonia — нова ера стилізації
ControlTheme в Avalonia 11+ — CSS-like селектори замість Triggers, декларативна система стилізування контролів. Порівняння з WPF ControlTemplate та практичний портинг кастомної кнопки.