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:
The two ComboBox
es 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):
And here is Russian/Dark combination:
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:
- Multiplatform UI Coding with AvaloniaUI in Easy Samples. Part 1 - AvaloniaUI Building Blocks
- Basics of XAML in Easy Samples for Multiplatform Avalonia .NET Framework
- Multiplatform Avalonia .NET Framework Programming Basic Concepts in Easy Samples
- 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:
Note that the foreground is Blue. Press Pink Theme button - you'll see the following:
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:
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:
<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">
<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">
<Button x:Name="LightButton"
Content="Light Theme"
Margin="0,0,10,0"/>
<Button x:Name="DarkButton"
Content="Dark Theme"
Margin="0,0,10,0"/>
<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:
<Window.Styles>
<SimpleTheme/>
</Window.Styles>
We set the default Styles to be defined by Avalonia's built-in SimpleTheme
which has two ThemeVariant
s: Light
(also called Default) and Dark
. Both ThemeVariant
s are defined as static
properties within ThemeVariant
Avalonia class:
public sealed record ThemeVariant
{
...
public static ThemeVariant Light { get; } = new(nameof(Light));
public static ThemeVariant Dark { get; } = new(nameof(Dark));
...
}
Within XAML, ThemeVariant
s can be referred to by using the x:Static
reference to their definitions within C# code. Also ThemeVariant
s 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
:
<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
):
<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:
<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:
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.
ThemeVariant
s 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 ThemeVariant
s 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:
Press Dark Theme button at the top. You see background changing to black while text color (foreground) becomes white:
Only the two buttons at the top do not change.
Take a look at the files within the main project:
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:
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="BackgroundBrush"
Color="Black"/>
<SolidColorBrush x:Key="ForegroundBrush"
Color="White"/>
</ResourceDictionary>
and here is LightResources.axaml:
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<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:
<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>
<np:ThemeLoader Name="ColorThemeLoader"
SelectedThemeId="Light">
<np:ThemeInfo Id="Dark"
ResourceUrl="/ColorThemes/DarkResources.axaml"/>
<np:ThemeInfo Id="Light"
ResourceUrl="/ColorThemes/LightResources.axaml"/>
</np:ThemeLoader>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<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:
<np:ThemeLoader Name="ColorThemeLoader"
SelectedThemeId="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:
<Window ...
Background="{DynamicResource BackgroundBrush}"
Width="300"
Height="200">
<Grid RowDefinitions="Auto, *"
Margin="10">
<StackPanel Orientation="Horizontal">
<Button x:Name="LightButton"
Content="Light Theme"
Margin="0,0,10,0"/>
<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:
public partial class MainWindow : Window
{
private ThemeLoader _themeLoader;
public MainWindow()
{
InitializeComponent();
...
_themeLoader =
Application.Current.Resources.GetThemeLoader("ColorThemeLoader")!;
Button lightButton = this.FindControl<Button>("LightButton");
lightButton.Click += LightButton_Click;
Button darkButton = this.FindControl<Button>("DarkButton");
darkButton.Click += DarkButton_Click;
}
private void LightButton_Click(object? sender,
global::Avalonia.Interactivity.RoutedEventArgs e)
{
_themeLoader.SelectedThemeId = "Light";
}
private void DarkButton_Click(object? sender,
global::Avalonia.Interactivity.RoutedEventArgs e)
{
_themeLoader.SelectedThemeId = "Dark";
}
...
}
First, we get our ThemeLoader
object defined in XAML by the passing its name to the GetThemeLoader
extension method:
_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:
Button lightButton = this.FindControl<Button>("LightButton");
lightButton.Click += LightButton_Click;
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:
private void LightButton_Click(object? sender, RoutedEventArgs e)
{
_themeLoader.SelectedThemeId = "Light";
}
private void DarkButton_Click(object? sender, RoutedEventArgs e)
{
_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 ThemeVariant
s 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:
Note that the buttons are light. Then press Dark Theme button - the theme changes and the buttons change also:
Note two differences from the previous sample:
- 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
. - 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:
<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>
<np:ThemeLoader Name="ColorThemeLoader"
SelectedThemeId="Light"
StyleResourceName="ColorLoaderStyles">
<np:ThemeInfo Id="Dark"
ResourceUrl="/ColorThemes/DarkResources.axaml"
StyleUrl="/ColorThemes/DarkStyles.axaml"/>
<np:ThemeInfo Id="Light"
ResourceUrl="/ColorThemes/LightResources.axaml"
StyleUrl="/ColorThemes/LightStyles.axaml"/>
</np:ThemeLoader>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<np:StyleReference TheStyle="{StaticResource ColorLoaderStyles}"/>
<SimpleTheme/>
</Application.Styles>
</Application>
Line np:ThemeVariantBehavior.ThemeReference="{DynamicResource BuiltInThemeReference}"
is the one responsible for using predefined ThemeVariant
s. It applies the reference to ThemeVariant
s defined in the current LightResource.axaml or DarkResource.axaml file file to the whole application.
LightResources.axaml file contains the following extra XAML code:
<np:ThemeVariantReference x:Key="BuiltInThemeReference"
TheThemeVariant="{x:Static ThemeVariant.Light}"/>
and DarkResources.axaml file:
<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
:
<Style Selector="Button.MyButton">
<Setter Property="CornerRadius"
Value="0"/>
</Style>
And here is the content of DarkStyles.axaml setting CornerRadius
to 3
:
<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:
<np:ThemeInfo Id="Dark"
ResourceUrl="/ColorThemes/DarkResources.axaml"
StyleUrl="/ColorThemes/DarkStyles.axaml"/>
<np:ThemeInfo Id="Light"
ResourceUrl="/ColorThemes/LightResources.axaml"
StyleUrl="/ColorThemes/LightStyles.axaml"/>
Note that each of the ThemeInfo
objects defines the StyleUrl
on top Id
and ResourceUrl
properties explained in the previous sample:
<np:ThemeInfo Id="Dark"
ResourceUrl="/ColorThemes/DarkResources.axaml"
StyleUrl="avares://Avalonia.Themes.Default/Accents/BaseDark.xaml"/>
<np:ThemeInfo Id="Light"
ResourceUrl="/ColorThemes/LightResources.axaml"
StyleUrl="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/>
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
:
<Application.Styles>
<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:
Pressing Dark Theme button will change the color theme to Dark:
Pressing Hebrew button will change the text to Hebrew:
Pressing Light Theme button will change the color theme to Light again:
Take a look at the project files within the demo application:
This sample has two sets of dictionaries:
- DarkResources.axaml and LightResources.axaml under ColorThemes folder
- 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:
<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:
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<np:ThemeLoader Name="ColorThemeLoader"
SelectedThemeId="Light"
StyleResourceName="ColorLoaderStyles">
<np:ThemeInfo Id="Dark"
ResourceUrl="/ColorThemes/DarkResources.axaml"
StyleUrl="avares://Avalonia.Themes.Default/Accents/BaseDark.xaml"/>
<np:ThemeInfo Id="Light"
ResourceUrl="/ColorThemes/LightResources.axaml"
StyleUrl="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/>
</np:ThemeLoader>
<np:ThemeLoader Name="LanguageLoader"
SelectedThemeId="English">
<np:ThemeInfo Id="English"
ResourceUrl="/LanguageDictionaries/EnglishDictionary.axaml"/>
<np:ThemeInfo Id="Hebrew"
ResourceUrl="/LanguageDictionaries/HebrewDictionary.axaml"/>
</np:ThemeLoader>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
ColorThemeLoader
selects "Light
" and LanguageLoader
"English
" by default (SelectedThemeId="English"
).
Now open MainWindow.axaml file:
<Window ...
Title="{DynamicResource WindowTitle}"
Background="{DynamicResource BackgroundBrush}"
...
>
<Grid RowDefinitions="Auto, *"
Margin="10">
<StackPanel Orientation="Horizontal">
<Button x:Name="LightButton"
Content="Light Theme"
Margin="0,0,10,0"/>
<Button x:Name="DarkButton"
Content="Dark Theme"
Margin="0,0,10,0"/>
<Button x:Name="EnglishButton"
Content="English"
Margin="0,0,10,0"/>
<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>
<MultiBinding Converter="{x:Static np:StringFormatConverter.Instance}">
<DynamicResourceExtension ResourceKey="WindowTitleText"/>
<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:
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.,
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:
<TextBlock.Text>
<MultiBinding Converter="{x:Static np:StringFormatConverter.Instance}">
<DynamicResourceExtension ResourceKey="WindowTitleText"/>
<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:
public partial class MainWindow : Window
{
private ThemeLoader _colorThemeLoader;
private ThemeLoader _languageThemeLoader;
public MainWindow()
{
InitializeComponent();
...
_colorThemeLoader =
Application.Current.Resources.GetThemeLoader("ColorThemeLoader")!;
_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)
{
_colorThemeLoader.SelectedThemeId = "Light";
}
private void DarkButton_Click(object? sender, RoutedEventArgs e)
{
_colorThemeLoader.SelectedThemeId = "Dark";
}
private void EnglishButton_Click(object? sender, RoutedEventArgs e)
{
_languageThemeLoader.SelectedThemeId = "English";
}
private void HebrewButton_Click(object? sender, RoutedEventArgs e)
{
_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.,
<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