Desktop UI

Control Templates — Частина 2. Named Parts та ContentPresenter

Просунуті механізми WPF ControlTemplate. ContentPresenter та ContentSource, ItemsPresenter, Named Parts (PART_*), OnApplyTemplate та GetTemplateChild — від теорії до кастомного ProgressBar, Toggle-Switch та ComboBox.
Нові терміни у цій статті:ContentPresenter, ContentSource, ContentTemplate, ItemsPresenter, Named Parts, PART_* конвенція, OnApplyTemplate(), GetTemplateChild<T>(), TemplatePartAttribute, VisualStateManager.

Чому ContentPresenter — це не просто «placeholder»

У Частині 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, які реально використовуються у практиці.

ContentSource

За замовчуванням 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>

ContentTemplate

ContentPresenter реагує на ContentTemplate хост-контролу автоматично. Якщо встановити <Button ContentTemplate="{StaticResource MyTemplate}"/>ContentPresenter використає цей шаблон для відображення Content. Це означає, що у вашому кастомному шаблоні ContentPresenter вже «вміє» працювати з ContentTemplate без жодних додаткових налаштувань — просто завдяки прив'язкам, що ContentPresenter встановлює автоматично при зв'язку з хост-контролом.

RecognizesAccessKey

RecognizesAccessKey="True" — важлива властивість у шаблонах Button та кнопкоподібних контролів. Вона дозволяє ContentPresenter розпізнавати символ підкреслення _ у Content як AccessKey (гарячу клавішу). Наприклад, Content="_Зберегти" при RecognizesAccessKey="True" відображається як Зберегти з підкресленим «З», і натискання Alt+З клацає кнопку. Без цієї властивості символ підкреслення просто відображається як текст.


Мінімальна демонстрація: ContentPresenter із DataTemplate

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...


ItemsPresenter: ContentPresenter для колекцій

Якщо 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>

Named Parts: контракт між контролом та шаблоном

Тут ми підходимо до одного з найбільш архітектурно важливих аспектів WPF — Named Parts або PART_-конвенція.

Уявімо, що ви пишете кастомний ComboBox. У стандартному ComboBox є дві принципово важливі частини:

  1. Поле вводуTextBox, де відображається вибране значення.
  2. Спадаючий списокPopup, що відкривається при кліку.

Клас ComboBox у C# очікує знайти ці частини всередині свого шаблону. Коли поле відображується, ComboBox викликає метод OnApplyTemplate(), де шукає внутрішні елементи за іменами. Якщо він знайшов TextBox з іменем PART_EditableTextBox — підключає до нього логіку вводу. Якщо знайшов Popup з іменем PART_Popup — підключає логіку відкриття/закриття.

Якщо ви напишете кастомний шаблон, де Popup називається myPopup а не PART_PopupComboBox просто не знайде його і не підключить логіку. Контрол буде візуально верно виглядати, але кліки не відкриватимуть список. І що важливо — WPF не кине виняток. Він просто тихо пропустить підключення відсутньої частини.

Конвенція PART_*

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 Parts популярних контролів WPF

КонтролNamed PartТипПризначення
TextBoxPART_ContentHostScrollViewerХостить текстовий вміст
PasswordBoxPART_ContentHostScrollViewerХостить замасковані символи
ComboBoxPART_EditableTextBoxTextBoxПоле вводу (editable mode)
ComboBoxPART_PopupPopupСпадаючий список
SliderPART_TrackTrackПовзунок (thumb + rail)
ProgressBarPART_IndicatorFrameworkElementЗаповнена частина
ProgressBarPART_TrackFrameworkElementТрек (повна довжина)
ScrollBarPART_TrackTrackТрек прокрутки
ListBoxItemPART_HeaderContentPresenter
ExpanderPART_HeaderSiteToggleButtonКнопка розгортання
Якщо ви замінюєте шаблон контролу, що використовує Named Parts (особливо TextBox, ComboBox, Slider), і прибираєте або перейменовуєте PART-елементи — відповідна функціональність мовчки перестане працювати. WPF не кидає Exception, не видає попереджень. Це класичне джерело важко знайдених помилок у WPF-проєктах.

Типові помилки при роботі з ControlTemplate

Розберемо найпоширеніші помилки, з якими стикається кожен, хто починає писати кастомні шаблони.

1. Забутий ContentPresenter → Content не відображається

<!-- ❌ Помилка: немає ContentPresenter -->
<ControlTemplate TargetType="Button">
    <Border Background="Blue" Padding="10">
        <!-- Content кнопки нікуди не рендеруватиметься! -->
    </Border>
</ControlTemplate>

Кнопка відображатиметься як синій прямокутник — без жодного тексту чи іконки, незалежно від Content. Виправлення — додати <ContentPresenter/> всередині Border.

2. Відсутній PART_ елемент

<!-- ❌ Помилка у шаблоні TextBox: PART_ContentHost відсутній -->
<ControlTemplate TargetType="TextBox">
    <Border>
        <TextBlock Text="Фіктивний вміст"/>
        <!-- Немає ScrollViewer з Name="PART_ContentHost" -->
        <!-- TextBox не зможе відображати введений текст -->
    </Border>
</ControlTemplate>

TextBox без PART_ContentHost виглядатиме як просте поле, але ввід не відображатиметься і курсор не з'явиться. Жодного Exception.

3. TargetType не збігається

<!-- ❌ Помилка: 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 із градієнтом

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)...

Превью використовує Avalonia. У реальному WPF ProgressBar із цим шаблоном буде ідентичним. Ключовий момент: x:Name="PART_Indicator" на Border — без цього WPF не знатиме, яку частину масштабувати при зміні Value. Перевірте: змініть Value з 35 на 80 — заповнена смуга розшириться.
У цьому прикладі градієнт жорстко задано у шаблоні. Щоб зробити колір кастомізованим через Foreground, потрібно замість LinearGradientBrush використати {TemplateBinding Foreground} на BackgroundPART_Indicator. Для різних кольорів прогресу — параметризований підхід через BasedOn або окреме DependencyProperty у кастомному CustomControl.

Практика: CheckBox у вигляді Toggle-Switch

Toggle-Switch — це компонент, що стало асоціюється з мобільними та сучасними десктопними UI. У WPF немає вбудованого ToggleSwitchControl, але CheckBox — ідеальна основа для його реалізації через ControlTemplate. CheckBox вже має властивість IsChecked (bool?) та всю логіку перемикання. Нам залишається лише замінити його візуальне дерево.

Ключова деталь цієї реалізації — внутрішній ControlTemplate.Trigger на властивості IsChecked. Саме він перемикає позицію «великого пальця» та колір фону між станами.

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

ControlTemplate.Triggers — внутрішні тригери шаблону — це єдиний правильний спосіб анімувати зміну стану елемента в межах шаблону. Вони мають доступ до TargetName — можуть прицільно змінювати властивості конкретних Named елементів шаблону. Style.Triggers такого доступу не мають (лише до властивостей самого контролу). Саме тому hover-ефекти, що змінюють вигляд внутрішнього Border — рентабельніше писати у ControlTemplate.Triggers.

Зверніть на техніку: Trigger переміщує Thumb через зміну HorizontalAlignment та Margin, а не через анімацію. Це простіше для розуміння, але менш «гладко». Для плавної анімації переходу (Transition) використовують VisualStateManager із Storyboard — тема Блоку 9 (Анімації).


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


Підсумок

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

Ця стаття завершила вивчення 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 дозволяє комбінувати умови.