Меня зовут Роман Гладких, я студент третьего курса Сибирского Государственного Университета Телекоммуникаций и Информатики по профилю Супервычисления. Так же являюсь студентом-партнером Майкрософт. Мое давнее хобби – это разработка приложений для Windows Phone и UWP на языке C#.
По умолчанию приложения UWP поддерживают две темы: темную (Dark) и светлую (Light). Так же имеется еще высококонтрастная тема (HighContrast). Такого набора обычно хватает для любого приложения, однако, что делать, если требуется быстро менять тему приложения на лету, причем ограничиваться Light и Dark нет желания?
В данном материале я расскажу, как реализовать свой менеджер тем. Материал ориентирован на новичков, однако и профессионалам может быть интересен. Милости просим под кат!
ThemeResource
Платформа UWP поддерживает специальное расширение разметки XAML, предназначение которого заключается в ссылке на ресурсы тем, которые могут обновляться во время выполнения. Ресурсы темы представляют собой набор ресурсов, применяющих различные значения в зависимости от того, какая тема системы активна.
{ThemeResource ResourceName}
Отличие от расширения разметки {StaticResource} в том, что {ThemeResource} может динамически использовать разные словари в качестве основного места поиска в зависимости от того, какая тема используется системой в данный момент. Другими словами, анализ значений, на которые ссылается {StaticResource} происходит только один раз при запуске приложения, тогда как {ThemeResource} при запуске и при каждом изменении темы системы.
Рассмотрим пример ResourceDictionary, в котором определяются пользовательские ресурсы темы.
<ResourceDictionary> <ResourceDictionary.ThemeDictionaries> <ResourceDictionary x:Key="Light"> <SolidColorBrush x:Key="MyBackgroundBrush" Color="#FFFFFFFF" /> </ResourceDictionary> <ResourceDictionary x:Key="Dark"> <SolidColorBrush x:Key="MyBackgroundBrush " Color="#FF232323" /> </ResourceDictionary> <ResourceDictionary x:Key="HighContrast"> <SolidColorBrush x:Key="MyBackgroundBrush " Color="#FF000000" /> </ResourceDictionary> </ResourceDictionary.ThemeDictionaries> </ResourceDictionary>
В родительской ResourceDictionary в секции ThemeDictionaries объявлены дочерние библиотеки, которые и являются наборами ресурсов для каждой из тем. В каждой библиотеке объявлена кисть с одним названием, но разным значением Color.
Итого, если мы будем ссылаться на нашу кисть при помощи {ThemeResource}, например, зададим прямоугольнику эту кисть как заливку, то в зависимости от выбранной в системе темы, мы получим прямоугольник белого, серого или черного цвета.
Обратите внимание, что в ресурсах темы могут лежать не только кисти, но также строки и другие объекты. Чтобы разработчик мог ознакомиться со всеми системными ресурсами темы, в Windows SDK входит XAML-файл, содержащий все ресурсы. Расположен он в C:\Program Files (x86)\Windows Kits\10\DesignTime\CommonConfiguration\Neutral\UAP\\Generic\ themeresources.xaml.
Как разработать свой менеджер тем?
Взвесив все за и против, мы пришли к выводу, что хотим больше, хотим менять их на лету и не зависеть от системной темы. Как это реализовать?
Так как в платформе UWP отсутствует расширение разметки {DynamicResource}, который, к слову, имеется в WPF, довольствоваться будем обычными привязками {Binding}.
Для начала создадим проект пустого приложения UWP с имененем UwpThemeManager. Минимальной версией я установил Windows 10 Anniversary Update, целевой Windows 10 Creators Update.
В проекте создадим папку Themes, внутри два ResourceDictionary с именами Theme.Dark.xaml и Theme.Light.xaml.

