65.9K
CodeProject 正在变化。 阅读更多。
Home

适用于跨平台 Avalonia UI 框架的主题和本地化功能

starIconstarIconstarIconstarIconstarIcon

5.00/5 (11投票s)

2021 年 11 月 18 日

MIT

17分钟阅读

viewsIcon

21506

本文描述了一个简单灵活的、用于本地化跨平台 Avalonia 应用程序的新程序包,并提供了示例。

引言

请注意,本文和示例代码均已更新,以兼容最新版本的Avalonia - 11.0.6

本地化、国际化和主题化

多年前,我偶然发现了一个用于本地化/国际化 WPF 应用程序的出色程序包。我成功地使用它使我构建的一个 WPF 应用程序能够切换英语和德语版本。

这个 WPF 的程序包由 Tomer Shamam 开发,后来从他的旧博客中删除。他将代码发送给了我,并经他同意,我在 Github WPFLocalizationPackage 仓库中发布了它。

最近,我再次需要对一个应用程序进行国际化。起初,我想将那个 WPF 程序包移植到 Avalonia(WPF 的一个出色的跨平台开源后继者)。最终,我决定从头开始构建一个新的功能,借鉴了 Tomer 的 WPF 程序包的一些想法。

做出这个决定的主要原因是 Avalonia 的 DynamicResource 标记扩展和绑定比 WPF 的相应功能工作得更好,并且没有 WPF 伴随的那些怪癖。因此,我没有创建自定义的标记扩展,而是直接利用了 Avalonia 的 DynamicResource

我仍然使用了 Tomer 的想法,即创建一个包含不同本地化字典的 C# 对象,这些字典可以轻松地在不同的区域设置之间切换。同样重要的是,我创建了一个演示应用程序,它的外观与他的演示非常相似,以确保我的实现涵盖了他所有的功能,甚至更多。

这两个程序包——Tomer 的原始程序包和我的 Avalonia 程序包——都可以用来更改任何控件上的任何依赖项(或 Avalonia)属性,而不仅仅是与本地化相关的属性。因此,相同的功能可用于主题化或皮肤化——完全改变应用程序的外观。

我的本地化/国际化程序包的优点是:

  • 易于使用——正如示例将演示的那样。
  • 动态性——其他一些解决方案,包括大公司的解决方案,需要为每个区域设置进行不同的编译,而我的程序包(以及 Tomer 的 WPF 程序包)允许在应用程序运行时切换区域设置。
  • 允许创建多个主题/本地化字典集,每个字典集只控制所需自定义的一个“坐标”。几个示例将展示如何动态切换多种语言和多种颜色主题——因此,任何语言和颜色主题的组合都是可能的。

运行高级演示

为了演示新的主题和 L10N(本地化)程序包的功能,我将从一个高级示例开始。此时请不要关注代码(稍后会进行解释)。请从 NP.Demos.ThemingAndLocalizationDemo 下载并运行演示。

您将看到以下内容:

右上角的两个 ComboBox 允许选择语言(“英语”、“希伯来语”或“俄语”)和颜色主题(“深色”或“浅色”)。上面的图片显示了应用程序处于英语语言和深色主题下。

这是应用程序在希伯来语语言和浅色主题下的视图(请注意文本和控件流的变化——从右到左而不是从左到右)

这是俄语/深色组合

什么是 Avalonia

Avalonia 是一个出色的跨平台开源 UI 框架,用于开发

  • 可在Windows、Mac和Linux上运行的桌面解决方案
  • 在浏览器中运行的Web应用程序(通过WebAssembly)
  • 适用于Android、iOS和Tizen的移动应用程序。

Avalonia 的功能与 WPF 类似,但 Avalonia 已经比 WPF 更强大,而且 Bug 更少,并且是跨平台的。

Avalonia 比 UNO Platform 强大得多,也更干净、更快——它是跨平台 XAML/C# 世界中唯一的竞争对手。

可以在我的 codeproject.com 文章中找到 Avalonia 教程和有关此精彩程序包的更多信息

  1. 使用 AvaloniaUI 在简易示例中进行多平台 UI 编码。第一部分 - AvaloniaUI 构建块
  2. 多平台 Avalonia .NET 框架 XAML 基础知识轻松示例
  3. 使用简单的示例学习 Avalonia .NET 框架跨平台编程基础概念
  4. Avalonia .NET Framework 编程高级概念的简易示例

另外,不要错过我最近发布的强大 Avalonia 跨平台 UI 停靠程序包 UniDock,它在 UniDock - A New Multiplatform UI Docking Framework. UniDock Power Features. 中有介绍。

Avalonia 11 相关更改

Avalonia 11 中的主题化发生了重大变化。我提供了几个示例来解释下面内置的 Avania 主题化功能。

在多个平台上运行示例

本文中的所有示例均已在 Windows 10、Mac Catalina 和 Ubuntu 20.04 上进行了测试。

