Desktop UI

Теми та ресурсні словники у WPF

Побудова системи тематизації WPF-додатку. DynamicResource, MergedDictionaries, Light/Dark теми з runtime-перемиканням, SystemColors, огляд MaterialDesignInXamlToolkit, MahApps.Metro та HandyControl.
Нові терміни у цій статті:DynamicResource, StaticResource, Runtime Theme Switching, ResourceDictionary, MergedDictionaries, Application.Current.Resources, SystemColors, SystemFonts, MaterialDesignInXamlToolkit, MahApps.Metro, HandyControl.

StaticResource vs DynamicResource: фундаментальна різниця

У WPF ресурси можна отримувати двома способами: {StaticResource} та {DynamicResource}. Ця різниця є фундаментальною для побудови систем тематизації, і її важливо зрозуміти перш ніж переходити до практики.

{StaticResource} вирішується один раз: при завантаженні XAML-файлу. Це означає, що якщо ресурс змінився після завантаження — елемент цього не дізнається. StaticResource трохи швидший, оскільки не відстежує зміни. Але для тематизації — абсолютно непридатний.

{DynamicResource} відстежує ресурс під час виконання: якщо ресурс у словнику замінено — всі елементи, що посилаються на нього через DynamicResource, автоматично оновляться. Саме це є механізмом, що дозволяє перемикати теми без перезавантаження вікна.

Проілюструємо різницю:

<!-- Light тема у ресурсах: -->
<SolidColorBrush x:Key="AppBackground" Color="#F9FAFB"/>

<!-- Кнопка з StaticResource ---                     -->
<!-- При зміні AppBackground після завантаження —    -->
<!-- Background кнопки НЕ зміниться                  -->
<Button Background="{StaticResource AppBackground}" Content="Не реагує на тему"/>

<!-- Кнопка з DynamicResource ---                    -->
<!-- При зміні AppBackground — Background оновиться  -->
<Button Background="{DynamicResource AppBackground}" Content="Реагує на тему"/>

Правило для тематизованих проєктів: всі ресурси, що залежать від теми, мають використовувати DynamicResource. Ресурси, що не змінюються ніколи (розміри, фіксовані шрифти, константи) — можуть використовувати StaticResource.


Організація Resource Dictionary у великому проєкті

Перш ніж реалізовувати перемикання тем, потрібно зрозуміти, як організувати ресурси у файловій структурі реального проєкту. Зберігати все в App.xaml — рішення, яке не масштабується. Вже при 50+ ресурсах це стає некерованим.

Рекомендована структура:

MyApp/
├── App.xaml                ← реєстрація тем
├── Themes/
│   ├── Light.xaml          ← кольори світлої теми
│   └── Dark.xaml           ← кольори темної теми
├── Styles/
│   ├── Buttons.xaml        ← стилі кнопок (DynamicResource на кольори)
│   ├── TextBoxes.xaml      ← стилі полів вводу
│   ├── Typography.xaml     ← стилі тексту
│   └── Cards.xaml          ← стилі карток/панелей
└── Resources/
    ├── Icons.xaml          ← іконки (Path Geometry)
    └── Converters.xaml     ← ресурси конвертерів

Принцип поділу: Themes/ містять лише кольори та кольорові ресурси (Brush, Color). Styles/ містять стилі контролів, що посилаються на теми через {DynamicResource}. Це забезпечує повне розділення: замінюємо тему — стилі автоматично беруть нові кольори.

Структура файлу теми

<!-- Themes/Light.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <!--  Семантичні ключі — не "LightBlue", а "AppBackground"  -->
    <Color x:Key="AppBackgroundColor">#F9FAFB</Color>
    <Color x:Key="AppSurfaceColor">#FFFFFF</Color>
    <Color x:Key="AppBorderColor">#E5E7EB</Color>
    <Color x:Key="AppTextPrimaryColor">#111827</Color>
    <Color x:Key="AppTextSecondaryColor">#6B7280</Color>
    <Color x:Key="AppAccentColor">#4F46E5</Color>
    <Color x:Key="AppAccentHoverColor">#3730A3</Color>
    <Color x:Key="AppSuccessColor">#10B981</Color>
    <Color x:Key="AppDangerColor">#EF4444</Color>
    <Color x:Key="AppWarningColor">#F59E0B</Color>

    <!-- Brush-и на основі Color-ів -->
    <SolidColorBrush x:Key="AppBackgroundBrush"
                     Color="{StaticResource AppBackgroundColor}"/>
    <SolidColorBrush x:Key="AppSurfaceBrush"
                     Color="{StaticResource AppSurfaceColor}"/>
    <SolidColorBrush x:Key="AppBorderBrush"
                     Color="{StaticResource AppBorderColor}"/>
    <SolidColorBrush x:Key="AppTextPrimaryBrush"
                     Color="{StaticResource AppTextPrimaryColor}"/>
    <SolidColorBrush x:Key="AppTextSecondaryBrush"
                     Color="{StaticResource AppTextSecondaryColor}"/>
    <SolidColorBrush x:Key="AppAccentBrush"
                     Color="{StaticResource AppAccentColor}"/>
    <SolidColorBrush x:Key="AppAccentHoverBrush"
                     Color="{StaticResource AppAccentHoverColor}"/>
    <SolidColorBrush x:Key="AppSuccessBrush"
                     Color="{StaticResource AppSuccessColor}"/>
    <SolidColorBrush x:Key="AppDangerBrush"
                     Color="{StaticResource AppDangerColor}"/>

</ResourceDictionary>
<!-- Themes/Dark.xaml — ідентичні ключі, інші значення -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Color x:Key="AppBackgroundColor">#0F172A</Color>
    <Color x:Key="AppSurfaceColor">#1E293B</Color>
    <Color x:Key="AppBorderColor">#334155</Color>
    <Color x:Key="AppTextPrimaryColor">#F8FAFC</Color>
    <Color x:Key="AppTextSecondaryColor">#94A3B8</Color>
    <Color x:Key="AppAccentColor">#818CF8</Color>
    <Color x:Key="AppAccentHoverColor">#A5B4FC</Color>
    <Color x:Key="AppSuccessColor">#34D399</Color>
    <Color x:Key="AppDangerColor">#F87171</Color>
    <Color x:Key="AppWarningColor">#FBBF24</Color>

    <SolidColorBrush x:Key="AppBackgroundBrush"
                     Color="{StaticResource AppBackgroundColor}"/>
    <SolidColorBrush x:Key="AppSurfaceBrush"
                     Color="{StaticResource AppSurfaceColor}"/>
    <SolidColorBrush x:Key="AppBorderBrush"
                     Color="{StaticResource AppBorderColor}"/>
    <SolidColorBrush x:Key="AppTextPrimaryBrush"
                     Color="{StaticResource AppTextPrimaryColor}"/>
    <SolidColorBrush x:Key="AppTextSecondaryBrush"
                     Color="{StaticResource AppTextSecondaryColor}"/>
    <SolidColorBrush x:Key="AppAccentBrush"
                     Color="{StaticResource AppAccentColor}"/>
    <SolidColorBrush x:Key="AppAccentHoverBrush"
                     Color="{StaticResource AppAccentHoverColor}"/>
    <SolidColorBrush x:Key="AppSuccessBrush"
                     Color="{StaticResource AppSuccessColor}"/>
    <SolidColorBrush x:Key="AppDangerBrush"
                     Color="{StaticResource AppDangerColor}"/>

</ResourceDictionary>
Важливо використовувати семантичні ключіAppBackground, а не LightGray. Семантичне іменування описує призначення ресурсу, а не його колір. Так AppBackground у Light темі може бути #F9FAFB, а у Dark — #0F172A. Стилі залишаються незмінними. Якщо ж ключ — LightGrayBrush — це ім'я перестає мати сенс у Dark темі.

App.xaml: реєстрація MergedDictionaries

У App.xaml реєструємо початковий набір словників через MergedDictionaries:

<!-- App.xaml -->
<Application x:Class="MyApp.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="MainWindow.xaml">

    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>

                <!-- 1. Спочатку тема (кольори) -->
                <ResourceDictionary Source="Themes/Light.xaml"/>

                <!-- 2. Потім стилі (посилаються на кольори через DynamicResource) -->
                <ResourceDictionary Source="Styles/Buttons.xaml"/>
                <ResourceDictionary Source="Styles/TextBoxes.xaml"/>
                <ResourceDictionary Source="Styles/Typography.xaml"/>
                <ResourceDictionary Source="Styles/Cards.xaml"/>

                <!-- 3. Ресурси (конвертери, іконки) -->
                <ResourceDictionary Source="Resources/Converters.xaml"/>

            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

