概要
Application.ThemeMode は実行中にも書き換えられるため、設定画面にライトとダークの切り替えを置く実装に使える。
ただし、コントロールの色とスタイルについて書き換えが届くのは、Application.Resources に置かれた Fluent のリソースディクショナリだけである。
その辞書を経由せずに色を決めている部分は、切り替えても Light のまま残る。
例外は発生せず、バインディングエラーも出力されないため、どこが追従していないのかを画面から探すことになる。
実測すると、追従しない原因は 3 系統に分かれた。
ウィンドウ側が自前の Fluent 辞書を持っている場合、Fluent 辞書を別の辞書の中にネストしている場合、ブラシを StaticResource で固定している場合である。
本記事では、ThemeMode の切り替えが何を差し替えているかを実測で示し、そこから原因を絞り込む手順と、原因ごとの対処を整理する。
前提・対象環境
- フレームワーク: .NET 9 以降の WPF(
net9.0-windows以降。ThemeModeは .NET 9 で追加された) - 対象 API:
Application.ThemeMode、Window.ThemeMode、Fluent テーマのリソースディクショナリ - アーキテクチャ: MVVM・コードビハインドのいずれでも同じ(切り替え処理はコードから行う)
- 検証環境: .NET 10.0.10 / Windows 11(OS の「アプリのモード」はダーク)
- ビルドの確認: .NET SDK 10.0.302 で新規作成した WPF プロジェクト
- 計測方法: アプリ全体を Light にした状態でウィンドウを開き、
Application.ThemeModeを Dark へ切り替えた。切り替えの前後で、DynamicResourceで参照したブラシの値、Application.Resourcesのマージ辞書の並び、Foregroundのローカル値の型を読み出した。この計測はtools/screenshot-captureのシーンとして実装している
ThemeMode は実験的 API として公開されており、.NET 9 の WPF における変更点が述べるとおり、コードから ThemeMode プロパティにアクセスするとエラー WPF0001 になる。
上記のプロジェクトで確かめたところ、App.xaml の ThemeMode="Dark" 属性だけではエラーにならず、コードビハインドで this.ThemeMode に代入した時点で error WPF0001 となった。
実行時の切り替えは必ずコードから行うため、このエラーの抑制は避けられない。
属性は ThemeMode 構造体にも付いており(ThemeMode 構造体)、ViewModel に相当する通常のクラスでは ThemeMode.Dark を参照しただけでエラーになった。
一方、Window の派生クラスの中では、ThemeMode.Dark の参照だけではエラーにならず、プロパティにアクセスした行でエラーになった。
プロジェクトファイルで抑制した場合は、どちらもエラーなくビルドできた。
本記事のコードは該当箇所を #pragma warning disable WPF0001 で囲んでいる。
MVVM のように参照箇所がクラスをまたいで散らばる場合は、プロジェクトファイルに <NoWarn>$(NoWarn);WPF0001</NoWarn> を指定するほうが扱いやすい。
問題
次の XAML は、Fluent のブラシを DynamicResource と StaticResource の両方で参照するテキストと、Fluent のスタイルが当たる標準コントロールを並べたものである。
アプリ全体を Light にした状態で、同じ内容のウィンドウを 2 枚開いておく。
<Border Background="{DynamicResource ApplicationBackgroundBrush}" Padding="20">
<StackPanel>
<TextBlock Text="DynamicResource"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="StaticResource" Margin="0,8,0,0"
Foreground="{StaticResource TextFillColorPrimaryBrush}" />
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
<Button Content="Save" />
<CheckBox Content="Overwrite" Margin="14,0,0,0" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
</Border>
TextFillColorPrimaryBrush と ApplicationBackgroundBrush は、Fluent テーマのリソースディクショナリが定義するキーである。
2 枚目のウィンドウ(SettingsWindow)だけは、表示前に ThemeMode = ThemeMode.Light を指定している。
この状態で、次のコードを実行してアプリ全体を Dark へ切り替える。
#pragma warning disable WPF0001
Application.Current.ThemeMode = ThemeMode.Dark;
#pragma warning restore WPF0001
切り替え後、1 枚目のウィンドウは暗くなるが、StaticResource で参照したテキストだけが暗い背景に沈んで読めなくなる。
2 枚目のウィンドウは、背景もボタンも明るいまま残る。
症状から絞り込めること
切り分けの起点は、Application.ThemeMode を変えたときに何が差し替わるかである。
アプリ全体の ThemeMode を順に設定し、Application.Resources.MergedDictionaries の中身を読み出した結果を次に示す。
ThemeMode が行っているのは、Application.Resources 直下に置いた Fluent.Light.xaml と Fluent.Dark.xaml の入れ替えである。
コントロールの外観やブラシは、この辞書からリソース検索で解決される。
したがって、切り替え後も Light のまま残るのは、入れ替えた辞書より先に別の Fluent 辞書が見つかるか、検索の結果を値として保持し続けているかのどちらかである。
症状の出方から、どちらに当たるかをおおよそ判別できる。
| 症状 | 疑う原因 |
|---|---|
| 特定のウィンドウだけが、背景も標準コントロールもまるごと Light のまま残る | ウィンドウ自身が Fluent 辞書を持っている |
Window.ThemeMode を指定していないウィンドウでも、DynamicResource で参照した Fluent のブラシまで Light の値のまま残る |
Fluent 辞書が別の辞書の中にネストしている |
| 標準コントロールは切り替わるが、自前で色を指定した文字や背景だけが残る | ブラシを StaticResource やコードでの代入で固定している |
切り分けの手順
判別を確定させるには、切り替えた直後に 3 か所を読み出す。
次のメソッドは、各ウィンドウの ThemeMode と Window.Resources の辞書、Application.Resources の辞書をネストまでたどって文字列にし、指定した要素の Foreground がどう設定されているかを最後に加える。
#pragma warning disable WPF0001
static string DumpThemeState(DependencyObject? probe = null)
{
var text = new StringBuilder();
Application app = Application.Current;
text.AppendLine($"Application.ThemeMode = {app.ThemeMode.Value}");
AppendDictionaries(text, app.Resources, "App");
foreach (Window window in app.Windows)
{
text.AppendLine($"[{window.Title}] Window.ThemeMode = {window.ThemeMode.Value}");
AppendDictionaries(text, window.Resources, $"[{window.Title}]");
}
if (probe is not null)
{
// DynamicResource なら ResourceReferenceExpression、値を固定していれば SolidColorBrush。
object local = probe.ReadLocalValue(TextBlock.ForegroundProperty);
string kind = local == DependencyProperty.UnsetValue ? "(no local value)" : local.GetType().Name;
text.AppendLine($"Foreground local value = {kind}");
}
return text.ToString();
}
static void AppendDictionaries(StringBuilder text, ResourceDictionary dictionary, string prefix)
{
foreach (ResourceDictionary merged in dictionary.MergedDictionaries)
{
text.AppendLine($"{prefix} > {merged.Source?.OriginalString ?? "(no Source)"}");
AppendDictionaries(text, merged, prefix + " >");
}
}
#pragma warning restore WPF0001
StringBuilder は System.Text、TextBlock は System.Windows.Controls、Debug は System.Diagnostics、それ以外の型は System.Windows にある。
TextBlock.ForegroundProperty は Control.ForegroundProperty と同じ依存関係プロパティなので、Button などを渡しても例外にはならない。
ただし、Fluent のスタイルから色が決まる標準コントロールでは (no local value) となり(後述の参照方法の表の最終行)、参照方法の判別には使えない。
戻り値は Debug.WriteLine(DumpThemeState(element)) のように出力して確認する。
Styles.xaml の中に Fluent.Light.xaml をネストし、SettingsWindow に Window.ThemeMode="Light" を指定した状態で Dark へ切り替え、MainWindow の StaticResource のテキストを渡した出力を次に示す。
3 系統の原因がすべて出力に現れている。
出力の読み方は次のとおりである。
[ウィンドウ名] Window.ThemeModeがNone以外と出るウィンドウ(この例ではLight)は、ウィンドウ自身が Fluent 辞書を持っている。直後の[ウィンドウ名] >の行に、その辞書が出る。App > >のように>が 2 つ以上続く行にFluent.を含む URI が出る場合は、Fluent 辞書がネストしている。Foreground local value = SolidColorBrushであれば、その要素はブラシの値そのものを保持しており、切り替えに追従しない。ResourceReferenceExpressionであればDynamicResourceで参照できている。ただしこの型名は WPF の内部実装の型であり、公開された契約ではない。本記事の値は検証環境(.NET 10)で読み出したものである。(no local value)は、値が Style・テンプレート・継承・既定値など、ローカル値以外で決まっていることを示す。
原因別の対処
各ウィンドウ構成で Application.ThemeMode を Light から Dark へ切り替え、DynamicResource で参照したブラシが追従したかを測った結果を次に示す。
ブラシの値は Light で #E4000000、Dark で #FFFFFFFF となる。
ウィンドウ自身が Fluent 辞書を持っている
Window.ThemeMode を設定すると、そのウィンドウの Resources に Fluent 辞書が読み込まれる(Window.ThemeMode の解説)。
リソース検索は要素に近いところから進むため、ウィンドウの辞書が見つかった時点でアプリ側の辞書までは届かない。
アプリ全体を Dark にしても、Window.ThemeMode="Light" のウィンドウが明るいまま残るのはこのためである。
Window.Resources に Fluent.Light.xaml を手でマージした場合も同じ結果になり、Window.ThemeMode は Light と読めた。
コード上で ThemeMode を設定していないウィンドウでも、この値は確認する価値がある。
対処は、ウィンドウ単位の指定をやめてアプリ側の設定に任せることである。
ウィンドウ単位の指定は、実行時に Window.ThemeMode = ThemeMode.None とすれば外れる。
アプリ側が None 以外のときにウィンドウへ None を指定すると、アプリ側の ThemeMode が適用される(ThemeMode.None の解説)。
実測でも、Window.ThemeMode=None のウィンドウは切り替えに追従した。
Fluent 辞書が別の辞書の中にネストしている
Styles.xaml などの独自の辞書の中に Fluent.Light.xaml を入れ、その辞書を App.xaml からマージしている構成では、Dark に切り替えてもブラシが Light の値のまま残った。
切り替えの前後で Application.Resources のマージ辞書の並びを読み出すと、原因が分かる。
ThemeMode が入れ替えるのは Application.Resources の直下に並ぶ Fluent 辞書であり、別の辞書の中にネストした Fluent 辞書は対象にならない。
この構成では、ThemeMode の辞書は既存の Styles.xaml より前(先頭)に入り、Styles.xaml の中の Fluent.Light.xaml はその後ろに残った。
MergedDictionaries は後に追加された辞書から先に検索されるため(ResourceDictionary.MergedDictionaries の解説)、後ろにある Styles.xaml の Light のブラシが先に見つかり続ける。
対処は、ネストした辞書から Fluent の参照を取り除き、Fluent 辞書の管理を ThemeMode に一本化することである。
同じ Fluent.Light.xaml でも、Application.Resources 直下へ手でマージした場合は、ThemeMode がその辞書を Fluent.Dark.xaml に置き換え、ブラシも追従した。
ただし Application.ThemeMode の解説は、Fluent 辞書を手でマージしないよう勧めており、手でマージした辞書は ThemeMode の辞書より優先されるとも述べている。
今回の環境で置き換えられた結果を根拠に、手でマージする構成を選ぶ理由は無い。
ThemeMode を使う場合は、Fluent 辞書を手でマージしない構成が妥当である。
ブラシを StaticResource やコードでの代入で固定している
標準コントロールは切り替わるのに、自前で色を付けた文字や背景だけが残る場合は、ブラシの参照方法が原因として疑わしい。
同じブラシを 3 通りの方法で参照し、切り替えの前後で値を読み出した結果を次に示す。
比較のため、Foreground を指定していない Button を最終行に加えている。
StaticResource は、XAML の読み込み時に見つかったブラシを 1 回だけ代入する。
コードで FindResource の戻り値を代入した場合も同じで、その時点の Light のブラシが値として残る。
辞書が Dark 版に入れ替わっても、要素はすでに受け取ったブラシを持ち続けるため追従しない。
DynamicResource はキーへの参照を保持し、辞書の入れ替えを受けて値を引き直す。
対処は、テーマで変わる色をすべて DynamicResource で参照することである。
コードから設定する場合は、element.SetResourceReference(TextBlock.ForegroundProperty, "TextFillColorPrimaryBrush") のように、値ではなくキーを渡す。
StaticResource と DynamicResource の違いそのものは、WPF で StaticResource を変更しても画面が更新されない原因と解決方法で扱っている。
実装例
ウィンドウ側の固定をまとめて外すには、切り替えの処理を 1 か所に集め、アプリ側の ThemeMode を変えるたびに各ウィンドウの指定も外す。
次の ApplyTheme は、アプリ全体の ThemeMode を設定したうえで、Application.Windows のすべてのウィンドウについて Window.ThemeMode を None に戻し、Window.Resources に手でマージされた Fluent 辞書を取り除く。
#pragma warning disable WPF0001
public static class ThemeSwitcher
{
public static void ApplyTheme(ThemeMode mode)
{
Application app = Application.Current;
app.ThemeMode = mode;
foreach (Window window in app.Windows)
{
// None にすると、アプリ側の ThemeMode が適用される。
window.ThemeMode = ThemeMode.None;
// 手でマージした Fluent 辞書は、アプリ側の辞書より近くにあるため外す。
foreach (ResourceDictionary dictionary in window.Resources.MergedDictionaries
.Where(IsFluentDictionary)
.ToList())
{
window.Resources.MergedDictionaries.Remove(dictionary);
}
}
}
private static bool IsFluentDictionary(ResourceDictionary dictionary) =>
dictionary.Source?.OriginalString.Contains(
"PresentationFramework.Fluent;component/Themes/",
StringComparison.OrdinalIgnoreCase) == true;
}
#pragma warning restore WPF0001
Where と ToList には System.Linq が必要である(ImplicitUsings が有効なら記述不要)。
呼び出し側の ThemeSwitcher.ApplyTheme(ThemeMode.Dark) も ThemeMode 構造体を参照する。
前述のビルド確認では、ViewModel に相当する通常のクラスから呼ぶ場合、呼び出し側でも WPF0001 の抑制が要った。
Application.Windows が列挙するのは、UI スレッドで生成され、まだ閉じていないウィンドウである(Application.Windows の解説)。
このプロパティは Application を生成したスレッドからしか使えないため、ApplyTheme も UI スレッドから呼ぶ。
バックグラウンドの処理から切り替える場合は、Application.Current.Dispatcher.Invoke を通して UI スレッドで実行する。
ApplyTheme が処理するのは、呼び出した時点で開いているウィンドウだけである。
その後に生成したウィンドウは、次に ApplyTheme を呼ぶまで処理されない。
別の UI スレッドで生成したウィンドウは、Application.Windows に含まれないため処理されない。
そうしたウィンドウでは ThemeMode を指定しないことが前提となる。
前節と同じウィンドウ構成で、Application.ThemeMode を直接書き換える代わりにこのメソッドを通した結果を次に示す。
ウィンドウ単位で固定していた 2 つの構成は追従するようになった。
一方、ネストした辞書の構成はこのメソッドでも直らない。
ネストはアプリ全体の辞書構成の問題であり、切り替えの処理で吸収するのではなく、前節のとおり辞書の構成を直す必要がある。
StaticResource で固定したブラシも、このメソッドでは直らない。
注意点
- ウィンドウ単位で意図的に配色を固定しているウィンドウも解除される。
ApplyThemeは例外なくWindow.ThemeModeをNoneに戻す。常に Light で表示したいウィンドウがある場合は、そのウィンドウを除外する条件を加える必要がある。 - アプリ側で
ThemeModeを設定すると、ウィンドウにNoneを指定しても Fluent は外れない。 アプリ側がNone以外のときは、Noneのウィンドウにもアプリ側の Fluent テーマが適用される(ThemeMode.None の解説)。 ThemeModeは実験的 API である。 Application.ThemeMode の解説は、将来のバージョンで削除される可能性があると述べている。切り替え処理をThemeSwitcherのように 1 か所へ集めておくと、API が変わったときの修正範囲を限定できる。ThemeMode.Systemは Windows の設定に応じて Light か Dark を選ぶ(ThemeMode.System の解説)。実測したのはアプリのモードがダークの環境だけであり、ライト側と、実行中に OS の設定を切り替えたときの追従は、OS の設定を変更する必要があるため計測していない。
検証環境で ThemeMode.System を設定した結果を次に示す。
Application.Resources には明暗を固定しない Fluent.xaml がマージされ、ブラシは Dark と同じ値になった。
まとめ
Application.ThemeMode の切り替えは、Application.Resources 直下の Fluent 辞書を Light 版から Dark 版へ入れ替える操作である。
追従しない部分の原因は、DumpThemeState のように Window.ThemeMode・マージ辞書の構成・Foreground のローカル値の型を読み出せば特定できる。
ウィンドウが Fluent 辞書を持っている場合は、Window.ThemeMode を None に戻して辞書を外す処理を、切り替えと同じ場所に置く。
Fluent 辞書がネストしている場合は、辞書の構成を直して ThemeMode に管理を一本化する。
ブラシを固定している場合は、DynamicResource または SetResourceReference に置き換える。
実行時の切り替えを前提とするアプリでは、ThemeMode をアプリ側だけに設定し、Fluent 辞書を手でマージせず、テーマで変わる色を DynamicResource で参照する構成を既定とするのが妥当である。