Desktop UI

Розширення розмітки XAML (Markup Extensions)

Вивчаємо механізм фігурних дужок {…} у XAML: MarkupExtension, ProvideValue, вбудовані x:Static/x:Type/x:Null/x:Array/Binding, перший погляд на Binding та створення Custom Markup Extension.

Розширення розмітки XAML (Markup Extensions)

Щоразу, коли ви пишете Background="{StaticResource PrimaryBrush}" або Text="{Binding UserName}" — ви бачите XAML Markup Extension. Фігурні дужки {...} — це не особливий синтаксис, не магія і не вбудована можливість XML. Це виклик методу.

Конкретно: {} у значенні атрибута — це виклик методу ProvideValue() відповідного класу, що наслідує абстрактний клас MarkupExtension. XAML-парсер побачив {StaticResource PrimaryBrush}, знайшов клас StaticResourceExtension, створив його екземпляр і викликав ProvideValue() — і те, що повернув ProvideValue(), і стало значенням Background.

Це нетривіальне розуміння, але воно відкриває двері до Power User рівня XAML. Ви більше не питаєте "чому це працює" — ви розумієте "як саме це працює".

Словник теми:Markup Extension — клас що наслідує MarkupExtension з єдиним абстрактним методом ProvideValue(IServiceProvider). IServiceProvider — інтерфейс через який MarkupExtension дізнається контекст: на якій властивості, якого об'єкта, в якому файлі він викликається. [MarkupExtensionReturnType] — атрибут що декларує тип, який поверне ProvideValue. x:Static — звернення до статичних членів (поля, властивості, enum-значення) з XAML. x:Type — передача типу (typeof(T)) як значення у XAML. x:Null — явний null як значення атрибуту. x:Array — створення масиву у XAML.

Механізм: що відбувається за {...}

Розглянемо детально, як XAML-парсер обробляє рядок Background="{StaticResource PrimaryBrush}".

