Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / programming / localization

Theming and Localization Functionality for Multiplatform Avalonia UI Framework

5.00/5 (12 votes)
25 Dec 2023MIT17 min read 20.3K  
New simple and flexible package for Theming and Localizing multiplatform Avalonia applications is described here with samples
In this article, you will see detailed examples of the theming/localization Avalonia based functionality I built and used in a multiplatform desktop application. The inspiration for this functionality was the old WPF package written by Tomer Shamam.

Introduction

Note that both the article and the samples code have been updated to work with latest version of Avalonia - 11.0.6

Localization, Internationalization and Theming

Years ago, I came across a great package for localizing/internationalizing WPF applications. I successfully used it to enable a WPF application that I had built to switch between English and German versions.

This package for WPF had been developed by Tomer Shamam and since then was removed from his former blog. He sent the code to me and with his kind permission, I published it in Github WPFLocalizationPackage repository.

Recently, again, I needed to internationalize an application. Initially, I wanted to port that WPF package to Avalonia (a great multiplatform open source descendant of WPF). Eventually, I decided to build a new functionality from scratch, borrowing some ideas from Tomer's WPF package.

The main reason for this decision is that Avalonia's DynamicResource markup extension and bindings work better and without the quirks which plagued their WPF counterparts. So instead of creating a custom markup extension, I simply tapped into Avalonia's DynamicResource.

I still used Tomer's idea of a C# object containing different localization dictionaries that can easily switch between different locales. Equally importantly, I created a demo application that looked very similar to his demo to make sure that my implementation covered all his functionality and more.

Both packages - Tomer's original package and my package for Avalonia - can be used to change any Dependency (or Avalonia) property on any control, not only properties related to localizations. Therefore, the same functionality can be used for theming or skinning - completely changing the look of the application.