主题/本地化代码位置

新的主题/L10N 功能是 NP.Ava.Visuals 开源程序包的一部分。该程序包还可以有其他用途,我计划撰写另一篇文章来解释其最重要的功能。

还有一个名为 NP.ViewModelInterfaces 的项目,其中包含由某些 Visual 对象实现的非视觉接口。此代码的目的是在非视觉(例如,ViewModel)项目中使用,而无需任何对 Avalonia 代码的引用,以控制和查询某些视觉对象。到目前为止,NP.ViewModelInterfaces 只包含与主题化和本地化功能相关的接口:IThemeLoader.csThemeInfo.cs

NuGet 包位置

NuGet 包可在 nuget.org 上的 NP.Ava.Visuals 中找到。它依赖于其他几个会自动安装的包。

您不需要单独引用 Avalonia 包,因为它们将通过安装 NP.Ava.Visuals 来安装。

如果您想从非视觉(ViewModel)项目控制主题/L10N 功能,您可以只安装 NP.ViewModelInterfaces,它也可以在 nuget.org 上找到。

主题/本地化代码示例

示例代码位置

所有主题/L10N 演示代码都可以在 NP.Demos.ThemingAndL10N 中找到。
代码是使用 Visual Studio 2022 构建和测试的。

内置主题示例

此示例项目位于 NP.Demos.BuiltInThemingSample 文件夹内。

在 VS2022 中打开解决方案,编译并运行项目。您将看到以下内容:

请注意,前景是蓝色。按粉色主题按钮——您将看到以下内容:

我们的粉色主题继承自浅色主题,因此背景被覆盖为粉色,但前景保持蓝色。

最后点击深色主题按钮

背景和前景都会改变。

现在,让我们看看代码。大部分代码位于 MainWindow.axaml XAML 文件中。

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:NP.Demos.BuiltInThemingSample"
        x:Class="NP.Demos.BuiltInThemingSample.MainWindow"
        Title="NP.Demos.BuiltInThemingSample"
        Background="{DynamicResource ThemeBackgroundBrush}"
        Foreground="{DynamicResource ThemeForegroundBrush}"
        Width="300"
        Height="200">
    <!-- Default Avalonia Styles -->
    <Window.Styles>
        <SimpleTheme/>
    </Window.Styles>
    <Window.Resources>
        <ResourceDictionary>
            <ResourceDictionary.ThemeDictionaries>
                <ResourceDictionary x:Key="Light">
                    <SolidColorBrush x:Key="ThemeForegroundBrush"
                                     Color="Blue"/>
                </ResourceDictionary>
                <ResourceDictionary x:Key="{x:Static local:CustomThemes.Pink}">
                    <SolidColorBrush x:Key="ThemeBackgroundBrush"
                                     Color="LightPink"/>
                </ResourceDictionary>
            </ResourceDictionary.ThemeDictionaries>
        </ResourceDictionary>
    </Window.Resources>
    <Grid RowDefinitions="Auto, *"
          Margin="10">
        <StackPanel Orientation="Horizontal">
            <!-- this button switches to light theme -->
            <Button x:Name="LightButton"
                    Content="Light Theme"
                    Margin="0,0,10,0"/>

            <!-- this button switches to dark theme -->
            <Button x:Name="DarkButton"
                    Content="Dark Theme"
                    Margin="0,0,10,0"/>

            <!-- this button switches to pink theme -->
            <Button x:Name="PinkButton"
                    Content="Pink Theme"
                    Margin="0,0,0,0"/>
        </StackPanel>
        <TextBlock Text="Hello World from Avalonia !!!"
                   Grid.Row="1"
                   VerticalAlignment="Center"
                   HorizontalAlignment="Center"/>
    </Grid>
</Window>

现在,我将从文件顶部开始解释。第一个有趣的部分是默认主题设置:

<!-- Default Avalonia Styles -->
<Window.Styles>
    <SimpleTheme/>
</Window.Styles>

我们将默认样式设置为由 Avalonia 的内置 SimpleTheme 定义,它有两个 ThemeVariantLight(也称为 Default)和 Dark。这两个 ThemeVariant 都在 ThemeVariant Avalonia 类中定义为 static 属性。

public sealed record ThemeVariant
{
    ...

    /// <summary>
    /// Use the Light theme variant.
    /// </summary>
    public static ThemeVariant Light { get; } = new(nameof(Light));

    /// <summary>
    /// Use the Dark theme variant.
    /// </summary>
    public static ThemeVariant Dark { get; } = new(nameof(Dark));
    
    ...
}

在 XAML 中,可以通过使用 x:Static 引用 C# 代码中的定义来引用 ThemeVariant。同样,通过使用它们的名称(因为每个 ThemeVariant 都应该有一个唯一的名称)来引用 Avalonia 自带的 ThemeVariant

然后(回到 MainWindow.xaml 文件),我们将 Windows.Resources 部分设置为一个包含 ThemeDictionariesResourceDictionary

