Приветствую, %username%!

Меня зовут Роман Гладких, я студент третьего курса Сибирского Государственного Университета Телекоммуникаций и Информатики по профилю Супервычисления. Так же являюсь студентом-партнером Майкрософт. Мое давнее хобби – это разработка приложений для 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.

image

В каждом файле добавим в ResourceDictionary три кисти с именами BackgroundBrush, ForegroundBrush и ChromeBrush. Содержимое этих файлов доступно под спойлерами.

Theme.Dark.xaml
<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>


Theme.Light.xaml
<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.

Реализация 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 представлен под спойлером.

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 (в новом проекте уже имеется). Итоговая страница будет так:

image

Задайте для кнопок и других элементов управления необходимые привязки к нашим кистям. В обработчиках события клика для кнопок выполним загрузку других тем.

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.

Для тестирования, создайте третий файл темы, и разместите его, например, на рабочем столе. Мой вариант:

Theme.Red.xaml
<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. Цветово�� оформление приложения станет красным.

Скриншоты приложения
image

image

image

Полный исходный код разметки страницы под спойлером.

MainPage.xaml - версия 1
<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 реализуется аналогичным образом.

BackgroundBindingHelper
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
                });
            }
        }
    }
}


ForegroundBindingHelper
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 элементам. Итоговый исходный код разметки под спойлером.

MainPage.xaml - версия 2
<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: ссылка.

Надеюсь, статья вам понравилась. Если нашли какую-либо неточность или ошибку, не стесняйтесь написать мне в личные сообщения.

До встречи на просторах Хабрахабра!