The advantages of my localization/internationalization package are:

  • Its simplicity of use - as will be demonstrated by the samples.
  • Its dynamic nature - some other solutions including those from big companies require different compilations to be made for each locale while my package (as well as Tomer's WPF package) allow switching the locales while the application is running.
  • Allowing the creation of multiple sets of Theming/Localization dictionaries, each of which will control only one 'coordinate' of the required customization. Several of the samples will show how to dynamically switch between several languages and several color themes - so that any combination of a language and a color theme is possible.

Running the Advanced Demo

In order to demonstrate the power of the new theming and L10N (localization) package, I'll start with an advanced sample. Do not look at the code at this point (it will be explained later). Download and run the demo from NP.Demos.ThemingAndLocalizationDemo.

Here is what you'll see:

Image 1

The two ComboBoxes at the top right corner allow to choose a language ("English", "Hebrew" or "Russian") and a color theme ("Dark" or "Light"). The picture above shows the application under English language and a Dark color theme.

Here is the view of the application under Hebrew language and Light theme (note the change of the text and control flow - right to left instead of left to right):

Image 2

And here is Russian/Dark combination:

Image 3

What is Avalonia

Avalonia is a great multiplatform open source UI framework for developing

  • Desktop solutions that will run across Windows, Mac and Linux
  • Web applications to run in Browser (via WebAssembly)
  • Mobile applications for Android, iOS and Tizen.

Avalonia's functionality is similar to WPF, but Avalonia is already more powerful and less buggy than WPF in addition to being multiplatform.

Avalonia is much more powerful, cleaner and faster than UNO Platform - its only competitor in multiplatform XAML/C# world.

Avalonia tutorials and more information about this wonderful package can be found in my codeproject.com articles:

  1. Multiplatform UI Coding with AvaloniaUI in Easy Samples. Part 1 - AvaloniaUI Building Blocks
  2. Basics of XAML in Easy Samples for Multiplatform Avalonia .NET Framework
  3. Multiplatform Avalonia .NET Framework Programming Basic Concepts in Easy Samples
  4. Avalonia .NET Framework Programming Advanced Concepts in Easy Samples

Also do not miss UniDock, the powerful Avalonia mulitplatform UI Docking package that I recently released at UniDock - A New Multiplatform UI Docking Framework. UniDock Power Features.

Avalonia 11 Related Changes

Theming underwent a big change in Avalonia 11. I am providing a couple of samples explaining the built-in Avania theming functionality below.

Running Samples on Multiple Platforms

All the samples in this article have been tested on Windows 10, Mac Catalina and Ubuntu 20.04

Theming/Localization Code Location

The new theming/L10N functionality is part of the NP.Ava.Visuals open source package. The package can have other uses too and I plan on writing another article explaining its most important functionality.

There is another project NP.ViewModelInterfaces containing non-visual interfaces implemented by some Visual objects. The purpose of this code is to be used within non-visual, e.g., View Model projects without any references to Avalonia code to control and query some visual objects. So far, NP.ViewModelInterfaces contain only interface related to the Theming and Localization functionality: IThemeLoader.cs and ThemeInfo.cs

Nuget Package Location

Nuget package is available at NP.Ava.Visuals from nuget.org. It depends on several other packages that will be installed automatically.

You do not need separate references to Avalonia packages, since they will be installed by installing NP.Ava.Visuals.

If you want to have some control over theming/l10n functionality from non-visual (view models) projects, you can install only NP.ViewModelInterfaces also available from nuget.org.

Theming/Localization Code Samples

Samples Code Location

All the Theming/L10N demo code is available at NP.Demos.ThemingAndL10N.
The code was built and tested using Visual Studio 2022.

Built-In Theming Sample

This sample project is located within NP.Demos.BuiltInThemingSample folder.

Open the solution in VS2022, compile and run the project. Here is what you'll see:

Image 4

Note that the foreground is Blue. Press Pink Theme button - you'll see the following:

Image 5

Our Pink theme inherits from Light theme so the background is overridden to Pink color but the foreground remain blue.

Finally click on Dark Theme button:

Image 6

Both the background and foreground change.

Now, let us take a look at the code. Most of the code is located within MainWindow.axaml XAML file:

XML
<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>

Now, I'll explain the file starting from its top. The first interesting part is default theme setting:

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

We set the default Styles to be defined by Avalonia's built-in SimpleTheme which has two ThemeVariants: Light (also called Default) and Dark. Both ThemeVariants are defined as static properties within ThemeVariant Avalonia class:

C#
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));
    
    ...
}

Within XAML, ThemeVariants can be referred to by using the x:Static reference to their definitions within C# code. Also ThemeVariants that come with Avalonia by using their Name (since each ThemeVariant should have a unique name).

Then (coming back to the MainWindow.xaml file) we set Windows.Resources section to be a ResourceDictionary that contains ThemeDictionaries:

XML
<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>

The first dictionary within ThemeDictionaries modifies the Light ThemeVariant changing its ThemeForegroundBrush to Blue (from Black):

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

It knows which ThemeVariant it needs to modify because of the x:Key="Light" parameter set on the ResourceDictionary. Remember: every ThemeVariant has a unique name so and since Light ThemeVariant comes from Avalonia, Avalonia can figure out which ThemeVariant to modify by the x:Key set to the name of the ThemeVariant.

The second ResourceDictionary:

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

associates the brushes with the Pink ThemeVariant defined within the CustomThemes static class of the Sample project:

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

Note that the Pink ThemeVariant derives from ThemeVariant.Light (second argument to its constructor). Because of this, Pink ThemeVariant gets all default brushes from Light ThemeVariant while only one brush is overridden - ThemeBackgroundBrush is set to LightPink color.

Note that the x:Key of the Pink ResourceDictionary is set using x:Static markup extension - since it is a custom ThemeVariant, we cannot use its name a the x:Key parameter to the ResourceDictionary.

What New Theming Functionality Does

The new theming is available as part of NP.Ava.Visuals package from nuget. The source code is located within NP.Ava.Visuals github repository.

ThemeVariants undoubtably greatly improved the Avalonia theming functionality, but still not sufficient for creating several independent theme ranges - each responsible for some feature.

For example, imagine that you want to create a number of Color themes (e.g., Light, Dark and Pink as above) and say two CornerRadius themes - one for buttons and other control to have CornerRadius 0 we call it NoCornerRadius theme - and another for all the controls to have CornerRadius 3 - CornerRadius3 theme.

Now we want to have all possible combinations of Color themes and CornerRadius themes - e.g., we want the user to choose Pink color theme with CornerRadius3. In fact, Color and CornerRadius themes provide two independent coordinates along which the themes can change. We can also come with examples of 3 and more independent coordinates for potential themes, e.g., Color, CornerRadius and Locale.

Since ThemeVariants built into Avalonia have only single inheritance, they cannot be used for that - one would have to duplicate code to create various theme combinations along multiple coordinates.

This problem is however resolved by ThemeLoader functionality from my NP.Ava.Visuals package as will be shown by the examples below.

Simple Theming Sample

This sample is located under NP.Demos.SimpleThemingSample. It represents only single-coordinate (Color) Theme change by using the NP.Ava.Visuals functionality.

Download it, compile and run. Here is what you'll see:

Image 7

Press Dark Theme button at the top. You see background changing to black while text color (foreground) becomes white:

Image 8

Only the two buttons at the top do not change.

Take a look at the files within the main project:

Image 9

There are two XAML files under ColorThemes folder called DarkResources.axaml and LightResources.axaml. Let us take a look at them. Here is the content of DarkResources.axaml:

XAML
<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>  

and here is LightResources.axaml:

XAML
<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>  

They contain Avalonia resources with the same keys and opposite colors - in DarkResources.axaml BackgroundBrush is 'Black' while ForegroundBrush is white - while in LightResources.axaml - it is the opposite.

Among the usual main solution files, only App.axaml, MainWindow.axaml and MainWindow.axaml.cs have some non-trivial changes.

Take a look at the contents of App.axaml:

XAML
<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>  

The important code that does the trick defining the ThemeLoader is included within ResourceDictionary.MergeDictionaries tag:

XAML
<!-- 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 is essentially a smart ResourceDictionary that can swap its content.

Our ThemeLoader defines two themes by using ThemeInfo objects. The first ThemeInfo object specifies the dark theme - its Id is "Dark" and its ResourceUrl="/ColorThemes/DarkResources.axaml" is set to point to the DarkResources.axaml file described above. The second ThemeInfo object specifies the light theme by having its ResourceUrl pointing to the LightResource.axaml file. Its Id is "Light".

The theme loader can swap its resource content by changing its SelectedThemeId. Initially, it is set to "Light" which is the Id of the second ThemeInfo object, so it is loaded when the application starts and we get the Application running under the light color theme.

We set the property Name of our ThemeLoader to "ColorThemeLoader". By this name, we shall be able to find this loader within our MainWindow.axaml.cs code behind.

Pressing button "Dark Theme" will result in code behind (within MainWindow.axaml.cs file) changing the SelectedThemeId of our ThemeLoader to "Dark" so that the application changes its colors.

MainWindow.axaml file is very simple:

XAML
<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>  

MainWindow's Background property uses DynamicResource markup extension to connect to the BackgroundBrush resource (which points to black color under dark theme and to white color under light theme): Background="{DynamicResource BackgroundBrush}"

TextBlock's Foreground employs the same method to obtain the value of ForegroundBrush resource: Foreground="{DynamicResource ForegroundBrush}".

Here are the highlights of MainWindow.axaml.cs file's content:

C#
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";
    }

    ...
}  

First, we get our ThemeLoader object defined in XAML by the passing its name to the GetThemeLoader extension method:

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

Next, we get the reference to LightButton and DarkButton defined in XAML and set the handlers for their Click events:

C#
// 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;  

Within each of the handlers, we set the SelectedThemeId to string "Light" or "Dark" for light and dark buttons correspondingly:

C#
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";
}  

Simple Theming with Style Change

The next sample shows how to change both Styles and Resource along the single (Color) coordinate. Also, it shows how to tap into built-in ThemeVariants for Resources defined there.

The previous sample showed how to change the background and the text color. The Light Theme and Dark Theme buttons at the top, however did not change - they remained light because Light is the default ThemeVariant.

This demo's code is available at NP.Demos.SimpleThemingSampleWithStyleChange.

Compile and run the demo - here is what you'll see:

Image 10

Note that the buttons are light. Then press Dark Theme button - the theme changes and the buttons change also:

Image 11

Note two differences from the previous sample:

  1. The buttons at the top change colors. They are the buttons not affected by the resources defined in our LightResources.axaml and DarkResources.axaml files, but affected by the change in ThemeVariant.
  2. The DarkTheme button in the center of the window has curved angles - this actually comes from the change in Styles - the light and dark styles are defined correspondingly in LightStyles.axaml file and the dark style is defined in DarkStyles.axaml files within ColorThemes project folder.

Note, that we are still changing themes along the single coordinate even in this sample because we cannot have all combinations of dark and light theme mingled with NoCornerRadious and CornerRadius3 options rather Light theme is hard coded to be always without CornerRadious and Dark theme - to have corner radius 3.

The major code changes (in comparison to the previous sample) are located within App.axaml file and are highlighted in bold:

XAML
<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>

Line np:ThemeVariantBehavior.ThemeReference="{DynamicResource BuiltInThemeReference}" is the one responsible for using predefined ThemeVariants. It applies the reference to ThemeVariants defined in the current LightResource.axaml or DarkResource.axaml file file to the whole application.

LightResources.axaml file contains the following extra XAML code:

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

and DarkResources.axaml file:

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

Now I'll explain how the button Styles are switched.

Note there are two new files under ColorThemes folder - DarkStyles.axml and LightStyles.axaml. Both contain only one style definition of the same Style selector - Selector="Button.MyButton". Here is the content of LightStyles.axaml setting CornerRadious to 0:

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

And here is the content of DarkStyles.axaml setting CornerRadius to 3:

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

Now, within App.axaml file, we refur to those files by setting ThemeInfo.StyleUrl to the corresponding file URL:

XML
<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 -->

Note that each of the ThemeInfo objects defines the StyleUrl on top Id and ResourceUrl properties explained in the previous sample:

XAML
<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 --> 

Theme loader now defines a property StyleResourceName: StyleResourceName="ColorLoaderStyles">. This property value should be chosen not to clash with any resource keys for the resources contained within the ThemeLoader. This value is used by StyleReference object to refer to the theme depended Style chosen by our ThemeLoader:

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

Changing Theme and Language Sample

Now we are ready to tackle a change across multiple coordinates - one of them Color and the other Language.

The purpose of this sample is to demonstrate an application that supports changing its color theme and language independently so that each combination of the color theme and the language is supported.

The code for this demo can be found at the following URL: NP.Demos.SimpleThemingAndL10NSample.

Here is what you'll see after downloading, compiling and running the sample:

Image 12

Pressing Dark Theme button will change the color theme to Dark:

Image 13

Pressing Hebrew button will change the text to Hebrew:

Image 14

Pressing Light Theme button will change the color theme to Light again:

Image 15

Take a look at the project files within the demo application:

Image 16

This sample has two sets of dictionaries:

  1. DarkResources.axaml and LightResources.axaml under ColorThemes folder
  2. EnglishDictionary.axaml and HebrewDictionary.axaml under LanguageDictionaries folder

The color theme files - DarkResources.axaml and LightResources.axaml are exactly the same as in the previous samples.

Take a look at EnglishDictionary.axaml:

XAML
<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> 

