Pull to refresh

Разработка менеджера тем в UWP-приложении

Reading time 13 min
Views 5.3K
Приветствую, %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: ссылка.

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

До встречи на просторах Хабрахабра!
Tags:
Hubs:
+5
Comments 10
Comments Comments 10

Articles