Themes in WinUI 3: ResourceDictionary und Laufzeit-Wechsel (Teil 9)

EchoPlay unterstützt drei Farbpaletten: MidnightLibrary (dunkel), ModernClassic (hell, neutral) und PaperCoffee (warm, cremig). Der Nutzer kann das Theme in den Einstellungen wechseln — sofort, ohne Neustart. Wie das technisch mit ResourceDictionary, einem eigenen ThemeService und dem richtigen Timing funktioniert, zeigt dieser Artikel.

ResourceDictionary: Ressourcen zentral definieren

In WinUI 3 werden Farben, Pinsel, Abstände und Stile als Ressourcen definiert. Eine ResourceDictionary ist eine Sammlung solcher Ressourcen in einer .xaml-Datei. Jeder Control in der Anwendung kann über seinen Schlüssel auf diese Ressourcen zugreifen.

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Color x:Key="AppBackgroundColor">#1A1A2E</Color>
    <Color x:Key="AppAccentColor">#7C4DFF</Color>

    <SolidColorBrush x:Key="AppBackgroundBrush" Color="{StaticResource AppBackgroundColor}" />
    <SolidColorBrush x:Key="AppAccentBrush" Color="{StaticResource AppAccentColor}" />
</ResourceDictionary>

Im XAML der Anwendung greifst du dann einfach über den Schlüssel darauf zu:

<Grid Background="{ThemeResource AppBackgroundBrush}" />

MergedDictionaries: Mehrere Paletten zusammenführen

App.xaml lädt alle drei Theme-Dateien über MergedDictionaries:

<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="Themes/MidnightLibrary.xaml" />
            <ResourceDictionary Source="Themes/ModernClassic.xaml" />
            <ResourceDictionary Source="Themes/PaperCoffee.xaml" />
        </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
</Application.Resources>

Die Reihenfolge ist entscheidend: Bei gleichnamigen Schlüsseln gewinnt das zuletzt eingefügte Dictionary. Wenn alle drei geladen sind, wäre immer PaperCoffee aktiv, weil es zuletzt steht. Der ThemeService löst das, indem er beim Start alle drei Dictionaries entfernt und nur das gewünschte neu hinzufügt.

ThemeService: Austausch zur Laufzeit

private void LoadAndApplyTheme(string themeName)
{
    ResourceDictionary dict = new()
    {
        Source = new($"ms-appx:///Themes/{themeName}.xaml")
    };

    Application.Current.Resources.MergedDictionaries.Add(dict);
    _activeThemeDictionary = dict;
    _activeThemeName = themeName;
}

ms-appx:/// ist das URI-Schema für Ressourcen im App-Paket. Ohne dieses Präfix würde WinUI nicht wissen, wo die Datei liegt. ms-appx:///Themes/MidnightLibrary.xaml zeigt auf EchoPlay.App/Themes/MidnightLibrary.xaml.

Beim Theme-Wechsel wird das alte Dictionary entfernt und das neue hinzugefügt:

public void ApplyTheme(string themeName)
{
    if (!KnownThemes.Contains(themeName))
    {
        return; // Unbekannte Namen ignorieren – kein Exception-Crash
    }

    if (_activeThemeName == themeName)
    {
        return; // Schon aktiv – nichts tun
    }

    if (_activeThemeDictionary is not null)
    {
        Application.Current.Resources.MergedDictionaries.Remove(_activeThemeDictionary);
        _activeThemeDictionary = null;
    }

    LoadAndApplyTheme(themeName);

    // Fire-and-Forget: Persistenz soll den UI-Thread nicht blockieren
    _ = PersistThemeAsync(themeName);
}

Fire-and-Forget: Persistenz im Hintergrund

Das Speichern des ausgewählten Themes in der Datenbank ist eine asynchrone Operation. Der Theme-Wechsel selbst soll aber sofort sichtbar sein, ohne auf den Datenbankschreibvorgang warten zu müssen. Das _ = ist das bewusste Verwerfen des Task-Objekts — der Code sagt damit: „Ich starte diesen Task, aber ich warte nicht auf sein Ergebnis.“ Fehler werden im PersistThemeAsync gefangen und geloggt, sie werfen keine Exception zurück.

_ = PersistThemeAsync(themeName);

Das ist ein akzeptiertes Muster für Hintergrundoperationen, die nicht kritisch für die aktuelle Benutzeraktion sind. Ein Theme-Wechsel, der 300 ms auf die Datenbank wartet, fühlt sich träge an. Fire-and-Forget löst das. Aber Vorsicht: Bei kritischen Operationen wie dem Import einer Serie oder dem Speichern eines Abspielstands sollte nie Fire-and-Forget verwendet werden — dort muss auf Fehler reagiert werden.

Whitelist gegen Injection

private static readonly IReadOnlyList<string> KnownThemes =
    ["MidnightLibrary", "ModernClassic", "PaperCoffee"];

if (!KnownThemes.Contains(themeName))
{
    _logger.Warning($"Unbekanntes Theme ignoriert: {themeName}");
    return;
}