<Window.Resources>
    <ResourceDictionary>
        <ResourceDictionary.ThemeDictionaries>
            <ResourceDictionary x:Key="Light">
                <SolidColorBrush x:Key="ThemeForegroundBrush"
                                 Color="Blue"/>
            </ResourceDictionary>
            <ResourceDictionary x:Key="{x:Static local:CustomThemes.Pink}">
                <SolidColorBrush x:Key="ThemeBackgroundBrush"
                                 Color="LightPink"/>
            </ResourceDictionary>
        </ResourceDictionary.ThemeDictionaries>
    </ResourceDictionary>
</Window.Resources>

ThemeDictionaries 中的第一个字典通过将 ThemeForegroundBrush 设置为 Blue(而不是 Black)来修改浅色 ThemeVariant

<ResourceDictionary x:Key="Light">
    <SolidColorBrush x:Key="ThemeForegroundBrush"
                     Color="Blue"/>
</ResourceDictionary>

它知道它需要修改哪个 ThemeVariant,这是因为在 ResourceDictionary 上设置了 x:Key="Light" 参数。请记住:每个 ThemeVariant 都有一个唯一的名称,由于 Light ThemeVariant 来自 Avalonia,Avalonia 可以通过设置为 ThemeVariant 名称的 x:Key 来确定要修改哪个 ThemeVariant

第二个 ResourceDictionary

<ResourceDictionary x:Key="{x:Static local:CustomThemes.Pink}">
    <SolidColorBrush x:Key="ThemeBackgroundBrush"
                     Color="LightPink"/>
</ResourceDictionary>

将画笔与示例项目中 CustomThemes 静态类中定义的 Pink ThemeVariant 相关联。

public static class CustomThemes
{
    public static ThemeVariant Pink { get; } = new ThemeVariant("Pink", ThemeVariant.Light);
}

请注意,Pink ThemeVariant 继承自 ThemeVariant.Light(其构造函数的第二个参数)。因此,Pink ThemeVariant 获得来自 Light ThemeVariant 的所有默认画笔,而只有一个画笔被覆盖——ThemeBackgroundBrush 被设置为 LightPink 颜色。

请注意,Pink ResourceDictionaryx:Key 使用 x:Static 标记扩展设置——因为它是一个自定义 ThemeVariant,我们不能将其名称用作 ResourceDictionaryx:Key 参数。

新的主题化功能有什么作用?

新的主题化功能作为 NP.Ava.Visuals 包的一部分,可从 nuget 获取。源代码位于 NP.Ava.Visuals github 仓库中。

ThemeVariant 无疑极大地改进了 Avalonia 的主题化功能,但仍然不足以创建多个独立的 thene 范围——每个范围负责某个功能。

例如,想象一下您想创建多个 Color 主题(例如,上面提到的 Light、Dark 和 Pink),然后是两个 CornerRadius 主题——一个用于使按钮和其他控件的 CornerRadius 为 0,我们称之为 NoCornerRadius 主题——另一个用于使所有控件的 CornerRadius 为 3 ——CornerRadius3 主题。

现在我们想拥有 Color 主题和 CornerRadius 主题的所有可能组合——例如,我们想让用户选择带有 CornerRadius3 的粉色主题。实际上,ColorCornerRadius 主题提供了两个独立的主题变化坐标。我们还可以想到 3 个或更多独立坐标的例子,例如 ColorCornerRadiusLocale

由于 Avalonia 内置的 ThemeVariant 仅支持单继承,因此无法用于此目的——需要重复代码才能创建沿多个坐标的各种主题组合。

然而,我的 NP.Ava.Visuals 包中的 ThemeLoader 功能解决了这个问题,如下面的示例所示。

简易主题示例

此示例位于 NP.Demos.SimpleThemingSample 下。它仅通过使用 NP.Ava.Visuals 功能来表示单坐标(颜色)主题更改。

下载、编译并运行。您将看到以下内容:

按顶部的深色主题按钮。您会看到背景变为黑色,而文本颜色(前景)变为白色。

只有顶部的两个按钮没有改变。

查看主项目中的文件。

ColorThemes 文件夹下有两个 XAML 文件,名为 DarkResources.axamlLightResources.axaml。让我们来看看它们。这是 DarkResources.axaml 的内容:

<ResourceDictionary xmlns="https://github.com/avaloniaui"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <!-- Defing Background and Foreground brushes for dark theme -->
  <SolidColorBrush x:Key="BackgroundBrush"
                   Color="Black"/>
  <SolidColorBrush x:Key="ForegroundBrush"
                 Color="White"/>
</ResourceDictionary>  

这是 LightResources.axaml

<ResourceDictionary xmlns="https://github.com/avaloniaui"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <!-- Defing Background and Foreground brushes for light theme -->
  <SolidColorBrush x:Key="BackgroundBrush"
                   Color="White"/>
  <SolidColorBrush x:Key="ForegroundBrush"
                 Color="Black"/>