It is a very simple dictionary file that defines three string resources - WindowTitle, WelcomeText and WindowTitleText.

HebrewDictionary.axaml defines the same resources, but with their values translated into Hebrew.

WindowTitle controls the title of the Window, WelcomeText is the text displayed inside the window and WindowTitleText is also displayed inside the window as a second line. I added it to show the dynamic substitution of the text using Avalonia bindings. Note that the text "Window Title is '{0}'" of WindowTitleText has {0} part that is there to be replaced by whatever the title of the window is. How it is achieved will be explained below.

Open App.axaml file. It defines two ThemeLoaders - one for color theme and another for languages:

XAML
<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 selects "Light" and LanguageLoader "English" by default (SelectedThemeId="English").

Now open MainWindow.axaml file:

XAML
<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>  

The Window tag sets its Title to WindowTitle and Background to BackgroundBrush coming from the theme/localization dictionaries:

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

There are four buttons defined at the top: two buttons for Dark and Light color themes and two buttons for English and Hebrew languages.

The TextBlocks set their Text and Foreground properties to be dynamically set to the resources defined in the language and color dictionaries, e.g.,

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

The second TextBlock has a smart way of binding its text by using a MultiBinding - binding a single target to multiple source. This is done to expand the string automatically plugging in the Window's Title property value:

XAML
<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>  

We use the StringFormatConverter defined in NP.Avalonia.Visuals package. It takes the first string as the format, and the rest as arguments and calls string.Format(string format, params object[] args) method on them. The first binding within the multibinding is provided by the DynamicResourceExtension (yes, in Avalonia - DynamicResource is just a binding and because of that it can be plugged into the MultiBinding as one of its Binding children). This binding returns the format string (for English, that would be "Window Title is '{0}'").

The second binding returns the title of the window to be plugged into the first string (in place of "{0}").

Now take a look at MainWindow.axaml.cs file, it is very similar to the same named files of the previous sample, only here, we define two ThemeLoader objects (_colorThemeLoader and _languageThemeLoader) and assign the handlers to Click events of 4 buttons instead of 2:

C#
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";
    }
    ...
}  

Code for the Advanced Demo

We already showed the advanced demo in the introduction section of this article, so here, we shall talk only about the code (located at NP.Demos.ThemingAndLocalizationDemo).

Conceptually, the code for the advanced demo does not contain much new on top of what was discussed in the simple samples above. Many more properties were localized, including window and control sizes the flow of the application (Hebrew is written and viewed right to left), horizontal alignments and others. Also RussianResources.axaml file was added to LanguageDictionaries.

One feature used in this demo deserves a special discussion. The demo uses DynamicResourceBinding object, e.g.,

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

DynamicResourceBinding combines the functionality of a Binding and a DynamicResource. It binds to a string or an object that is then used as the resource key of a DynamicResource. The return value of the DynamicResourceBinding will change when either the property providing the resource key or the dynamic resource pointed by the resource key change their values.

I created DynamicResourceBinding in order to match some of the functionality provided by Tomer's custom markup extension, but it can also be pretty useful for customizing the application.

I will write more about DynamicResourceBinding in a future article dedicated to NP.Avalonia.Visuals package functionality.

Conclusion

This article explains and gives detailed examples of the theming/localization Avalonia based functionality I built and used in a multiplatform desktop application.

The inspiration for this functionality was the old WPF package written by Tomer Shamam.

This functionality is released as part of NP.Avalonia.Visuals nuget package and Github repository under the simplest and most permissive MIT License - which essentially means that you can use it in any application, commercial or not, as long as you do not blame the author (me) for the possible bugs and provide a short attribution.

All the samples described here were tested on Windows 10, Mac Catalina and Ubuntu 20.04 machines.

History

  • 18th November, 2021: Initial version
  • 26th December, 2023: Upgraded to work with Avalonia 11

License

This article, along with any associated source code and files, is licensed under The MIT License