Порядок важливий: тема Light.xaml завантажується перед стилями. Якщо стиль у Buttons.xaml використовує {DynamicResource AppAccentBrush} — на момент завантаження стилю ресурс вже існує у словнику.


Runtime перемикання теми: ThemeService

Тепер реалізуємо патерн ThemeService — сервіс, що відповідає за перемикання теми. Механізм: знаходимо у MergedDictionaries поточний словник теми, замінюємо його новим. Оскільки всі елементи використовують DynamicResource — вони автоматично відмалюються.

// Services/ThemeService.cs
public enum AppTheme { Light, Dark }

public static class ThemeService
{
    private const string LightThemeSource = "Themes/Light.xaml";
    private const string DarkThemeSource  = "Themes/Dark.xaml";

    public static AppTheme CurrentTheme { get; private set; } = AppTheme.Light;

    public static void ApplyTheme(AppTheme theme)
    {
        var mergedDicts = Application.Current.Resources.MergedDictionaries;

        // Знаходимо поточний словник теми (перший у MergedDictionaries)
        var themeDict = mergedDicts.FirstOrDefault(d =>
            d.Source?.OriginalString.Contains("Themes/") == true);

        if (themeDict != null)
            mergedDicts.Remove(themeDict);

        // Визначаємо URI нової теми
        var newThemeUri = theme == AppTheme.Dark
            ? new Uri(DarkThemeSource, UriKind.Relative)
            : new Uri(LightThemeSource, UriKind.Relative);

        // Завантажуємо та вставляємо новий словник
        var newThemeDict = new ResourceDictionary { Source = newThemeUri };
        mergedDicts.Insert(0, newThemeDict);  // Вставляємо на початок

        CurrentTheme = theme;
    }

    public static void ToggleTheme() =>
        ApplyTheme(CurrentTheme == AppTheme.Light ? AppTheme.Dark : AppTheme.Light);
}
// ViewModel: кнопка перемикання теми
public class SettingsViewModel : ObservableObject
{
    private bool _isDarkTheme = false;
    public bool IsDarkTheme
    {
        get => _isDarkTheme;
        set
        {
            SetProperty(ref _isDarkTheme, value);
            ThemeService.ApplyTheme(value ? AppTheme.Dark : AppTheme.Light);
        }
    }
}
mergedDicts.Insert(0, newThemeDict)обов'язково на позицію 0. Якщо вставити в кінець — словник теми буде після стилів, і стилі вже завантажені зі StaticResource. Тема обов'язково має бути першою у MergedDictionaries, до стилів. Для DynamicResource порядок важливий при ініціалізації.

Демонстрація: DynamicResource у дії

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...


SystemColors та SystemFonts: тема операційної системи

WPF надає доступ до системних кольорів і шрифтів через статичні класи SystemColors та SystemFonts. Ці ресурси автоматично оновлюються при зміні теми ОС (Класична, Aero, High Contrast).

<!-- SystemColors через StaticResource (один раз) -->
<Border Background="{x:Static SystemColors.WindowBrush}">
    <TextBlock Text="Системний колір вікна"
               Foreground="{x:Static SystemColors.WindowTextBrush}"/>
</Border>

<!-- SystemColors через DynamicResource (реагує на зміну теми ОС) -->
<Border Background="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}">
    <TextBlock Text="Колір виділення ОС"
               Foreground="{DynamicResource {x:Static SystemColors.HighlightTextBrushKey}}"/>
</Border>
SystemColors ресурсЗначення
SystemColors.WindowBrushФон вікна
SystemColors.WindowTextBrushТекст у вікні
SystemColors.HighlightBrushФон виділення (selection)
SystemColors.HighlightTextBrushТекст при виділенні
SystemColors.ControlBrushФон контролів
SystemColors.GrayTextBrushПриглушений текст
Для додатків, що мають «вбудовуватись» у системний стиль Windows (корпоративні Enterprise-програми), SystemColors є кращим вибором, ніж кастомні ресурси. Вони автоматично дотримуються налаштувань High Contrast і адаптуються до теми ОС без жодного коду.