Wenn der Theme-Name aus der Datenbank kommt, könnte er durch eine alte Datenbankversion einen inzwischen gelöschten Theme-Namen enthalten. Ohne Whitelist-Prüfung würde new Uri("ms-appx:///Themes/OldTheme.xaml") eine Uri-Exception werfen oder eine Resource-Datei laden, die nicht existiert. Die Whitelist verhindert das — jeder unbekannte Name wird mit einem Logging-Eintrag verworfen, und das Fallback-Theme wird beibehalten.

Initialisierung vor dem ersten Rendern

// App.xaml.cs – in OnLaunched
ThemeService themeService = Services.GetRequiredService<ThemeService>();
await themeService.InitializeAsync();

// Erst danach das Fenster öffnen
MainWindow = _window = new MainWindow();
_window.Activate();

Das Theme muss gesetzt sein, bevor das Fenster gerendert wird. Wenn das Theme erst nach dem Öffnen des Fensters gesetzt wird, sieht der Nutzer für einen kurzen Moment das Standardtheme, bevor das richtige eingeblendet wird — ein unangenehmes Aufblitzen. InitializeAsync entfernt zuerst alle vorgeladenen Theme-Dictionaries und lädt dann nur das in AppSettings gespeicherte:

RemoveAllThemeDictionaries(); // Alle drei aus App.xaml entfernen
LoadAndApplyTheme(themeName); // Nur das aktive laden

ThemeResource vs. StaticResource

WinUI 3 unterscheidet zwischen {ThemeResource} und {StaticResource}. StaticResource wird einmalig beim Laden aufgelöst — wechselt das Dictionary, bleibt der alte Wert. ThemeResource wird bei Ressourcenänderungen neu aufgelöst und ist damit notwendig für dynamische Theme-Wechsel. EchoPlay nutzt {ThemeResource} in allen Views, die auf Theme-Farben zugreifen — damit der Wechsel von MidnightLibrary zu PaperCoffee sofort sichtbar ist, ohne die App neu zu starten.

WinUI 3-Systemressourcen überschreiben

Custom-Brush-Keys wie AppBackgroundBrush reichen nicht aus, um das gesamte Erscheinungsbild der App zu steuern. WinUI 3 verwendet intern eigene Ressourcenschlüssel für eingebaute Controls — zum Beispiel NavigationViewDefaultPaneBackground für den Hintergrund der Navigationsleiste oder CardBackgroundFillColorDefaultBrush für Card-Controls. Solange diese internen Schlüssel nicht überschrieben werden, zeigt WinUI 3 für diese Bereiche seine eigenen Standard-Farben — unabhängig davon, welches Theme die App gesetzt hat.

Die Lösung ist, die WinUI-3-eigenen Ressourcenschlüssel im eigenen Theme-Dictionary mit den passenden Werten zu überschreiben:

<ResourceDictionary x:Key="Dark">
    <SolidColorBrush x:Key="ApplicationPageBackgroundThemeBrush" Color="#12141D"/>
    <SolidColorBrush x:Key="NavigationViewDefaultPaneBackground" Color="#1B1E2B"/>
    <SolidColorBrush x:Key="NavigationViewExpandedPaneBackground" Color="#1B1E2B"/>
    <SolidColorBrush x:Key="NavigationViewContentBackground" Color="#12141D"/>
    <SolidColorBrush x:Key="CardBackgroundFillColorDefaultBrush" Color="#1B1E2B"/>
    <SolidColorBrush x:Key="TextFillColorPrimaryBrush" Color="#E2E4F3"/>
    <SolidColorBrush x:Key="AccentFillColorDefaultBrush" Color="#8E94F2"/>

    <SolidColorBrush x:Key="AppBackgroundBrush" Color="#12141D"/>
    ...
</ResourceDictionary>

Weil die Theme-Dictionary-Einträge nach dem WinUI-3-eigenen Ressourcen-Lookup kommen, haben eigene Einträge Vorrang — das ist das normale Override-Verhalten von MergedDictionaries. Welche Schlüssel überhaupt existieren, lässt sich in der offiziellen WinUI-3-Dokumentation und im öffentlichen GitHub-Repository des WinUI-Teams nachschlagen.

Für EchoPlay wurden folgende Schlüsselgruppen überschrieben: ApplicationPageBackgroundThemeBrush steuert den Seitenhintergrund — das, was außerhalb aller Controls sichtbar ist. NavigationViewDefaultPaneBackground und NavigationViewExpandedPaneBackground steuern den Hintergrund der Navigationsleiste links. CardBackgroundFillColorDefaultBrush und CardBackgroundFillColorSecondaryBrush betreffen Card-Controls und andere erhöhte Oberflächen. TextFillColorPrimaryBrush, TextFillColorSecondaryBrush und die weiteren Text-Varianten steuern Standard-Textfarben in WinUI-Controls. AccentFillColorDefaultBrush und seine Varianten steuern Hervorhebungsfarben — Checkboxen, ToggleSwitches, aktive Tabs.

Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu