Теми та ресурсні словники у WPF
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)...
<StackPanel Margin="20" Spacing="16">
<StackPanel.Resources>
<!-- Симульована "тема" прямо в Resources для демонстрації -->
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F9FAFB"/>
<SolidColorBrush x:Key="AppSurfaceBrush" Color="White"/>
<SolidColorBrush x:Key="AppBorderBrush" Color="#E5E7EB"/>
<SolidColorBrush x:Key="AppTextPrimaryBrush" Color="#111827"/>
<SolidColorBrush x:Key="AppAccentBrush" Color="#4F46E5"/>
<SolidColorBrush x:Key="AppAccentHoverBrush" Color="#3730A3"/>
<!-- Стиль картки використовує DynamicResource -->
<Style x:Key="ThemeCard" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="20"/>
</Style>
<!-- Стиль кнопки через DynamicResource -->
<Style TargetType="Button">
<Setter Property="Background" Value="{DynamicResource AppAccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Padding" Value="14,8"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource AppAccentHoverBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
</StackPanel.Resources>
<!-- Фон через DynamicResource -->
<Border Background="{DynamicResource AppBackgroundBrush}"
CornerRadius="16" Padding="16">
<StackPanel Spacing="12">
<!-- Картка 1 -->
<Border Style="{StaticResource ThemeCard}">
<StackPanel Spacing="6">
<TextBlock Text="Статистика місяця"
FontWeight="SemiBold"
Foreground="{DynamicResource AppTextPrimaryBrush}"
FontSize="14"/>
<TextBlock Text="Всі значення використовують DynamicResource"
Foreground="{DynamicResource AppTextSecondaryBrush}"
FontSize="12"/>
</StackPanel>
</Border>
<!-- Метрики -->
<UniformGrid Columns="3" Rows="1">
<Border Style="{StaticResource ThemeCard}" Margin="0,0,6,0">
<StackPanel>
<TextBlock Text="142" FontSize="24" FontWeight="Bold"
Foreground="{DynamicResource AppAccentBrush}"/>
<TextBlock Text="Задачі" FontSize="11"
Foreground="{DynamicResource AppTextSecondaryBrush}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource ThemeCard}" Margin="3,0">
<StackPanel>
<TextBlock Text="89%" FontSize="24" FontWeight="Bold"
Foreground="#10B981"/>
<TextBlock Text="Виконано" FontSize="11"
Foreground="{DynamicResource AppTextSecondaryBrush}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource ThemeCard}" Margin="6,0,0,0">
<StackPanel>
<TextBlock Text="15" FontSize="24" FontWeight="Bold"
Foreground="#F59E0B"/>
<TextBlock Text="В черзі" FontSize="11"
Foreground="{DynamicResource AppTextSecondaryBrush}"/>
</StackPanel>
</Border>
</UniformGrid>
<Button Content="Переключити тему"
Command="{Binding ToggleThemeCommand}"/>
</StackPanel>
</Border>
</StackPanel>
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 | Приглушений текст |
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). - Використання:
Install-Package MaterialDesignThemes- В
App.xamlдодати словники Material Design (описано в документації бібліотеки). - Всі стандартні 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-патерни "з коробки".
Практичні завдання
Ціль: Організувати кольори у словники та підключити їх в App.xaml.
Завдання:
- Створіть папку
Themesу проєкті. - Створіть файли
Light.xamlтаDark.xaml(тип ResourceDictionary). - Додайте у них ключі
AppBackground,AppForeground,PrimaryColorз різними значеннями. - В
App.xamlпідключіть лишеLight.xamlчерезMergedDictionaries. - У
MainWindow.xamlвстановітьBackground="{DynamicResource AppBackground}".
Перевірка: Запустіть додаток. У App.xaml змініть Source словника на Dark.xaml, перезапустіть. Вікно має змінити колір.
Ціль: Навчитись динамічно замінювати словники у Application.Current.Resources.
Завдання:
- Використовуючи результати Рівня 1, додайте у
MainWindowкнопку "Перемкнути тему". - Створіть метод
ToggleTheme()(або прив'яжіть до команди у MVVM). - У методі: перевірте поточний словник. Якщо це Light — видаліть його, створіть
new ResourceDictionary { Source = ... }для Dark, додайте в колекцію на індекс 0. - Додайте пару стилів (наприклад, для кнопок) у
App.xaml, що використовують ці динамічні ресурси.
Перевірка: При натисканні на кнопку додаток має миттєво змінювати кольори без перезапуску. Жоден стиль не ламається.
Ціль: Встановити та налаштувати третєсторонню бібліотеку тем.
Завдання:
- Створіть новий WPF-проєкт.
- Встановіть через NuGet
MaterialDesignThemes. - Зайдіть на GitHub проєкту MaterialDesignInXamlToolkit. Знайдіть інструкцію "Getting Started".
- Скопіюйте необхідні XAML-рядки в
<Application.Resources.MergedDictionaries>. - Додайте у вікно
Button,TextBox,CheckBox.
Перевірка: Запустіть додаток. Всі стандартні контроли мають отримати Material-вигляд (flat кнопки з анімацією натискання (ripple), поля вводу з підкресленням) без жодного рядка коду або додаткових атрибутів (це сила implicit стилів).
Підсумок
Що ми вивчили у цій статті
Тематизація 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-підхід.
Pseudo-classes в Avalonia — замість WPF Triggers
Avalonia замінила WPF Triggers на CSS pseudo-classes. Вбудовані :pointerover, :focus, :pressed, :checked, кастомні PseudoClasses.Set(), data-driven стилізація через compiled bindings — повний посібник.
Avalonia Themes — Fluent Design та система тематизації
Вбудована система тем Avalonia — Fluent Theme, Simple Theme, ThemeVariant для Dark/Light режимів, runtime switching та порівняння з WPF