В каждом файле добавим в ResourceDictionary три кисти с именами BackgroundBrush, ForegroundBrush и ChromeBrush. Содержимое этих файлов доступно под спойлерами.
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <SolidColorBrush x:Key="BackgroundBrush" Color="#FF1A1A1A" /> <SolidColorBrush x:Key="ForegroundBrush" Color="White" /> <SolidColorBrush x:Key="ChromeBrush" Color="#FF232323" /> </ResourceDictionary>
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <SolidColorBrush x:Key="BackgroundBrush" Color="White" /> <SolidColorBrush x:Key="ForegroundBrush" Color="Black" /> <SolidColorBrush x:Key="ChromeBrush" Color="#FFBFBFBF" /> </ResourceDictionary>
Теперь нам потребуется специальный класс, который будет загружать ресурсы наших тем и уведомлять все Binding об изменении ссылок на кисти. Создадим запечатанный (sealed) класс ThemeManager, реализующий интерфейс INotifyPropertyChanged.
public sealed class ThemeManager : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; private void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
Обязательно добавим в класс публичные строковые константы с путем до наших словарей с темами.
public const string DarkThemePath = "ms-appx:///Themes/Theme.Dark.xaml"; public const string LightThemePath = "ms-appx:///Themes/Theme.Light.xaml";
В код нашего класса добавим приватное поле типа ResourceDictionary – это будет словарь с текущими значениями темы.
private ResourceDictionary _currentThemeDictionary;
Далее требуется добавить в класс ThemeManager свойства типа Brush, чтобы не допускать ошибок при биндинге из XAML, и работали подсказки от Visual Studio. Во избежание путаницы, назовем свойства точно так же, как кисти названы в словарях тем. Так же для нашего удобства добавим строковое свойство CurrentTheme, которое будет возвращать имя текущей темы.
public string CurrentTheme { get; private set; } public Brush BackgroundBrush => _currentThemeDictionary[nameof(BackgroundBrush)] as Brush; public Brush ChromeBrush => _currentThemeDictionary[nameof(ChromeBrush)] as Brush; public Brush ForegroundBrush => _currentThemeDictionary[nameof(ForegroundBrush)] as Brush;
Чтобы при смене темы все привязки {Binding} узнали о том, что ссылки на кисти поменялись, нужно вызвать событие PropertyChanged для каждого из свойств. Создадим для этого специальный приватный метод.
Чтобы не допустить ошибки в названиях свойств, используем ключевое слово nameof, вызов которого при компиляции преобразуется в строковую константу с именем указанного элемента.
private void RaisePropertyChanged() { OnPropertyChanged(nameof(BackgroundBrush)); OnPropertyChanged(nameof(ChromeBrush)); OnPropertyChanged(nameof(ForegroundBrush)); OnPropertyChanged(nameof(CurrentTheme)); }
Теперь встает вопрос о загрузке словарей с темами. Создадим два метода: LoadTheme и LoadThemeFromFile. Первый метод загружает словарь с темой, расположенный в пакете приложения (для этого выше мы задали константы DarkThemePath и LightThemePath). Второй метод загружает тему из любого файла (принимает на вход StorageFile), не обязательно из пакета приложения.
Реализация методов занимает несколько строк.
public void LoadTheme(string path) { _currentThemeDictionary = new ResourceDictionary(); App.LoadComponent(_currentThemeDictionary, new Uri(path)); CurrentTheme = Path.GetFileNameWithoutExtension(path); RaisePropertyChanged(); } public async Task LoadThemeFromFile(StorageFile file) { string xaml = await FileIO.ReadTextAsync(file); _currentThemeDictionary = XamlReader.Load(xaml) as ResourceDictionary; CurrentTheme = Path.GetFileNameWithoutExtension(file.Path); RaisePropertyChanged(); }
ThemeManager почти готов, осталось лишь добавить в конструктор вызов метода загрузки темной темы (она будет по умолчанию).
public ThemeManager() { LoadTheme(DarkThemePath); }
Все готово! Осталось объявить экземпляр нашего класса в App.xaml в секции ресурсов приложения и добавить статическую ссылку На этот экземпляр в App.xaml.cs.
<Application x:Class="UwpThemeManager.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:UwpThemeManager"> <Application.Resources> <ResourceDictionary> <local:ThemeManager x:Key="ThemeManager" /> </ResourceDictionary> </Application.Resources> </Application>
public static ThemeManager ThemeManager => (ThemeManager)App.Current.Resources["ThemeManager"];
Полный код ThemeManager.cs представлен под спойлером.
using System; using System.ComponentModel; using System.IO; using System.Threading.Tasks; using Windows.Storage; using Windows.UI.Xaml; using Windows.UI.Xaml.Markup; using Windows.UI.Xaml.Media; namespace UwpThemeManager { public sealed class ThemeManager : INotifyPropertyChanged { public const string DarkThemePath = "ms-appx:///Themes/Theme.Dark.xaml"; public const string LightThemePath = "ms-appx:///Themes/Theme.Light.xaml"; public event PropertyChangedEventHandler PropertyChanged; public ThemeManager() { LoadTheme(DarkThemePath); } public string CurrentTheme { get; private set; } public Brush BackgroundBrush => _currentThemeDictionary[nameof(BackgroundBrush)] as Brush; public Brush ChromeBrush => _currentThemeDictionary[nameof(ChromeBrush)] as Brush; public Brush ForegroundBrush => _currentThemeDictionary[nameof(ForegroundBrush)] as Brush; public void LoadTheme(string path) { _currentThemeDictionary = new ResourceDictionary(); App.LoadComponent(_currentThemeDictionary, new Uri(path)); CurrentTheme = Path.GetFileNameWithoutExtension(path); RaisePropertyChanged(); } public async Task LoadThemeFromFile(StorageFile file) { string xaml = await FileIO.ReadTextAsync(file); _currentThemeDictionary = XamlReader.Load(xaml) as ResourceDictionary; CurrentTheme = Path.GetFileNameWithoutExtension(file.Path); RaisePropertyChanged(); } private void RaisePropertyChanged() { OnPropertyChanged(nameof(BackgroundBrush)); OnPropertyChanged(nameof(ChromeBrush)); OnPropertyChanged(nameof(ForegroundBrush)); OnPropertyChanged(nameof(CurrentTheme)); } private void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); private ResourceDictionary _currentThemeDictionary; } }
Использование менеджера тем
Так как мы выполнили все необходимые приготовления, использование ThemeManager будет очень простым. Рассмотрим небольшой пример.
<Rectangle Fill="{Binding BackgroundBrush, Source={StaticResource ThemeManager}}"/>
В данном примере мы объявили элемент Rectangle (прямоугольник), у которого свойство Fill (заливка) привязали к свойству BackgroundBrush из ThemeManager, расположенного в ресурсах приложения.
Создадим простую страницу MainPage (в новом проекте уже имеется). Итоговая страница будет так:

Задайте для кнопок и других элементов управления необходимые привязки к нашим кистям. В обработчиках события клика для кнопок выполним загрузку других тем.
private void DarkThemeButton_Click(object sender, RoutedEventArgs e) => App.ThemeManager.LoadTheme(ThemeManager.DarkThemePath); private void LightThemeButton_Click(object sender, RoutedEventArgs e) => App.ThemeManager.LoadTheme(ThemeManager.LightThemePath); private async void CustomThemeButton_Click(object sender, RoutedEventArgs e) { var picker = new FileOpenPicker(); picker.FileTypeFilter.Add(".xaml"); var file = await picker.PickSingleFileAsync(); if (file != null) { try { await App.ThemeManager.LoadThemeFromFile(file); } catch (Exception ex) { var msg = new MessageDialog(ex.ToString(), "Ошибка"); await msg.ShowAsync(); } } }
Для первых двух кнопок мы вызывает метод LoadTheme в ThemeManager с указанием константы с путем до файла XAML с темой. Последний обработчик события (у кнопки с текстом Custom theme) создает окно выбора файла, указывает фильтр по типу .xaml и показывает пользователю стандартное окно выбора файла. Если пользователь выбрал файл, то он передается в метод LoadThemeFromFile, который мы реализовали в ThemeManager.
Для тестирования, создайте третий файл темы, и разместите его, например, на рабочем столе. Мой вариант:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <SolidColorBrush x:Key="BackgroundBrush" Color="#FF1A1A1A" /> <SolidColorBrush x:Key="ForegroundBrush" Color="White" /> <SolidColorBrush x:Key="ChromeBrush" Color="#FF5A0000" /> </ResourceDictionary>
Скомпилируйте и запустите приложение. При нажатии на кнопки Dark theme и Light theme, цветовое оформление приложения будет автоматически меняться. Нажмите на кнопку Custom theme, затем откройте файл Theme.Red.xaml. Цветовое оформление приложения станет красным.