Бібліотеки тем (UI Toolkits) для WPF

Створення повноцінної системи стилів з нуля — величезна робота. Потрібно стилізувати десятки контролів (Button, TextBox, ComboBox, DataGrid, Slider, меню, скроллбари), врахувати всі стани (hover, pressed, disabled) та реалізувати Light/Dark теми.

У більшості професійних WPF-проєктів використовують готові UI-бібліотеки, що надають цілісну дизайн-систему. Зробимо короткий огляд трьох найпопулярніших.

1. MaterialDesignInXamlToolkit

Найпопулярніша бібліотека для WPF. Реалізує Google Material Design.

  • Стиль: Сучасний, чистий Material Design (плоский дизайн, тіні, анімації натискання — "ripple effect").
  • Можливості: Вбудована Light/Dark тематизація, сотні векторних іконок (PackIcon), розширені контроли (ColorZone, DialogHost, Snackbar, DrawerHost).
  • Використання:
    1. Install-Package MaterialDesignThemes
    2. В App.xaml додати словники Material Design (описано в документації бібліотеки).
    3. Всі стандартні WPF-контроли автоматично отримають Material-вигляд.

2. MahApps.Metro

Одна з найстаріших і найстабільніших бібліотек.

  • Стиль: Windows 8 / Windows 10 "Metro" стиль (суворий, кутастий, мінімалістичний).
  • Можливості: Круті стилі для вікон (MetroWindow — заміна стандартного Window з кастомним TitleBar), гнучка система колірних акцентів (Blue, Red, Green тощо), додаткові контроли (ToggleSwitch, NumericUpDown).
  • Сценарій: Ідеально для десктопних утиліт, корпоративного софту, що має виглядати як нативний Windows 10 додаток.

3. HandyControl

Китайська бібліотека, яка стрімко набирає популярність завдяки величезній кількості кастомних контролів.

  • Стиль: Власний сучасний стиль, щось середнє між Material та Fluent.
  • Можливості: Надає понад 70 (!!) нових контролів, яких немає у WPF (включаючи Carousel, Rate, Watermark, Timeline, StepBar, Divider).
  • Сценарій: Коли вбудованих контролів WPF катастрофічно не вистачає і потрібні складні UI-патерни "з коробки".
Моя рекомендація: Для нових WPF-проєктів використовуйте MaterialDesignInXamlToolkit. Вона має найбільшу спільноту, відмінну документацію, активно підтримується і робить WPF-додаток миттєво сучасним на вигляд.

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


Підсумок

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

Тематизація WPF-додатку базується на механізмі {DynamicResource}. Він дозволяє підписатись на оновлення ресурсу в runtime. На відміну від нього, {StaticResource} «фіксує» значення під час ініціалізації вікна (швидше, але не підходить для тем).

Організація ресурсів:

  • Themes/Light.xaml — лише кольори та пензлі (Brushes). Використовуємо семантичні ключі (AppSurfaceBrush, а не WhiteBrush).
  • Styles/*.xaml — шаблони контролів, що беруть кольори через DynamicResource.

Перемикання тем у коді зводиться до маніпуляцій з колекцією Application.Current.Resources.MergedDictionaries — старий словник видаляється, новий вставляється на нульову позицію. Всі DynamicResource посилання автоматично реалізують оновлення UI.

Для системного вигляду ОС (який реагує на налаштування Windows) використовуємо статичні класи SystemColors та SystemFonts.

Для професійних проєктів стилізація «з нуля» економічно невиправдана. Індустріальним стандартом є використання готових UI-бібліотек. MaterialDesignInXaml — найпопулярніша бібліотека (сучасний вигляди, анімації, сотні іконок). Альтернативи — MahApps.Metro (корпоративний Windows стиль) та HandyControl (сотні кастомних контролів).

Що далі

Наступна тема — Адаптивний UI та Ресурси в Avalonia. Avalonia пропонує значно елегантніший спосіб тематизації. Замість ручної маніпуляції з ResourceDictionary, вона використовує механізм ThemeVariant (Light/Dark перемикання «з коробки») та нову систему ресурсів, що дозволяє перевизначати окремі кольорові токени на льоту. Ми розглянемо, як портувати набуті знання тематизації з WPF в сучасний Avalonia-підхід.