In this note, we will look at how to switch between light and dark themes in a WinUI app.
In theory, there is a property called RequestedTheme
that should handle switching between themes. However, it seems something isn’t working as expected. This property only changes the theme of the window’s content, but it doesn’t affect the title bar (window header).
To solve this problem, you can use the DwmSetWindowAttribute
function. As I mentioned earlier, the easiest way to use it is with the CsWin32 library. This library helps simplify working with Windows APIs in C#. Here’s how you can do it:
-
Add CsWin32 to your project:
Install the CsWin32 NuGet package to generate P/Invoke methods for Windows APIs.
-
Generate the necessary bindings:
Create a NativeMethods.txt file in your project and add
DwmSetWindowAttribute
to it. This tellsCsWin32
to generate the required method. -
Use
DwmSetWindowAttribute
to set the title bar theme:Call the function to apply the dark or light theme to the window title bar.
Here’s an example of how you might implement this:
private unsafe void UpdateTheme(bool isDarkTheme)
{
var hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this));
var isDark = isDarkTheme ? 1 : 0;
// Set the theme for the title bar
var result = PInvoke.DwmSetWindowAttribute(
hwnd: hwnd,
dwAttribute: DWMWINDOWATTRIBUTE.DWMWA_USE_IMMERSIVE_DARK_MODE,
pvAttribute: Unsafe.AsPointer(ref isDark),
cbAttribute: sizeof(int));
if (result != 0)
{
throw Marshal.GetExceptionForHR(result) ?? throw new ApplicationException("Can't switch dark mode setting");
}
// Set the theme for the content
if (Content is FrameworkElement element)
{
element.RequestedTheme = isDarkTheme ? ElementTheme.Dark : ElementTheme.Light;
}
}
In this way, you can set the theme manually. But what if we want the app to use system settings?
First, it’s worth noting that the system has two theme settings: one for the system (taskbar, start menu) and one for apps. In general, users change the theme for everything. However, they also have the option to mix and match — for example, making the taskbar dark while keeping apps light. Judging by the visual bugs in the current version of Windows, even the system developers seem to have forgotten about this.
I couldn’t find officially documented APIs for determining the theme (maybe I didn’t search well enough). However, I managed to find out that there are two functions in uxtheme.dll
that return the system and app themes. Here’s how you can declare them:
[DllImport("UxTheme.dll", EntryPoint = "#132", SetLastError = true)]
static extern bool ShouldAppsUseDarkMode();
[DllImport("UxTheme.dll", EntryPoint = "#138", SetLastError = true)]
static extern bool ShouldSystemUseDarkMode();
The system theme is useful when you need to interact with the taskbar. For example, to add a non-standard context menu for a tray icon or when writing an app like my flowOSD.
There are many ways to track changes in the system and app themes. For my own use, I chose the following solution: handle the WM_WININICHANGE
message and request data using the functions mentioned above.
For this, I wrote a small class that uses IObservable
to broadcast the current system and app themes:
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Runtime.InteropServices;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
public class SystemEventsService : IDisposable
{
private const uint WM_WININICHANGE = 0x001A;
private NativeWindow window;
private readonly BehaviorSubject<bool> appDarkThemeSubject, systemDarkThemeSubject;
public SystemEventsService()
{
window = new NativeWindow(this);
appDarkThemeSubject = new BehaviorSubject<bool>(ShouldAppsUseDarkMode());
systemDarkThemeSubject = new BehaviorSubject<bool>(ShouldSystemUseDarkMode());
AppDarkTheme = appDarkThemeSubject.AsObservable();
SystemDarkTheme = systemDarkThemeSubject.AsObservable();
}
public IObservable<bool> AppDarkTheme { get; }
public IObservable<bool> SystemDarkTheme { get; }
public void Dispose()
{
window.Dispose();
}
private void ProcessMessage(uint msg, WPARAM wParam, LPARAM lParam)
{
if (msg == WM_WININICHANGE && Marshal.PtrToStringAuto(lParam) == "ImmersiveColorSet")
{
appDarkThemeSubject.OnNext(ShouldAppsUseDarkMode());
systemDarkThemeSubject.OnNext(ShouldSystemUseDarkMode());
return;
}
}
[DllImport("UxTheme.dll", EntryPoint = "#132", SetLastError = true)]
static extern bool ShouldAppsUseDarkMode();
[DllImport("UxTheme.dll", EntryPoint = "#138", SetLastError = true)]
static extern bool ShouldSystemUseDarkMode();
private sealed class NativeWindow : IDisposable
{
private readonly string windowId;
private SystemEventsService owner;
private WNDPROC proc;
public unsafe NativeWindow(SystemEventsService owner)
{
this.owner = owner ?? throw new ArgumentNullException(nameof(owner));
windowId = $"class:com.albertakhmetov.themesample";
proc = OnWindowMessageReceived;
fixed (char* className = windowId)
{
var classInfo = new WNDCLASSW()
{
lpfnWndProc = proc,
lpszClassName = new PCWSTR(className),
};
PInvoke.RegisterClass(classInfo);
Hwnd = PInvoke.CreateWindowEx(
dwExStyle: 0,
lpClassName: windowId,
lpWindowName: windowId,
dwStyle: 0,
X: 0,
Y: 0,
nWidth: 0,
nHeight: 0,
hWndParent: new HWND(IntPtr.Zero),
hMenu: null,
hInstance: null,
lpParam: null);
}
}
~NativeWindow()
{
Dispose(false);
}
public HWND Hwnd { get; private set; }
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private void Dispose(bool isDisposing)
{
if (Hwnd != HWND.Null)
{
PInvoke.DestroyWindow(hWnd: Hwnd);
Hwnd = HWND.Null;
PInvoke.UnregisterClass(
lpClassName: windowId,
hInstance: null);
}
}
private LRESULT OnWindowMessageReceived(HWND hwnd, uint msg, WPARAM wParam, LPARAM lParam)
{
owner.ProcessMessage(msg, wParam, lParam);
return PInvoke.DefWindowProc(
hWnd: hwnd,
Msg: msg,
wParam: wParam,
lParam: lParam);
}
}
}
The main principle of operation: create an invisible window and process window messages in it. If something relevant is found, request additional data and broadcast it via IObservable
. The theme is controlled by the window message WM_WININICHANGE
if its lParam
is equal to “ImmersiveColorSet”:
msg == WM_WININICHANGE && Marshal.PtrToStringAuto(lParam) == "ImmersiveColorSet"
For successful compilation, you need to add the System.Reactive NuGet package to the project and include the following functions in NativeMethods.txt:
RegisterClass
UnregisterClass
CreateWindowEx
DestroyWindow
DefWindowProc
Let’s look at an example of using this class. Define themeSubject
as a BehaviorSubject
that stores the preferred app theme: dark, light, or system-defined.
The following expression combines the sequences of app settings and system events. If the theme is set to “system-defined,” it will be taken from the SystemEventsService
class. Otherwise, the user’s custom setting will be used. The theme will change when the user updates the setting in the app (themeSubject
) or in the system settings. This solution uses the app theme setting; to use the system theme, replace AppDarkTheme
with SystemDarkTheme
.
themeSubject
.CombineLatest(systemEventsService.AppDarkTheme.Select(x => x))
.ObserveOn(SynchronizationContext.Current!)
.Subscribe(x => UpdateTheme(x.First == Theme.System ? x.Second : (x.First == Theme.Dark)));
The source code available here.