</ResourceDictionary>  

它们包含具有相同键和相反颜色的 Avalonia 资源——在 DarkResources.axaml 中,BackgroundBrush 是 'Black',而 ForegroundBrush 是白色——而在 LightResources.axaml 中——正好相反。

在通常的主解决方案文件之外,只有 App.axamlMainWindow.axamlMainWindow.axaml.cs 有一些非平凡的更改。

查看 App.axaml 的内容:

<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:np="https://np.com/visuals"
             x:Class="NP.Demos.SimpleThemingSample.App">
  <Application.Resources>
    <ResourceDictionary>
      <ResourceDictionary.MergedDictionaries>
        <!-- define the Theme loader with two themes - Dark and Light -->
        <np:ThemeLoader Name="ColorThemeLoader"
                        SelectedThemeId="Light"> <!-- Set original theme to Light -->
          <np:ThemeInfo Id="Dark"
                        ResourceUrl="/ColorThemes/DarkResources.axaml"/>
          <np:ThemeInfo Id="Light"
                        ResourceUrl="/ColorThemes/LightResources.axaml"/>
        </np:ThemeLoader>
      </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
  </Application.Resources>

  <!-- Default Avalonia Styles -->
  <Application.Styles>
    <StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseDark.xaml"/>
    <StyleInclude Source="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/>
  </Application.Styles>
</Application>  

实现 ThemeLoader 的重要代码包含在 ResourceDictionary.MergeDictionaries 标签内。

<!-- define the Theme loader with two themes - Dark and Light -->
<np:ThemeLoader Name="ColorThemeLoader"
                SelectedThemeId="Light"> <!-- Set original theme to Light -->
  <np:ThemeInfo Id="Dark"
                ResourceUrl="/ColorThemes/DarkResources.axaml"/>
  <np:ThemeInfo Id="Light"
                ResourceUrl="/ColorThemes/LightResources.axaml"/>
</np:ThemeLoader>  

ThemeLoader 本质上是一个智能 ResourceDictionary,可以交换其内容。

我们的 ThemeLoader 通过使用 ThemeInfo 对象定义了两个主题。第一个 ThemeInfo 对象指定了深色主题——它的 Id"Dark",它的 ResourceUrl="/ColorThemes/DarkResources.axaml" 设置为指向上面描述的 DarkResources.axaml 文件。第二个 ThemeInfo 对象通过将其 ResourceUrl 指向 LightResource.axaml 文件来指定浅色主题。它的 Id"Light"

通过更改其 SelectedThemeId,主题加载器可以交换其资源内容。最初,它设置为 "Light",这是第二个 ThemeInfo 对象的 Id,因此它在应用程序启动时加载,我们得到了在浅色主题下运行的应用程序。

我们将 ThemeLoaderName 属性设置为 "ColorThemeLoader"。通过这个名称,我们将在 MainWindow.axaml.cs 代码隐藏中找到这个加载器。

按下“深色主题”按钮将导致代码隐藏(在 MainWindow.axaml.cs 文件中)将我们的 ThemeLoaderSelectedThemeId 更改为“Dark”,以便应用程序更改其颜色。

MainWindow.axaml 文件非常简单:

<Window ...
        Background="{DynamicResource BackgroundBrush}"
        Width="300"
        Height="200">
  <Grid RowDefinitions="Auto, *"
        Margin="10">
    <StackPanel Orientation="Horizontal">
      <!-- this button switches to light theme -->
      <Button x:Name="LightButton"
              Content="Light Theme"
              Margin="0,0,10,0"/>


      <!-- this button switches to dark theme -->
      <Button x:Name="DarkButton"
              Content="Dark Theme"
              Margin="0,0,0,0"/>
    </StackPanel>

    <TextBlock Text="Hello World from Avalonia !!!"
               Grid.Row="1"
               HorizontalAlignment="Center"
               VerticalAlignment="Center"
               FontSize="20"
               Foreground="{DynamicResource ForegroundBrush}"/>
  </Grid>
</Window>  

MainWindowBackground 属性使用 DynamicResource 标记扩展连接到 BackgroundBrush 资源(在深色主题下指向黑色,在浅色主题下指向白色):Background="{DynamicResource BackgroundBrush}"

TextBlockForeground 使用相同的方法获取 ForegroundBrush 资源的值:Foreground="{DynamicResource ForegroundBrush}"

以下是 MainWindow.axaml.cs 文件内容的关键点:

public partial class MainWindow : Window
{
    // reference to ThemeLoader object defined
    // in XAML
    private ThemeLoader _themeLoader;