Крок 1: Парсер бачить {

XAML-парсер читає XML-атрибут. Якщо значення починається з { (і не починається з {}— це escape для літерального {) — це Markup Extension. Парсер не обробляє решту рядка як рядкове значення.

Крок 2: Знаходить клас

Перше слово після { — ім'я Markup Extension класу. WPF (і XAML взагалі) відшукує клас за таким алгоритмом:

  1. Парсер бере текст StaticResource
  2. Спочатку шукає StaticResourceExtension (конвенція: суфікс Extension може бути відкинутий у XAML)
  3. Шукає клас у всіх xmlns-маппінгах поточного файлу
// WPF знаходить саме цей клас:
public class StaticResourceExtension : MarkupExtension
{
    public StaticResourceExtension(object resourceKey) { ... }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        // Шукає ресурс по ключу у ResourceDictionary
        return FindResource(resourceKey, serviceProvider);
    }
}

Крок 3: Парсер розбирає параметри і викликає ProvideValue

Все після імені класу — це параметри. PrimaryBrush — позиційний параметр (йде у конструктор). Для іменованих параметрів використовується синтаксис {ExtensionName Property=Value, OtherProp=Value2}:

<!-- Позиційний параметр: йде у конструктор(ключ) -->
{StaticResource PrimaryBrush}

<!-- Іменовані параметри: встановлюються через властивості -->
{Binding ElementName=mySlider, Path=Value, Mode=OneWay}

<!-- Комбінація: перший позиційний, далі іменовані -->
{DynamicResource {x:Type Button}}

Після парсингу параметрів — парсер викликає ProvideValue(serviceProvider). Результат присвоюється властивості (у прикладі — Background).

Escape-символ: {}

Щоб написати літеральну рядок що починається з { — використовується {} як escape:

<!-- Це Markup Extension (викликає ProvideValue): -->
<TextBlock Text="{Binding UserName}"/>

<!-- Це літеральний рядок "{Binding UserName}" — без виклику: -->
<TextBlock Text="{}{Binding UserName}"/>

<!-- Або через Property Element Syntax: -->
<TextBlock>
    <TextBlock.Text>{Binding UserName}</TextBlock.Text>
</TextBlock>

IServiceProvider: контекст виконання

ProvideValue(IServiceProvider serviceProvider) отримує serviceProvider — це не просто формальність. Через нього MarkupExtension може дізнатися:

public override object ProvideValue(IServiceProvider serviceProvider)
{
    // Дізнатися цільовий об'єкт і властивість
    var targetProvider = serviceProvider.GetService<IProvideValueTarget>();
    var targetObject = targetProvider.TargetObject;     // наприклад, Button
    var targetProperty = targetProvider.TargetProperty; // наприклад, Button.Background

    // Дізнатися тип (для x:Type)
    var typeProvider = serviceProvider.GetService<IXamlTypeResolver>();

    // Дізнатися шлях до файлу (для дизайн-таймових перевірок)
    var rootProvider = serviceProvider.GetService<IRootObjectProvider>();

    return /* обчислене значення */;
}

StaticResourceExtension використовує targetObject щоб знайти ResourceDictionary через Visual Tree — начинаючи від цільового Button піднятись до Application.Resources.


Вбудовані Markup Extensions: x:

{x:Static}: статичні члени у XAML

x:Static дозволяє посилатися на статичні поля, властивості та enum-значення прямо у XAML:

<!-- Системний колір: SystemColors.HighlightBrush -->
<TextBlock Foreground="{x:Static SystemColors.HighlightBrush}"
           Text="Я системного кольору виділення"/>

<!-- Системний шрифт: SystemFonts.DefaultFontFamily -->
<TextBlock FontFamily="{x:Static SystemFonts.DefaultFontFamily}"
           Text="Я у системному шрифті"/>

<!-- Системний розмір шрифту -->
<TextBlock FontSize="{x:Static SystemFonts.DefaultFontSize}"
           Text="Розмір шрифту ОС"/>

<!-- Enum-значення (FlowDirection) -->
<TextBlock FlowDirection="{x:Static FlowDirection.RightToLeft}"
           Text="Справа наліво (RTL)"/>

Для власних статичних членів — потрібен xmlns:local:

<!-- Ваш статичний клас з константами -->
<Window xmlns:local="clr-namespace:MyApp">
    <Border BorderThickness="{x:Static local:AppConstants.DefaultBorderThickness}"/>
    <TextBlock FontSize="{x:Static local:AppTheme.BaseFontSize}"/>
</Window>
// У C#:
public static class AppConstants
{
    public static readonly Thickness DefaultBorderThickness = new Thickness(1);
    public static readonly double CardCornerRadius = 8.0;
}

public static class AppTheme
{
    public static readonly double BaseFontSize = 14.0;
    public static readonly double HeadingFontSize = 24.0;
    public static readonly Brush AccentBrush = new SolidColorBrush(Color.FromRgb(37, 99, 235));
}

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Корисне правило: якщо ви хочете щоб ваш застосунок виглядав нативно і враховував налаштування Windows (для людей з вадами зору, що змінюють кольори ОС) — використовуйте SystemColors та SystemFonts замість хардкодованих значень.

{x:Type}: тип як значення

x:Type — це XAML-аналог оператора typeof() у C#. Він повертає System.Type. Найчастіше використовується у стилях, темах та DataTemplate:

<!-- Без x:Type (було б помилкою — рядок не конвертується у Type): -->
<!-- <Style TargetType="Button"> -->  <!-- Насправді XAML сам розуміє рядок тут -->

<!-- З x:Type (явна форма): -->
<Style TargetType="{x:Type Button}">
    <Setter Property="Padding" Value="8,4"/>
</Style>

<!-- x:Type у DataTrigger: -->
<DataTrigger Binding="{Binding CurrentView}" Value="{x:Type local:HomeView}">
    <Setter Property="Content" Value="Головна"/>
</DataTrigger>

<!-- x:Type для створення об'єкта через ObjectDataProvider: -->
<ObjectDataProvider ObjectType="{x:Type local:MyDataService}"
                    MethodName="GetAllUsers"/>

Чому TargetType="Button" і TargetType="{x:Type Button}" обидва працюють? Тому що TargetType має Type Converter (TypeTypeConverter), що перетворює рядок "Button" у typeof(Button). Але явний x:Type читабельніший і не залежить від Type Converter.

{x:Null}: явний null

Використовується коли стандартний null неможливо передати рядковим значенням, або коли треба явно перевизначити успадковане значення:

<!-- Кнопка без фону (прозора) — явно null, а не просто "Transparent" -->
<Button Background="{x:Null}" Content="Без фону"/>

<!-- Скасування успадкованого курсору -->
<TextBox Cursor="{x:Null}" Text="Звичайний курсор (успадкований)"/>

<!-- Border без рамки (null відрізняється від Transparent!) -->
<Border BorderBrush="{x:Null}" BorderThickness="0" Background="#e2e8f0">
    <TextBlock Text="Border без рамки"/>
</Border>

Різниця між {x:Null} та рядком-пустим значенням:

  • Background="" → помилка або пусте значення
  • Background="{x:Null}"null, повністю прозорий (хіт-тестинг теж відсутній)
  • Background="Transparent"#00FFFFFF — прозорий колір, але Background != null (хіт-тестинг працює)

{x:Array}: масив у XAML

Дозволяє оголосити масив прямо у XAML:

<Window xmlns:sys="clr-namespace:System;assembly=mscorlib">
    <Window.Resources>
        <!-- Масив рядків -->
        <x:Array x:Key="Countries" Type="{x:Type sys:String}">
            <sys:String>Україна</sys:String>
            <sys:String>Польща</sys:String>
            <sys:String>Німеччина</sys:String>
            <sys:String>Франція</sys:String>
        </x:Array>

        <!-- Масив кольорів -->
        <x:Array x:Key="PaletteColors" Type="{x:Type Color}">
            <Color>#2563eb</Color>
            <Color>#7c3aed</Color>
            <Color>#059669</Color>
            <Color>#dc2626</Color>
        </x:Array>
    </Window.Resources>

    <StackPanel>
        <!-- Використання у ComboBox -->
        <ComboBox ItemsSource="{StaticResource Countries}"/>

        <!-- Або у ListBox -->
        <ListBox ItemsSource="{StaticResource Countries}"/>
    </StackPanel>
</Window>

x:Array рідко використовується у production (там зазвичай ItemsSource прив'язаний до ViewModel), але корисний для прототипування і демонстрацій.


{StaticResource} і {DynamicResource} — тепер очима Markup Extension

Ми вже знайомі з цими двома зі статті про ресурси. Але тепер, розуміючи механізм MarkupExtension, подивимось глибше.

StaticResourceExtension — найпростіший з Markup Extensions. Його ProvideValue викликається один раз і повертає значення з ResourceDictionary. Немає підписок, немає відслідковування — просто пошук і повернення:

// Спрощена реалізація StaticResourceExtension:
public class StaticResourceExtension : MarkupExtension
{
    public object ResourceKey { get; set; }

    public StaticResourceExtension(object resourceKey)
        => ResourceKey = resourceKey;

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        var target = serviceProvider.GetService<IProvideValueTarget>();
        var targetObject = target.TargetObject as FrameworkElement;

        // Шукаємо по ResourceDictionary-ланцюжку:
        return targetObject?.FindResource(ResourceKey)
            ?? throw new XamlParseException($"Ресурс '{ResourceKey}' не знайдено");
    }
}

DynamicResourceExtension — складніший. Його ProvideValue не повертає значення одразу. Натомість він реєструє слухача на зміни ResourceDictionary і повертає this (або спеціальний placeholder). Коли ресурс з'являється або змінюється — слухач викликає SetValue безпосередньо на цільовій властивості:

// Псевдокод DynamicResourceExtension:
public override object ProvideValue(IServiceProvider serviceProvider)
{
    var target = serviceProvider.GetService<IProvideValueTarget>();

    // Підписуємось на зміни ресурсу:
    var listener = new ResourceChangedListener(target.TargetObject, target.TargetProperty, ResourceKey);
    ResourceDictionary.AddResourceChangedHandler(listener);

    // Повертаємо поточне значення (може бути null якщо ресурс ще не існує):
    return FindCurrentValue(ResourceKey, target.TargetObject);
}

Ось звідки така різниця у поведінці: StaticResource — одноразовий знімок, DynamicResource — підписка на зміни.


{Binding}: перший погляд

{Binding} — найскладніший і найпотужніший Markup Extension у WPF. Ми детально вивчимо його у Блоці 7 (Data Binding). Але вже зараз — важливо зрозуміти: Binding теж є Markup Extension. Він підпорядковується тим самим правилам.

Прямо зараз розглянемо лише одну форму Binding: прив'язка до іншого UI-елемента через ElementName.

Binding між елементами: ElementName

<!-- Slider → TextBlock: без жодного C# коду! -->
<StackPanel Spacing="12" Margin="16">
    <Slider x:Name="volumeSlider" Minimum="0" Maximum="100" Value="50"/>

    <!-- TextBlock.Text прив'язаний до volumeSlider.Value -->
    <TextBlock Text="{Binding ElementName=volumeSlider, Path=Value}"/>
</StackPanel>

Що тут відбувається з точки зору Markup Extension:

  1. Парсер бачить {Binding ElementName=volumeSlider, Path=Value}
  2. Знаходить клас BindingExtension (або Binding у WPF)
  3. Встановлює його властивості: ElementName = "volumeSlider", Path = new PropertyPath("Value")
  4. Викликає ProvideValue — той повертає BindingExpression
  5. BindingExpression знаходить елемент volumeSlider за ім'ям і підписується на зміни його Value
  6. При кожній зміні — оновлює TextBlock.Text

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

{Binding ElementName=..., Path=...} — це Element-to-Element Binding. Він не потребує ViewModel, DataContext чи INotifyPropertyChanged. Binding-механізм напряму підписується на DependencyProperty одного елемента і оновлює DependencyProperty іншого. У Блоці 7 ми розберемо DataContext Binding, конвертери значень, режими (OneWay/TwoWay/OneTime) і багато іншого.

Custom Markup Extension: власний {...}

Найпотужніша частина системи Markup Extensions — ви можете створити власний. Це звичайний C#-клас і кілька рядків коду.

Структура Custom Markup Extension

using System.Windows.Markup;

// Суфікс Extension відкидається у XAML: {Multiply Value=..., Factor=...}
public class MultiplyExtension : MarkupExtension
{
    // Властивості = іменовані параметри у XAML
    public double Value { get; set; }
    public double Factor { get; set; }

    // Конструктор за замовчуванням (обов'язковий для іменованих параметрів)
    public MultiplyExtension() { }

    // Конструктор з позиційними параметрами (опційний)
    public MultiplyExtension(double value, double factor)
    {
        Value = value;
        Factor = factor;
    }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        return Value * Factor;
    }
}
<!-- Використання у XAML: -->
<Window xmlns:ext="clr-namespace:MyApp.Extensions">
    <TextBlock FontSize="{ext:Multiply Value=4, Factor=4}"
               Text="FontSize = 4 × 4 = 16"/>

    <Border Width="{ext:Multiply 12, 8}"
            Height="{ext:Multiply 4, 8}"
            Background="#2563eb"/>
</Window>

Реалістичний приклад: {EnumDescription}

Практичніший Custom Extension — той, що перетворює enum-значення у human-readable опис:

// Атрибут для enum-значень
public class DescriptionAttribute : Attribute
{
    public string Description { get; }
    public DescriptionAttribute(string description) => Description = description;
}

// Enum з описами
public enum UserStatus
{
    [Description("Активний")] Active,
    [Description("Заблокований")] Banned,
    [Description("Очікує підтвердження")] PendingVerification,
    [Description("Видалений")] Deleted
}

// Extension
public class EnumDescriptionExtension : MarkupExtension
{
    public Enum Value { get; set; }

    public EnumDescriptionExtension(Enum value) => Value = value;

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        if (Value is null) return string.Empty;

        var field = Value.GetType().GetField(Value.ToString());
        var attr = field?.GetCustomAttribute<DescriptionAttribute>();
        return attr?.Description ?? Value.ToString();
    }
}
<!-- Використання: -->
<xmlns:ext="clr-namespace:MyApp.Extensions"
 xmlns:enums="clr-namespace:MyApp.Domain">

<!-- Замість Text="Active" → "Активний" -->
<TextBlock Text="{ext:EnumDescription {x:Static enums:UserStatus.Active}}"/>
<TextBlock Text="{ext:EnumDescription {x:Static enums:UserStatus.PendingVerification}}"/>

Повноцінний приклад: {LocalizedString}

Найпоширеніший сценарій — локалізація рядків через Markup Extension:

[MarkupExtensionReturnType(typeof(string))]
public class LocalizedStringExtension : MarkupExtension
{
    public string Key { get; set; }

    public LocalizedStringExtension() { }
    public LocalizedStringExtension(string key) => Key = key;

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        if (string.IsNullOrEmpty(Key))
            return $"[Empty Key]";

        // Шукаємо ресурс у ResourceManager або власному словнику
        var value = Strings.ResourceManager.GetString(Key,
            Thread.CurrentThread.CurrentUICulture);

        return value ?? $"[{Key}]";  // Fallback: показуємо ключ у квадратних дужках
    }
}
<!-- Тепер UI залишається декларативним при будь-якій локалізації: -->
<Button Content="{ext:LocalizedString SaveChanges}"/>
<TextBlock Text="{ext:LocalizedString WelcomeMessage}"/>
<Window Title="{ext:LocalizedString AppTitle}"/>

Перевага перед {StaticResource} для локалізації: LocalizedStringExtension може враховувати поточну культуру, перевантаження за певними правилами (fallback до базової мови), і при потребі — підписатися на зміни мови в runtime (якщо зробити DynamicResource-подібну логіку).

Назва класу зазвичай закінчується на Extension, але у XAML цей суфікс відкидається: LocalizedStringExtension в XAML використовується як {ext:LocalizedString Key}. Це конвенція, а не вимога — ніщо не заважає назвати клас L10n і писати {ext:L10n Key}.

Підсумок

Механізм

{...} у XAML = виклик ProvideValue() класу що наслідує MarkupExtension. IServiceProvider дає доступ до контексту: цільовий об'єкт, властивість, кореневий об'єкт.

Вбудовані x:

  • {x:Static} → статичні поля/властивості/enum
  • {x:Type}typeof(T) у XAML
  • {x:Null} → явний null
  • {x:Array Type=...} → масив у XAML

Static vs Dynamic

StaticResource.ProvideValue → одноразовий пошук і повернення. DynamicResource.ProvideValue → підписка на зміни ResourceDictionary, оновлення через SetValue при зміні.

Custom Extension

Клас наслідує MarkupExtension, реалізує ProvideValue. Властивості класу = іменовані параметри у {MyExt Prop=Value}. Конструктор = позиційні параметри. Нульова магія.

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