Quick Background: I am writing this from the standpoint of a Blazor web developer who wants to use an RCL to create the UI for a cross-platform application. This question, however, is specific to running it on Windows.
The Issue
After a couple of weeks of research and trying various recommendations and things that I thought might work, I am running into a lot of issues with customizing the main application window when targeting Windows. The primary thing I am trying to do is to change the background color of the top bar when running the application in Windows. I've successfully managed to change the background color of the TitleBar (using the Windows Platform-specific App.xaml.cs
), however, this only works on Windows 11 since the TitleBar property is null otherwise. I am looking for a way to either add my own TitleBar. I've tried a few things, including using the ConfigureLifecycleEvents
in MauiProgram.cs, but I don't really understand how to use the SetTitleBar
method; as well as adding the <DataTemplate x:Key="MauiAppTitleBarContainerTemplate">
which requires me to understand how to manipulate it (add draggable area, set the background for the custom title bar, as well as the TitleBar.ButtonBackgroundColor or what the correct thing to do is for the control buttons for the window); and have played around with the ExtendsContentIntoTitleBar
and wrapping the main page in a NavigationPage, which just gives me a big empty space between the TitleBar and the BlazorWebView.
I would really love some resources for fully customizing the TitleBar, primarily focused on changing the TitleBar background color (and button background color) at runtime. Below is the setup of the Platform-specific (Window) App.xaml.cs that works with Win 11, but not Win 10.
// Platforms/Windows/App.xaml.cs
public partial class App : MauiWinUIApplication
{
public App()
{
InitializeComponent();
WindowHandler.Mapper.AppendToMapping(nameof(IWindow), (handler, view) =>
{
var width = 1420;
var height = 838;
var nativeWindow = handler.PlatformView;
nativeWindow.Activate();
var hWnd = WindowNative.GetWindowHandle(nativeWindow);
var windowId = Win32Interop.GetWindowIdFromWindow(hWnd);
var appWindow = AppWindow.GetFromWindowId(windowId);
var preferencesState = handler.MauiContext.Services.GetService<PreferencesState>();
var themeManager = handler.MauiContext.Services.GetService<IThemeManager>();
// The TitleBar is null when not Windows 11 (well...at least on Windows 10 machines)
if(appWindow.TitleBar is not null)
{
var activeTheme = themeManager[preferencesState.ActiveTheme];
var color = ((Color)activeTheme ["BarBackgroundColor"]).ToWinUiColor();
appWindow.TitleBar.BackgroundColor = color;
appWindow.TitleBar.ButtonBackgroundColor = color; // Changes the background color of the control buttons
// This forces the icon area to refresh as well (so the background color applies without needing to click off of the app then back on)
appWindow.TitleBar.IconShowOptions = IconShowOptions.ShowIconAndSystemMenu;
}
// Size and position the window at the center of the current display
var screenWidth = DeviceDisplay.MainDisplayInfo.Width;
var screenHeight = DeviceDisplay.MainDisplayInfo.Height;
appWindow.MoveAndResize(
new Windows.Graphics.RectInt32(
// Move the top left corner so that the window is centered on the main display
(int)(screenWidth / 2 - width / 2),
(int)(screenHeight / 2 - height / 2),
// Set the height and width
width, height
)
);
});
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
Below is more information about how the app is setup, but it can be skipped if the above makes sense. I will have a code sample in GitHub shortly (I will be remaking it on my stream today). I'll update the question with the repo once I am finished.
The UI portion of the application--a Razor Class Library (RCL)--has a theme switcher with multiple themes (not just light and dark). I have a shared (Dependency Injected) Preferences object that holds the value of the current theme. It implements INotifyPropertyChanged so changes to the currently selected theme can be watched.
In the RCL, the CSS contains a :root
element with all of the CSS variables used for coloring the elements, along with separate rules to change the values of the CSS variables based on the class on the body
element. When the theme changes, a class is added to the body
element, so those rules are applied and the color/styling changes. I think this is a pretty standard approach for theming in a web application, although there could be smarter approaches (swapping out a CSS file in the header, for example).
For the Maui application: I have separate Fonts.xaml
, Colors.xaml
, and Styles.xaml
resource dictionaries that are merged in App.xaml
. I have the themes defined as classes that implement ITheme
, which just lists the properties required for a theme class, along with an enum that has an extension method for getting the associated ITheme
implementation. The enum is the same on that is used for controling the theme switching in the Blazor components. I also have one ThemeResourceDictionary.xaml
that will be the ResourceDictionary wrapper for a given ITheme:
ThemeResourceDictionary.xaml.cs
:
public partial class ThemeResourceDictionary : ResourceDictionary, ITheme
{
public ThemeResourceDictionary(ITheme theme)
{
var myProps = GetType().GetProperties().Where(x => x.PropertyType == typeof(Color));
foreach (var curProp in theme.GetType().GetProperties().Where(x => x.PropertyType == typeof(Color)))
{
Add(curProp.Name, curProp.GetValue(theme));
var myProp = myProps.Single(x => x.Name == curProp.Name);
myProp.SetValue(this, curProp.GetValue(theme));
}
InitializeComponent();
}
public Color PageBackgroundColor { get; private set; }
public Color PrimaryTextColor { get; private set; }
public Color BarBackgroundColor { get; private set; }
public Color BarTextColor { get; private set; }
public Color ButtonBackgroundColor { get; private set; }
}
I also have a ThemeManager.cs
that is dependency injected that just keeps track of all of the themes that have been added (the add is shown in the App.xaml.cs
below.) In the main App.xaml
file, I removed all of the contents and populated the merged dictionaries in the code-behind.
App.xaml.cs
public partial class App : Application, IDisposable
{
private readonly IThemeManager _themeManager;
private readonly PreferencesState _preferencesState;
public App(IThemeManager themeManager, PreferencesState preferencesState)
{
_themeManager = themeManager;
_preferencesState = preferencesState;
// Add the colors first
Resources.MergedDictionaries.Add(themeManager.Colors);
// Then the active theme
ApplyTheme(_preferencesState.ActiveTheme);
// Then the styles to make sure all of the resources are available by this time
Resources.MergedDictionaries.Add(new Resources.Styles.Styles());
InitializeComponent();
MainPage = new MainPage();
_preferencesState.PropertyChanged += HandlePreferenceChanged;
}
private void HandlePreferenceChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(PreferencesState.ActiveTheme))
{
ApplyTheme(_preferencesState.ActiveTheme);
}
}
protected override Window CreateWindow(IActivationState activationState)
{
var window = base.CreateWindow(activationState);
window.Title = "Theming Example";
return window;
}
private void ApplyTheme(ColorTheme theme)
{
var resDict = Current.Resources.MergedDictionaries;
var curTheme = resDict.GetActiveThemeResource();
if (curTheme is not null && theme.Is(curTheme))
{
// The theme is not changing
return;
}
// Remove the current theme and add the new theme resources
if (curTheme is not null) { resDict.Remove(curTheme); }
var updateTheme = _themeManager[theme];
resDict.Add(updateTheme);
#if WINDOWS
// Apply the colors to the app window for Windows
WindowHelpers.ApplyColorTheme(updateTheme);
#endif
}
public void Dispose()
{
_preferencesState.PropertyChanged -= HandlePreferenceChanged;
}
}
The WindowHelpers is specific to Windows Platform and will perform the same logic, that is: get the AppWindow
and change the TitleBar.BackgroundColor
and TitleBar.ButtonBackgroundColor
to the active theme dictionary (from the ThemeManager) if TitleBar
is not null.