    public MainWindow()
    {
        InitializeComponent();

        ...

        // find the theme loader by its name
        _themeLoader =
            Application.Current.Resources.GetThemeLoader("ColorThemeLoader")!;

        // set the handler for lightButton's click event
        Button lightButton = this.FindControl<Button>("LightButton");
        lightButton.Click += LightButton_Click;

        // set the handler for darkButton's click event
        Button darkButton = this.FindControl<Button>("DarkButton");
        darkButton.Click += DarkButton_Click;
    }

    private void LightButton_Click(object? sender, 
                                   global::Avalonia.Interactivity.RoutedEventArgs e)
    {
        // set the theme to Light
        _themeLoader.SelectedThemeId = "Light";
    }

    private void DarkButton_Click(object? sender, 
                                  global::Avalonia.Interactivity.RoutedEventArgs e)
    {
        // set the theme to Dark
        _themeLoader.SelectedThemeId = "Dark";
    }

    ...
}  

首先,我们通过将名称传递给 GetThemeLoader 扩展方法来获取 XAML 中定义的 ThemeLoader 对象。

// find the theme loader by its name
_themeLoader =
    Application.Current.Resources.GetThemeLoader("ColorThemeLoader")!;  

接下来,我们获取 XAML 中定义的 LightButtonDarkButton 的引用,并为它们的 Click 事件设置处理程序。

// set the handler for lightButton's click event
Button lightButton = this.FindControl<Button>("LightButton");
lightButton.Click += LightButton_Click;

// set the handler for darkButton's click event
Button darkButton = this.FindControl<Button>("DarkButton");
darkButton.Click += DarkButton_Click;  

在每个处理程序中,我们相应地将 SelectedThemeId 设置为字符串 "Light""Dark",分别对应浅色和深色按钮。

private void LightButton_Click(object? sender, RoutedEventArgs e)
{
    // set the theme to Light
    _themeLoader.SelectedThemeId = "Light";
}

private void DarkButton_Click(object? sender, RoutedEventArgs e)
{
    // set the theme to Dark
    _themeLoader.SelectedThemeId = "Dark";
}  

带样式更改的简易主题

下一个示例演示了如何沿单个坐标(颜色)更改样式和资源。它还展示了如何利用内置的 ThemeVariant 来获取其中定义的资源。

上一个示例显示了如何更改背景和文本颜色。然而,顶部的浅色主题深色主题按钮没有改变——它们仍然是浅色,因为 Light 是默认的 ThemeVariant

此演示的代码可在 NP.Demos.SimpleThemingSampleWithStyleChange 中找到。

编译并运行演示——您将看到以下内容:

请注意,按钮是浅色的。然后按深色主题按钮——主题改变,按钮也随之改变。

注意与上一个示例的两个区别:

  1. 顶部的按钮改变了颜色。这些按钮不受我们 LightResources.axamlDarkResources.axaml 文件中定义的资源影响,但受到 ThemeVariant 更改的影响。
  2. 窗口中央的深色主题按钮具有圆角——这实际上来自于 Styles 的更改——浅色和深色样式分别定义在 ColorThemes 项目文件夹中的 LightStyles.axaml 文件和 DarkStyles.axaml 文件中。

请注意,在此示例中,我们仍然沿单个坐标更改主题,因为我们不能将所有深色和浅色主题与 NoCornerRadiousCornerRadius3 选项混合,而是将浅色主题硬编码为始终不带 CornerRadious,而深色主题——具有圆角半径 3。

与上一个示例相比,主要的代码更改(位于 App.axaml 文件中)并以粗体显示。

<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:np="https://np.com/visuals"
             x:Class="NP.Demos.SimpleThemingSample.App"
             np:ThemeVariantBehavior.ThemeReference=
                     "{DynamicResource BuiltInThemeReference}">
  <Application.Resources>
    <ResourceDictionary>
      <ResourceDictionary.MergedDictionaries>
        <!-- define the Theme loader with two themes - Dark and Light -->
        <np:ThemeLoader Name="ColorThemeLoader"
                        SelectedThemeId="Light"
                        StyleResourceName="ColorLoaderStyles"> 
                        <!-- to refer to the style by StyleReference-->
          <np:ThemeInfo Id="Dark"
                        ResourceUrl="/ColorThemes/DarkResources.axaml"
                        StyleUrl="/ColorThemes/DarkStyles.axaml"/> 
                        <!-- refers to dark styles -->
          <np:ThemeInfo Id="Light"
                        ResourceUrl="/ColorThemes/LightResources.axaml"
                        StyleUrl="/ColorThemes/LightStyles.axaml"/> 
                        <!-- refers to light styles -->
        </np:ThemeLoader>
      </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
  </Application.Resources>

  <Application.Styles>
    <!-- reference to the theme dependent style defined within the ThemeLoader-->
    <np:StyleReference TheStyle="{StaticResource ColorLoaderStyles}"/>
    
    <!-- Default Avalonia Style -->
    <SimpleTheme/>
  </Application.Styles>
</Application>