Полный исходный код разметки страницы под спойлером.
<Page x:Class="UwpThemeManager.MainPage1" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> <Grid Background="{Binding BackgroundBrush, Source={StaticResource ThemeManager}}"> <Grid.RowDefinitions> <RowDefinition Height="48" /> <RowDefinition /> </Grid.RowDefinitions> <Border Background="{Binding ChromeBrush, Source={StaticResource ThemeManager}}"> <TextBlock Text="{Binding CurrentTheme, Source={StaticResource ThemeManager}}" Foreground="{Binding ForegroundBrush, Source={StaticResource ThemeManager}}" Style="{StaticResource SubtitleTextBlockStyle}" VerticalAlignment="Center" Margin="12,0,0,0" /> </Border> <StackPanel Grid.Row="1" HorizontalAlignment="Center"> <Button Content="Dark theme" Background="{Binding ChromeBrush, Source={StaticResource ThemeManager}}" Foreground="{Binding ForegroundBrush, Source={StaticResource ThemeManager}}" Margin="0,12,0,0" HorizontalAlignment="Stretch" Click="DarkThemeButton_Click" /> <Button Content="Light Theme" Background="{Binding ChromeBrush, Source={StaticResource ThemeManager}}" Foreground="{Binding ForegroundBrush, Source={StaticResource ThemeManager}}" Margin="0,12,0,0" HorizontalAlignment="Stretch" Click="LightThemeButton_Click" /> <Button Content="Custom theme" Background="{Binding ChromeBrush, Source={StaticResource ThemeManager}}" Foreground="{Binding ForegroundBrush, Source={StaticResource ThemeManager}}" Margin="0,12,0,0" HorizontalAlignment="Stretch" Click="CustomThemeButton_Click" /> </StackPanel> </Grid> </Page>
Подводные камни
Если задавать значения Background, Foreground и т.д. у самих элементов, то все будет работать, однако мы не можем задать {Binding} в стилях элементов управления. В UWP привязки в Style не поддерживаются. Как же это обойти? Нам поможет Attached DependencyProperty!
Attached Property. Это Dependency Property, которое объявлено не в классе объекта, для которого оно будет использоваться, но ведет себя, как будто является его частью. Объявляется в отдельном классе, имеет getter и setter в виде статических методов. Можно добавить обработчик на событие PropertyChanged.
Подробнее про Attached property вы можете узнать немного подробнее в статье AndyD: WPF: использование Attached Property и Behavior
Реализуем Attached property для свойств Background и Foreground. Это будут статические классы с именем BackgroundBindingHelper и ForegroundBindingHelper. Объявим статические методы GetBackground (возвращает string) и SetBackground, а также DependencyProperty с типом значения string.
В Visual Studio имеется специальная заготовка (code snippet) для Attached Dependency Property, которая доступна, если ввести propa и нажать Tab.
Так же добавим приватный метод-обработчик BackgroundPathPropertyChanged, который будет обновлять Binding при изменении значения Background.
ForegroundBindingHelper реализуется аналогичным образом.
using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Data; namespace UwpThemeManager.BindingHelpers { public static class BackgroundBindingHelper { public static string GetBackground(DependencyObject obj) => (string)obj.GetValue(BackgroundProperty); public static void SetBackground(DependencyObject obj, string value) => obj.SetValue(BackgroundProperty, value); public static readonly DependencyProperty BackgroundProperty = DependencyProperty.RegisterAttached("Background", typeof(string), typeof(BackgroundBindingHelper), new PropertyMetadata(null, BackgroundPathPropertyChanged)); private static void BackgroundPathPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e) { var propertyPath = e.NewValue as string; if (propertyPath != null) { var backgroundproperty = Control.BackgroundProperty; BindingOperations.SetBinding(obj, backgroundproperty, new Binding { Path = new PropertyPath(propertyPath), Source = App.ThemeManager }); } } } }
using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Data; namespace UwpThemeManager.BindingHelpers { public static class ForegroundBindingHelper { public static string GetForeground(DependencyObject obj) => (string)obj.GetValue(ForegroundProperty); public static void SetForeground(DependencyObject obj, string value) => obj.SetValue(ForegroundProperty, value); public static readonly DependencyProperty ForegroundProperty = DependencyProperty.RegisterAttached("Foreground", typeof(string), typeof(ForegroundBindingHelper), new PropertyMetadata(null, ForegroundPathPropertyChanged)); private static void ForegroundPathPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e) { var propertyPath = e.NewValue as string; if (propertyPath != null) { var backgroundproperty = Control.ForegroundProperty; BindingOperations.SetBinding(obj, backgroundproperty, new Binding { Path = new PropertyPath(propertyPath), Source = App.ThemeManager }); } } } }
Отлично! Теперь мы можем биндиться к нашим кистям даже в стилях. Для примера создадим стиль для кнопок на нашей странице.
<Page.Resources> <Style x:Key="ButtonStyle" TargetType="Button"> <Setter Property="binding:BackgroundBindingHelper.Background" Value="ChromeBrush" /> <Setter Property="binding:ForegroundBindingHelper.Foreground" Value="ForegroundBrush" /> <Setter Property="Margin" Value="0,12,0,0" /> <Setter Property="HorizontalAlignment" Value="Stretch"/> </Style> </Page.Resources>
В Setter.Property указано имя класса, которое предоставляет AttachedProperty. В Value указано имя свойства с кистью из ThemeManager.
Задайте этот стиль кнопкам на странице, и все будет работать так же хорошо, как и при прямом указании Background и Foreground элементам. Итоговый исходный код разметки под спойлером.
<Page x:Class="UwpThemeManager.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:binding="using:UwpThemeManager.BindingHelpers" mc:Ignorable="d"> <Page.Resources> <Style x:Key="ButtonStyle" TargetType="Button"> <Setter Property="binding:BackgroundBindingHelper.Background" Value="ChromeBrush" /> <Setter Property="binding:ForegroundBindingHelper.Foreground" Value="ForegroundBrush" /> <Setter Property="Margin" Value="0,12,0,0" /> <Setter Property="HorizontalAlignment" Value="Stretch"/> </Style> </Page.Resources> <Grid Background="{Binding BackgroundBrush, Source={StaticResource ThemeManager}}"> <Grid.RowDefinitions> <RowDefinition Height="48" /> <RowDefinition /> </Grid.RowDefinitions> <Border Background="{Binding ChromeBrush, Source={StaticResource ThemeManager}}"> <TextBlock Text="{Binding CurrentTheme, Source={StaticResource ThemeManager}}" Foreground="{Binding ForegroundBrush, Source={StaticResource ThemeManager}}" Style="{StaticResource SubtitleTextBlockStyle}" VerticalAlignment="Center" Margin="12,0,0,0" /> </Border> <StackPanel Grid.Row="1" HorizontalAlignment="Center"> <Button Content="Dark theme" Style="{StaticResource ButtonStyle}" Click="DarkThemeButton_Click" /> <Button Content="Light Theme" Style="{StaticResource ButtonStyle}" Click="LightThemeButton_Click" /> <Button Content="Custom theme" Style="{StaticResource ButtonStyle}" Click="CustomThemeButton_Click" /> </StackPanel> </Grid> </Page>
Подведем итоги
Путем нехитрых манипуляций мы реализовали свой собственный менеджер тем, который не зависит от системной, и может работать с любым количеством тем, в том числе подгружать сторонние.
Полный исходный код проекта доступен на GitHub: ссылка.
Надеюсь, статья вам понравилась. Если нашли какую-либо неточность или ошибку, не стесняйтесь написать мне в личные сообщения.
До встречи на просторах Хабрахабра!