np:ThemeVariantBehavior.ThemeReference="{DynamicResource BuiltInThemeReference}" 负责使用预定义的 ThemeVariant。它将当前 LightResource.axamlDarkResource.axaml 文件中定义的 ThemeVariant 的引用应用于整个应用程序。

LightResources.axaml 文件包含以下额外 XAML 代码:

<np:ThemeVariantReference x:Key="BuiltInThemeReference" 
                          TheThemeVariant="{x:Static ThemeVariant.Light}"/>

以及 DarkResources.axaml 文件:

<np:ThemeVariantReference x:Key="BuiltInThemeReference" 
                          TheThemeVariant="{x:Static ThemeVariant.Dark}"/>

现在我将解释如何切换按钮 Styles

请注意,ColorThemes 文件夹下有两个新文件——DarkStyles.axmlLightStyles.axaml。它们都只包含一个 Style 选择器——Selector="Button.MyButton"——的样式定义。这是 LightStyles.axaml 的内容,将 CornerRadious 设置为 0

<!-- Add Styles Here -->
<Style Selector="Button.MyButton">
    <Setter Property="CornerRadius"
            Value="0"/>
</Style>

这是 DarkStyles.axaml 的内容,将 CornerRadius 设置为 3

<!-- Add Styles Here -->
<Style Selector="Button.MyButton">
    <Setter Property="CornerRadius"
            Value="3"/>
</Style>

现在,在 App.axaml 文件中,我们通过设置 ThemeInfo.StyleUrl 到相应的文件 URL 来引用这些文件:

<np:ThemeInfo Id="Dark"
            ResourceUrl="/ColorThemes/DarkResources.axaml"
            StyleUrl="/ColorThemes/DarkStyles.axaml"/> <!-- refers to dark styles -->
<np:ThemeInfo Id="Light"
            ResourceUrl="/ColorThemes/LightResources.axaml"
            StyleUrl="/ColorThemes/LightStyles.axaml"/> <!-- refers to light styles -->

请注意,每个 ThemeInfo 对象都在 IdResourceUrl 属性(在上一个示例中已解释)的顶部定义了 StyleUrl

<np:ThemeInfo Id="Dark"
              ResourceUrl="/ColorThemes/DarkResources.axaml"
              StyleUrl="avares://Avalonia.Themes.Default/Accents/BaseDark.xaml"/> 
<!-- refers to dark styles -->
<np:ThemeInfo Id="Light"
              ResourceUrl="/ColorThemes/LightResources.axaml"
              StyleUrl="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/> 
<!-- refers to light styles --> 

ThemeLoader 现在定义了一个属性 StyleResourceNameStyleResourceName="ColorLoaderStyles">。此属性值应选择为不与 ThemeLoader 中包含的资源的任何资源键冲突。StyleReference 对象使用此值来引用由我们的 ThemeLoader 选择的主题相关的 Style

<Application.Styles>
    <!-- reference to the styles defined within the ThemeLoader-->
    <np:StyleReference TheStyle="{StaticResource ColorLoaderStyles}"/>
    ...
</Application.Styles>

更改主题和语言示例

现在我们准备处理跨多个坐标的更改——一个坐标是 Color,另一个是 Language

本示例的目的是演示一个应用程序,该应用程序支持独立更改其颜色主题和语言,以便支持颜色主题和语言的每种组合。

此演示的代码可在以下 URL 找到:NP.Demos.SimpleThemingAndL10NSample

下载、编译并运行示例后,您将看到以下内容:

深色主题按钮将颜色主题更改为深色

希伯来语按钮将文本更改为希伯来语。

浅色主题按钮将颜色主题再次更改为浅色

查看演示应用程序中的项目文件。

此示例有两组字典:

  1. ColorThemes 文件夹下的 DarkResources.axamlLightResources.axaml
  2. LanguageDictionaries 文件夹下的 EnglishDictionary.axamlHebrewDictionary.axaml

颜色主题文件——DarkResources.axamlLightResources.axaml 与之前的示例完全相同。

查看 EnglishDictionary.axaml

<ResourceDictionary xmlns="https://github.com/avaloniaui"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <x:String x:Key="WindowTitle">Theming Demo</x:String>
  <x:String x:Key="WelcomeText">Hello World from Avalonia !!!</x:String>
  <x:String x:Key="WindowTitleText">Window Title is '{0}'</x:String>
</ResourceDictionary> 

这是一个非常简单的字典文件,它定义了三个 string 资源——WindowTitleWelcomeTextWindowTitleText

HebrewDictionary.axaml 定义了相同的资源,但其值被翻译成希伯来语。

WindowTitle 控制 Window 的标题,WelcomeText 是显示在窗口内的文本,WindowTitleText 也显示在窗口内作为第二行。我添加它以显示文本的动态替换,使用 Avalonia 绑定。请注意,WindowTitleText 的文本 "Window Title is '{0}'" 中包含 {0} 部分,它将被窗口标题的任何内容替换。这将在下面进行解释。

打开 App.axaml 文件。它定义了两个 ThemeLoaders——一个用于颜色主题,另一个用于语言。

<Application.Resources>
  <ResourceDictionary>
    <ResourceDictionary.MergedDictionaries>
      <!-- define the Theme loader with two themes - Dark and Light -->
      <np:ThemeLoader Name="ColorThemeLoader"
                      SelectedThemeId="Light"
                      StyleResourceName="ColorLoaderStyles"> 
      <!-- to refer to the style by StyleReference-->
        <np:ThemeInfo Id="Dark"
                      ResourceUrl="/ColorThemes/DarkResources.axaml"
                      StyleUrl="avares://Avalonia.Themes.Default/Accents/BaseDark.xaml"/> 
        <!-- refers to dark styles -->
        <np:ThemeInfo Id="Light"
                      ResourceUrl="/ColorThemes/LightResources.axaml"
                      StyleUrl="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/> 
        <!-- refers to light styles -->
      </np:ThemeLoader>

      <np:ThemeLoader Name="LanguageLoader"
              SelectedThemeId="English">
        <!-- to refer to the style by StyleReference-->
        <np:ThemeInfo Id="English"
                      ResourceUrl="/LanguageDictionaries/EnglishDictionary.axaml"/>
        <!-- refers to dark styles -->
        <np:ThemeInfo Id="Hebrew"
                      ResourceUrl="/LanguageDictionaries/HebrewDictionary.axaml"/>
        <!-- refers to light styles -->
      </np:ThemeLoader>
    </ResourceDictionary.MergedDictionaries>
  </ResourceDictionary>
</Application.Resources>  

ColorThemeLoader 默认选择“Light”,LanguageLoader 选择“English”(SelectedThemeId="English")。

现在打开 MainWindow.axaml 文件。

<Window ...
        Title="{DynamicResource WindowTitle}"
        Background="{DynamicResource BackgroundBrush}"
        ...
        >
  <Grid RowDefinitions="Auto, *"
        Margin="10">
    <StackPanel Orientation="Horizontal">
      
      <!-- this button switches to light theme -->
      <Button x:Name="LightButton"
              Content="Light Theme"
              Margin="0,0,10,0"/>

      <!-- this button switches to dark theme -->
      <Button x:Name="DarkButton"
              Content="Dark Theme"
              Margin="0,0,10,0"/>

      <!-- this button switches to English language-->
      <Button x:Name="EnglishButton"
              Content="English"
              Margin="0,0,10,0"/>

      <!-- this button switches to Hebrew language-->
      <Button x:Name="HebrewButton"
              Content="Hebrew"
              Margin="0,0,10,0"/>
    </StackPanel>
    <StackPanel HorizontalAlignment="Center"
                VerticalAlignment="Center"
                Grid.Row="1">
      <TextBlock Text="{DynamicResource WelcomeText}"
                 ...
                 Foreground="{DynamicResource ForegroundBrush}"
                 Margin="0,0,0,10"/>
      <TextBlock HorizontalAlignment="Center"
                 ...
                 Foreground="{DynamicResource ForegroundBrush}"
                 Margin="0,0,0,10">
        <TextBlock.Text> <!-- Use multibinding to format the string -->
          <MultiBinding Converter="{x:Static np:StringFormatConverter.Instance}">
            
            <!-- pass the main string from a language dictionary -->
            <DynamicResourceExtension ResourceKey="WindowTitleText"/>
            
            <!-- pass window title as a string parameter -->
            <Binding Path="Title" 
                     RelativeSource="{RelativeSource AncestorType=Window}"/>
          </MultiBinding>
        </TextBlock.Text>
      </TextBlock>
    </StackPanel>
  </Grid>
</Window>  

Window 标签将其 Title 设置为 WindowTitle,将其 Background 设置为来自主题/本地化字典的 BackgroundBrush

Title="{DynamicResource WindowTitle}"
Background="{DynamicResource BackgroundBrush}"  

顶部定义了四个按钮:两个用于深色和浅色主题,两个用于英语和希伯来语。

TextBlock 将它们的 TextForeground 属性动态设置为语言和颜色字典中定义的资源,例如:

Text="{DynamicResource WelcomeText}"
Foreground="{DynamicResource ForegroundBrush}" 

第二个 TextBlock 有一种智能的方式来绑定其文本,使用 MultiBinding——将单个目标绑定到多个源。这是为了扩展 string,自动插入 WindowTitle 属性值。

<TextBlock.Text> <!-- Use multibinding to format the string -->
  <MultiBinding Converter="{x:Static np:StringFormatConverter.Instance}">

    <!-- pass the main string from a language dictionary -->
    <DynamicResourceExtension ResourceKey="WindowTitleText"/>

    <!-- pass window title as a string parameter -->
    <Binding Path="Title" 
             RelativeSource="{RelativeSource AncestorType=Window}"/>
  </MultiBinding>
</TextBlock.Text>  

我们使用 NP.Avalonia.Visuals 包中定义的 StringFormatConverter。它将第一个 string 作为格式,其余作为参数,并对它们调用 string.Format(string format, params object[] args) 方法。多重绑定中的第一个绑定由 DynamicResourceExtension 提供(是的,在 Avalonia 中——DynamicResource 只是一个绑定,因此它可以作为其 Binding 子项之一插入到 MultiBinding 中)。此绑定返回格式 string(对于英语,将是 "Window Title is '{0}'")。

第二个绑定返回要插入到第一个 string 中的窗口标题(代替“{0}”)。

现在看看 MainWindow.axaml.cs 文件,它与上一个示例的同名文件非常相似,只是在这里,我们定义了两个 ThemeLoader 对象(_colorThemeLoader_languageThemeLoader),并将处理程序分配给 4 个按钮而不是 2 个按钮的 Click 事件。

public partial class MainWindow : Window
{
    private ThemeLoader _colorThemeLoader;
    private ThemeLoader _languageThemeLoader;

    public MainWindow()
    {
        InitializeComponent();

        ...

        // find the color theme loader by name
        _colorThemeLoader =
            Application.Current.Resources.GetThemeLoader("ColorThemeLoader")!;

        // find the language theme loader by name
        _languageThemeLoader =
            Application.Current.Resources.GetThemeLoader("LanguageLoader")!;

        Button lightButton = this.FindControl<Button>("LightButton");
        lightButton.Click += LightButton_Click;

        Button darkButton = this.FindControl<Button>("DarkButton");
        darkButton.Click += DarkButton_Click;

        Button englishButton = this.FindControl<Button>("EnglishButton");
        englishButton.Click += EnglishButton_Click;

        Button hebrewButton = this.FindControl<Button>("HebrewButton");
        hebrewButton.Click += HebrewButton_Click;
    }

    private void LightButton_Click(object? sender, RoutedEventArgs e)
    {
        // set the theme to Light
        _colorThemeLoader.SelectedThemeId = "Light";
    }

    private void DarkButton_Click(object? sender, RoutedEventArgs e)
    {
        // set the theme to Dark
        _colorThemeLoader.SelectedThemeId = "Dark";
    }

    private void EnglishButton_Click(object? sender, RoutedEventArgs e)
    {
        // set language to English
        _languageThemeLoader.SelectedThemeId = "English";
    }

    private void HebrewButton_Click(object? sender, RoutedEventArgs e)
    {
        // set language to Hebrew
        _languageThemeLoader.SelectedThemeId = "Hebrew";
    }
    ...
}  

高级演示的代码

我们在本文的介绍部分已经展示了高级演示,所以在这里,我们将只讨论代码(位于 NP.Demos.ThemingAndLocalizationDemo)。

从概念上讲,高级演示的代码在上面讨论的简单示例之上并没有太多新内容。许多属性都进行了本地化,包括窗口和控件的大小、应用程序的流程(希伯来语是从右到左书写和查看的)、水平对齐方式等。此外,还在 LanguageDictionaries 中添加了 RussianResources.axaml 文件。

此演示中使用的一个功能值得特别讨论。该演示使用 DynamicResourceBinding 对象,例如:

<TextBlock.Text>
  <MultiBinding Converter="{x:Static np:StringFormatConverter.Instance}">
    <np:DynamicResourceBinding Path="Uid"/>
    <Binding Path="ID"/>
  </MultiBinding>
</TextBlock.Text>  

DynamicResourceBinding 结合了 BindingDynamicResource 的功能。它绑定到一个 string 或对象,该对象随后用作 DynamicResource 的资源键。当提供资源键的属性或资源键指向的动态资源更改其值时,DynamicResourceBinding 的返回值将发生变化。

我创建了 DynamicResourceBinding 以匹配 Tomer 的自定义标记扩展提供的一些功能,但它对于自定义应用程序也非常有用。

我将在未来专门介绍 NP.Avalonia.Visuals 包功能的文章中详细介绍 DynamicResourceBinding

结论

本文解释并提供了我在跨平台桌面应用程序中构建和使用的基于 Avalonia 的主题化/本地化功能的详细示例。

这项功能的灵感来自于 Tomer Shamam 编写的旧 WPF 程序包。

此功能作为 NP.Avalonia.Visuals nuget 包和 Github 仓库的一部分发布,采用最简单、最宽松的 MIT 许可证——这基本上意味着您可以在任何应用程序中使用它,无论商业与否,只要您不将作者(我)归咎于可能存在的错误,并提供简短的归属。

本文描述的所有示例均在 Windows 10、Mac Catalina 和 Ubuntu 20.04 机器上进行了测试。

历史

  • 2021 年 11 月 18 日:初始版本
  • 2023 年 12 月 26 日:升级以支持 Avalonia 11
© . All rights reserved.