Windows 應用程式的深色模式與對比佈景主題支援 ── DWM 的深色標題列、WinForms/WPF 跟隨系統佈景主題、高對比時的繪製

· 更新日期: · · 深色模式, 對比佈景主題, 高對比, DWM, WinForms, WPF, Windows 11, 無障礙, 商務應用程式, Windows

「公司的 PC 換成 Windows 11 之後,使用深色模式的同仁說『只有我們的商務應用程式標題列一片慘白,看得眼睛痛』」「弱視的同仁啟用對比佈景主題後,接單畫面的狀態顯示不見了」── 這兩種症狀在最近一兩年的諮詢中都在增加。

前者起因於 Windows 11「色彩」的設定,後者起因於「協助工具」的設定,但在開發者眼中它們是同一個問題:「沒能跟隨佈景主題」。而且實際上,對策的基礎是共通的。不要把色彩硬式編碼,讀取系統的設定,察覺變更,重新上色。就這三點。

上一篇「Windows 應用程式無障礙入門」談的是螢幕閱讀器讀取的機制(UI Automation)以及命名、鍵盤、色彩的基礎。其中提到了跟隨對比佈景主題,但沒有處理淺色/深色這個色彩模式本身。本文作為它的姊妹篇,依照 DWM(桌面視窗管理員)繪製標題列、WinForms/WPF 跟隨系統佈景主題、對比佈景主題時的繪製這個順序,在實作層面把色彩模式(淺色/深色)與對比佈景主題這兩條軸串起來。目標讀者是以 WinForms/WPF/Win32 開發與維護商務應用程式的開發者,前提環境是 Windows 11(深色標題列需要組建 22000 以上)與 .NET 9/10 的 WinForms、WPF(.NET Framework 4.8 與 .NET 8 需要自行實作一部分),難度為中級

本文的脈絡從整理佈景主題的兩條軸開始,依序串起預設變成淺色的原因、DWM 的深色標題列、偵測與跟隨的機制、WinForms 與 WPF 的實作、對比佈景主題時的繪製、方針的決定方式與驗證的本文結構整理佈景主題的兩條軸預設變成淺色的原因DWM 的深色標題列偵測與跟隨的機制WinForms/WPF 的實作對比佈景主題時的繪製方針的決定與驗證

圖 1: 本文把佈景主題的整理、機制、實作、對比佈景主題、驗證串成一條線。

1. 先講結論

  • Windows 的「佈景主題」有兩條軸。一條是「設定 > 個人化 > 色彩」的淺色/深色(色彩模式),另一條是「設定 > 協助工具 > 對比佈景主題」。後者是被限制在大約 7:1 以上對比度的調色盤,與淺色/深色是兩回事。在對比佈景主題生效期間無法使用深色模式。判斷的優先順序是「對比佈景主題 → 淺色/深色」。12
  • 既有應用程式的標題列仍是白的,是基於相容性的預設行為。Windows 沒有辦法知道應用程式是否支援深色,因此把所有視窗預設當成淺色處理。3
  • 把標題列變成深色靠的是 DwmSetWindowAttributeDWMWA_USE_IMMERSIVE_DARK_MODE(值 20)。傳入 BOOL 的 TRUE 之後,系統處於深色時視窗框架就會以深色繪製。文件記載的支援範圍是 Windows 11 組建 22000 以上。43
  • 目前的模式用 UISettings.GetColorValue 讀取,變更用 ColorValuesChanged 接收。前景色(預設的文字色)若偏亮就判定為深色,這是 Microsoft 的官方做法。事件不保證在 UI 執行緒上送達,所以要先切回 UI 執行緒再重新上色。35
  • WinForms 在 .NET 9 加入了 Application.SetColorMode,到 .NET 10 不再是實驗性的。Application.Run 之前呼叫 SystemColorMode.System。它有三條限制:僅限 Windows 11、對比佈景主題下無效、執行期間的設定變更不會被跟隨。672
  • WPF 在 .NET 9 加入了 Fluent 佈景主題與 ThemeModeThemeMode="System" 即可跟隨,視窗的深色化也一併被控制。不過從程式碼進行的操作在 .NET 10 中仍是實驗性的(WPF0001),Fluent 樣式仍「在進行中」。若維持傳統佈景主題,就用 DynamicResource 抽換淺色/深色的 ResourceDictionary。8910
  • 處於對比佈景主題時,要把色彩對應到系統色彩正確的配對上,省去文字背後的圖片,並把多色的圖形以前景與背景兩色繪製。判斷用 SPI_GETHIGHCONTRAST(WinForms 是 SystemInformation.HighContrast,WPF 是 SystemParameters.HighContrast),通知是 WM_SYSCOLORCHANGE / WM_THEMECHANGED(在 .NET 中是 SystemEvents.UserPreferenceChanged)。111213
  • 支援深色模式無法取代無障礙支援。深色的調色盤同樣需要 4.5:1 的對比度,不只依賴色彩的傳達方式在任何佈景主題下都必要。1415

用一句話總結:所謂佈景主題支援,就是「把色彩集中到一處,讀取系統的設定,察覺變更並重新上色;但在對比佈景主題下要把配色全面交給系統色彩的配對」

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 28 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

2. 「佈景主題」有兩條軸 ── 淺色/深色與對比佈景主題

2.1. 淺色/深色(色彩模式)

Windows「設定 > 個人化 > 色彩」中的色彩模式,是決定作業系統與整個應用程式的前景色與背景色明暗關係的設定。Microsoft 的文件把淺色定義為「亮背景配暗前景」,把深色定義為「暗背景配亮前景」,並補充說這裡的前景指的是「預設的文字色」。在深色模式下,前景(文字)變亮,背景變暗。3

這個設定儲存在登錄檔 HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize 底下的 AppsUseLightTheme(應用程式的模式)與 SystemUsesLightTheme(Windows 自身的模式)這兩個 DWORD 值中,Microsoft 的設定參考文件裡有記載。16 不過如後文所述,應用程式讀取時的正規途徑是 WinRT 的 UISettings

2.2. 對比佈景主題(高對比)

在「設定 > 協助工具 > 對比佈景主題」中選擇的對比佈景主題,使用的是被限制到大約 7:1 以上對比度的調色盤,對象是強烈需要前景與背景在視覺上分離的使用者。Windows 11 內建了 Aquatic、Desert、Dusk、Night sky 四種,使用者不只能從中挑選,還可以分別編輯背景、文字、超連結、停用文字、選取文字與按鈕的色彩。用左 Alt+左 Shift+PrintScreen 可以快速切換,未選擇時會套用 Aquatic。1

Microsoft 的文件明確寫著「不要把對比佈景主題與淺色/深色佈景主題混為一談」。淺色/深色使用的是寬廣的調色盤,並不是為最大對比度而最佳化的。1 而且重要的是,在對比佈景主題生效期間無法使用深色模式。WinForms 的 Application.SetColorMode 在對比佈景主題下不提供深色,XAML 的 RequestedTheme 也會被系統覆寫。217

2.3. 判斷的優先順序

因此,應用程式的實作應當依照下面的順序。先判斷是不是對比佈景主題,是的話就把配色全面交給系統色彩。不是的話,再選擇淺色或深色其中一套調色盤。

佈景主題的兩條軸與判斷的優先順序對比佈景主題生效時把配色全面交給系統色彩的配對,未生效時讀取淺色/深色的色彩模式來選擇應用程式的調色盤的判斷順序生效未生效淺色深色對比佈景主題生效嗎?交給系統色彩的配對色彩模式是?淺色的調色盤深色的調色盤無法使用深色模式

圖 2: 把對比佈景主題的判斷放在前面,只有未生效時才選擇淺色/深色的調色盤。

3. 為什麼既有應用程式在深色模式下仍是白的

視窗由兩個區域組成:由標題列、框線、標題按鈕構成的非工作區,以及由應用程式繪製的工作區。從 Windows Vista 起,非工作區由 DWM(桌面視窗管理員)合成繪製,應用程式透過 DwmSetWindowAttribute 指定繪製方式的屬性。18

Microsoft 的文件坦率地說明了既有應用程式仍是白色的理由:「因為 Windows 不知道應用程式能否支援深色模式,基於回溯相容性的理由,它假設其無法支援」。雖然也有 WinUI、Windows App SDK 這類原生處理深色模式的框架,但 Win32 應用程式多半不支援深色模式,所以 Windows 預設給出淺色的標題列。3

視窗的兩個區域與繪製的主體由標題列與框線構成的非工作區由 DWM 繪製,工作區由應用程式或 UI 框架繪製,因此深色支援在兩邊都需要最上層視窗非工作區(標題列・框線)工作區(畫面的內容)由 DWM 合成繪製由應用程式或框架繪製用 DwmSetWindowAttribute 指示應用程式自己的調色盤

圖 3: 標題列由 DWM 繪製、內容由應用程式繪製,所以深色支援既要對 DWM 下指示,也要有應用程式自己的調色盤。

由此得出兩個結論。第一,要把標題列變成深色,必須由應用程式明確地向 DWM 提出請求。第二,提出請求後變成深色的只有標題列,工作區必須自己重新上色。文件也說「要完整支援深色模式,應用程式的整個介面都必須遵循深色佈景主題」,並聲明官方指南只講到偵測與標題列為止,不處理工作區的重新上色。3 只把標題列變黑而內容仍是白色的應用程式,比整體保持白色的應用程式更不自然。

預設變成淺色的機制Windows 不知道應用程式能否支援深色因此基於相容性預設使用淺色,只有應用程式透過 DWM 屬性傳入 TRUE 時才依系統的深色設定繪製視窗框架Windows 不知道能否支援為相容性預設使用淺色應用程式傳入 TRUE 了嗎?永遠是淺色的框架依系統設定繪製

圖 4: 不知道能否支援的 Windows 預設使用淺色,只有收到應用程式的明確指示時才跟隨。

4. DWM 的深色標題列 ── DwmSetWindowAttribute

4.1. DWMWA_USE_IMMERSIVE_DARK_MODE

把標題列變成深色的屬性是 DWMWA_USE_IMMERSIVE_DARK_MODEDWMWINDOWATTRIBUTE 列舉的說明是這樣的:「當深色模式的系統設定啟用時,允許以深色模式的色彩繪製這個視窗的框架。基於相容性的理由,所有視窗不論系統設定為何都預設為淺色模式。pvAttribute 指向一個 BOOL,TRUE 表示尊重深色模式,FALSE 表示永遠是淺色模式。Windows 11 組建 22000 以上支援」4

也就是說,TRUE 並不是「變成深色」,而是「如果系統是深色,可以變成深色」這樣的許可。只要應用程式這邊已經準備好把工作區塗成深色,傳入 TRUE 就能讓標題列跟隨系統設定。反過來說,如果設計上讓應用程式永遠以淺色顯示(也就是後文的「固定淺色」),維持預設的 FALSE 就沒有問題。

官方指南的 C++ 程式碼是下面這樣。為了那些標頭檔中沒有該常數的舊 SDK,其中還包含了自行定義值 20 的步驟。3

#include <dwmapi.h>
#pragma comment(lib, "dwmapi.lib")

#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif

// 是否為 Windows 11(組建 22000)以上。前提是資訊清單中宣告了 Windows 10
// 以上的 supportedOS(沒有的話版本會被截成 Windows 8 等級)
bool IsWindows11OrGreater()
{
    OSVERSIONINFOEXW osvi{ sizeof(osvi) };
    osvi.dwMajorVersion = 10;
    osvi.dwMinorVersion = 0;
    osvi.dwBuildNumber = 22000;
    DWORDLONG mask = 0;
    VER_SET_CONDITION(mask, VER_MAJORVERSION, VER_GREATER_EQUAL);
    VER_SET_CONDITION(mask, VER_MINORVERSION, VER_GREATER_EQUAL);
    VER_SET_CONDITION(mask, VER_BUILDNUMBER, VER_GREATER_EQUAL);
    return ::VerifyVersionInfoW(
        &osvi, VER_MAJORVERSION | VER_MINORVERSION | VER_BUILDNUMBER, mask) != FALSE;
}

// honorDarkMode = true: 系統是深色時,標題列也可以用深色繪製
void ApplyTitleBarTheme(HWND hwnd, bool honorDarkMode)
{
    if (!IsWindows11OrGreater())
    {
        // 文件記載的支援從組建 22000 起。低於它就不呼叫,遵循預設(淺色)
        LogInfo(L"DWMWA_USE_IMMERSIVE_DARK_MODE is not documented for this OS build; keeping the default light frame");
        return;
    }
    BOOL value = honorDarkMode ? TRUE : FALSE;
    HRESULT hr = ::DwmSetWindowAttribute(
        hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &value, sizeof(value));
    if (FAILED(hr))
    {
        // 在受支援的系統上失敗屬於異常。不要默默吞掉,記錄 HRESULT 讓問題浮現
        LogWarning(L"DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE) failed: 0x%08X", hr);
    }
}

順帶說明一下依作業系統版本區分呼叫的理由。文件記載的支援範圍是 Windows 11 組建 22000 以上。4 說同一個值在 Windows 10 上也有效的回報並不少見,但不該讓商務應用程式的顯示依賴未寫進文件的行為。如果設計成「先呼叫,失敗就放棄」,那麼在 Windows 10 上呼叫剛好成功時,標題列就會建立在未寫進文件的行為之上變成深色。低於組建 22000 就不呼叫,遵循「標題列會是淺色」這個有文件記載的預設行為;在受支援的系統上失敗時,把 HRESULT 記進記錄檔讓問題浮現,如此而已。網路上還流傳著使用值 19 的舊做法,或是呼叫 uxtheme.dll 的序數匯出函式把通用控制項變成深色的手法,但它們都是非公開 API,更新後行為改變時沒有人會為你負責。

4.2. 呼叫的時機 ── HWND 存活時,以及每次被重新建立時

DwmSetWindowAttribute 是針對 HWND 呼叫的,因此必須在視窗控制代碼建立之後。而且 WinForms 的表單會因為修改 ShowInTaskbar 之類的操作而重新建立控制代碼。重新建立後的新 HWND 上沒有這個屬性,所以呼叫的位置不是「建構函式」,而是「每次控制代碼建立時都會被呼叫的地方」。WinForms 是 OnHandleCreated,WPF 是 SourceInitialized

呼叫 DwmSetWindowAttribute 的時機在視窗控制代碼建立後設定 DWM 屬性,控制代碼被重新建立時對新的控制代碼再次設定,收到佈景主題變更的通知時也重新設定的流程控制代碼重新建立佈景主題變更的通知HWND 建立設定 DWM 屬性顯示中

圖 5: DWM 屬性繫結在 HWND 上,所以每次建立與每次重新建立都要重新設定。

WinForms 中的 P/Invoke 如下(DllImport 的安全寫法請參閱「在 C# 中安全呼叫 Win32 API」)。

using System.Runtime.InteropServices;

public partial class MainForm : Form
{
    private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;

    [DllImport("dwmapi.dll")]
    private static extern int DwmSetWindowAttribute(
        IntPtr hwnd, int attribute, ref int value, int size);

    protected override void OnHandleCreated(EventArgs e)
    {
        base.OnHandleCreated(e);
        ApplyTitleBarTheme();
    }

    private void ApplyTitleBarTheme()
    {
        // 文件記載的支援從組建 22000 起。低於它就不呼叫,遵循預設(淺色)。
        // 這個判斷在 .NET Framework 上也能用。但在 .NET Framework 上,如果資訊清單中
        // 沒有宣告 Windows 10 以上的 supportedOS,版本會被截成 Windows 8 等級
        //(.NET 5 之後也可以用 OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000))
        if (Environment.OSVersion.Version < new Version(10, 0, 22000))
        {
            _logger.LogInformation("Dark title bar is not documented for this OS build; keeping the default light frame");
            return;
        }
        // 1(TRUE) = 系統是深色時可以用深色繪製。0(FALSE) = 永遠是淺色
        int honorDarkMode = 1;
        int hr = DwmSetWindowAttribute(
            Handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref honorDarkMode, sizeof(int));
        if (hr < 0)
        {
            _logger.LogWarning("DwmSetWindowAttribute failed: 0x{Hr:X8}", hr);
        }
    }
}

需要這段程式碼的是 .NET 8 以前、.NET Framework 以及 Win32/MFC 的應用程式。在 .NET 9 之後的 WinForms 使用 Application.SetColorMode,以及在 .NET 9 之後的 WPF 使用 ThemeMode 時,視窗的深色化由框架代勞(ThemeMode 的文件明確寫著「也控制視窗的背景材質與深色模式的套用」)。10 重複呼叫沒有害處,但會讓責任歸屬變得模糊,所以請只選其中一種。

在 WPF 中,SourceInitialized 的時間點上 HWND 已經確定。用 WindowInteropHelper 取得控制代碼。19

using System.Windows.Interop;

public partial class MainWindow : Window
{
    protected override void OnSourceInitialized(EventArgs e)
    {
        base.OnSourceInitialized(e);
        var hwnd = new WindowInteropHelper(this).Handle;
        TitleBarTheme.Apply(hwnd, honorDarkMode: true); // 內容就是前面的 P/Invoke
    }
}

4.3. 標題列的色彩、文字色、框線色與背景材質

在 Windows 11 中,除了深色/淺色的二選一,還新增了直接指定標題列色彩本身的屬性。

屬性 內容 支援的組建
DWMWA_USE_IMMERSIVE_DARK_MODE 20 系統是深色時把框架繪製成深色(BOOL) 22000
DWMWA_BORDER_COLOR 34 視窗框線的色彩(COLORREF)。用 DWMWA_COLOR_NONE 可以去掉框線 22000
DWMWA_CAPTION_COLOR 35 標題列的色彩(COLORREF) 22000
DWMWA_TEXT_COLOR 36 標題文字的色彩(COLORREF) 22000
DWMWA_SYSTEMBACKDROP_TYPE 38 系統繪製的背景材質(Mica 或 Acrylic) 22621

這三個指定色彩的屬性,傳入 DWMWA_COLOR_DEFAULT(0xFFFFFFFF)就會回到系統預設。關於框線色,請注意文件寫明「隨視窗啟用之類的狀態變化改變色彩是應用程式的責任」。4 背景材質用 DWM_SYSTEMBACKDROP_TYPE 列舉指定,DWMSBT_MAINWINDOW 在 Windows 11 上相當於 Mica,DWMSBT_TRANSIENTWINDOW 相當於 Acrylic,但文件也明確寫著「材質的效果在未來的 Windows 中可能改變」。20

在商務應用程式中要慎重考慮它們的用武之地。把標題列塗成品牌色之後,你就得自己負責保證標題文字與標題按鈕在那個色彩上的對比度。除了深色/淺色兩種狀態,還要再加上作用中/非作用中的組合。對多數商務應用程式來說,正確答案是「遵循系統預設(只把值 20 設為 TRUE)」,品牌色是真正需要時才有的選項。

如何決定標題列的色彩遵循系統預設只需把值 20 設為 TRUE,而塗成品牌色之後保證文字與標題按鈕的對比度以及管理作用中/非作用中的色彩都成為應用程式的責任,要還原時要傳入 DWMWA_COLOR_DEFAULT遵循系統預設塗成品牌色標題列的色彩?只把值 20 設為 TRUE指定色彩屬性(34~36)自己保證文字與按鈕的對比度作用中/非作用中也要自己管理要還原時傳入 COLOR_DEFAULT

圖 6: 選擇品牌色之後對比度與狀態管理的責任就轉移到應用程式這邊,對多數商務應用程式來說遵循預設才是正確答案。

5. 系統佈景主題的偵測與跟隨 ── 讀取、察覺、重新上色

讓工作區跟隨佈景主題的工作可以拆成三件事:讀取目前的設定,察覺變更,然後重新上色

偵測與跟隨的三個步驟啟動時用 UISettings 讀取目前的色彩模式,透過 ColorValuesChanged 等通知察覺變更,切回 UI 執行緒後重新為應用程式的調色盤上色的迴圈讀取: UISettings.GetColorValue重新上色: 重新套用調色盤察覺: ColorValuesChanged切回 UI 執行緒DWM 屬性也重新設定

圖 7: 啟動時讀取、靠通知察覺、切回 UI 執行緒重新上色的迴圈構成了佈景主題跟隨的骨架。

5.1. 讀取 ── UISettings 與「前景偏亮就是深色」

Microsoft 的官方做法使用 WinRT 的 Windows.UI.ViewManagement.UISettings。用 GetColorValue(UIColorType::Foreground) 取得前景色(預設的文字色),以整數運算估算它的感知亮度來判斷「是否偏亮」,前景偏亮就判定為深色模式。文件聲明這個算式不是嚴謹的亮度分析模型,而是足以做明暗分類的近似。321

UISettings 是 WinRT 的類別,但只要把 C# 的 WPF/WinForms 的 TargetFramework 寫成 net8.0-windows10.0.19041.0 這種帶 Windows SDK 版本的形式,就能直接呼叫(原理請參閱「WinRT 就是 COM」)。也可以直接讀登錄檔的 AppsUseLightTheme,但登錄檔是設定的儲存位置而不是 API 合約,所以讀取時應以 UISettings 為準,把登錄檔留給診斷用途。

讀取色彩模式的途徑正規途徑是用 WinRT 的 UISettings 取得前景色來判斷明暗,登錄檔的 AppsUseLightTheme 只是儲存位置,留給診斷用途現在是淺色還是深色UISettings.GetColorValue登錄檔 AppsUseLightTheme判斷前景色的感知亮度偏亮就是深色儲存位置。留給診斷用途

圖 8: 正規的讀取途徑是 UISettings,登錄檔只不過是儲存位置。

5.2. 察覺 ── ColorValuesChanged 不在 UI 執行緒上送達

偵測變更同樣使用 UISettingsColorValuesChanged 事件在色彩的值改變時觸發,官方指南也用這個事件追蹤設定的變更。53 這裡有一個實務上的注意點。這個事件不保證在 UI 執行緒上送達。請先用 WPF 的 Dispatcher、WinForms 的 Control.Invoke,或是兩邊都能用的 SynchronizationContext 切回 UI 執行緒,再去操作控制項。與 UI 執行緒相處的方式整理在「WPF/WinForms 的 UI 執行緒與 async/await」中。

另外,變更輔色時這個事件同樣會觸發。如果只想在淺色/深色改變時重新上色,就在每次事件裡重新判斷,只有與上一次不同時才發出通知。而且依照第 2 章的優先順序,如果對比佈景主題生效,就不能去看前景色的亮度。像 Aquatic 這種暗背景的對比佈景主題前景是亮的,只看亮度會誤判成「深色」。對比佈景主題要當成獨立的狀態先行判斷。

下面這個類別把這個判斷順序集中到了一處。用來切回 UI 執行緒的 SynchronizationContext,以及對比佈景主題的判斷(WinForms 是 SystemInformation.HighContrast,WPF 是 SystemParameters.HighContrast,見第 8 章)都由呼叫端傳入。請注意建立的時機。在 WinForms 中,Program.Main 的時間點上既沒有訊息迴圈也沒有 ControlSynchronizationContext.Currentnull。請在表單的建構函式或 OnLoad 這類控制項已經建立之後的位置傳入 SynchronizationContext.Current。WPF 則可以傳入 new DispatcherSynchronizationContext(Application.Current.Dispatcher)

using Windows.UI.ViewManagement; // TargetFramework: net8.0-windows10.0.19041.0 以上

public enum ThemeState { Light, Dark, HighContrast }

public sealed class SystemThemeWatcher : IDisposable
{
    private readonly UISettings _settings = new();
    private readonly SynchronizationContext _ui;
    private readonly Func<bool> _isHighContrast;

    private bool _disposed;

    public ThemeState Current { get; private set; }
    public event EventHandler? Changed;

    // ui: UI 執行緒的 SynchronizationContext。傳入控制項建立之後的
    //     SynchronizationContext.Current,WPF 則傳入 DispatcherSynchronizationContext
    // isHighContrast: () => SystemInformation.HighContrast(WinForms)
    //                 () => SystemParameters.HighContrast(WPF)
    public SystemThemeWatcher(SynchronizationContext ui, Func<bool> isHighContrast)
    {
        _ui = ui ?? throw new ArgumentNullException(nameof(ui));
        _isHighContrast = isHighContrast ?? throw new ArgumentNullException(nameof(isHighContrast));
        Current = Read();
        _settings.ColorValuesChanged += OnColorValuesChanged;
    }

    private ThemeState Read()
    {
        // 判斷順序如第 2 章所述: 對比佈景主題在先。暗背景的對比佈景主題
        // 前景是亮的,只看亮度會誤判成「深色」
        if (_isHighContrast()) return ThemeState.HighContrast;
        // 與官方指南相同的判斷: 前景(預設的文字色)偏亮就是深色
        var fg = _settings.GetColorValue(UIColorType.Foreground);
        bool isDark = (5 * fg.G + 2 * fg.R + fg.B) > 8 * 128;
        return isDark ? ThemeState.Dark : ThemeState.Light;
    }

    // 從 UI 執行緒呼叫。也可以從 UserPreferenceChanged 等其他途徑的通知中呼叫
    public void Refresh()
    {
        if (_disposed) return;
        var next = Read();
        // 在淺色/深色之間,為了忽略只改輔色之類的變更,狀態相同就不通知。
        // 對比佈景主題中是例外: 使用者編輯佈景主題的色彩後狀態仍然是 HighContrast,
        // 所以狀態相同也要通知,讓程式重新取一次系統色彩
        if (next == Current && next != ThemeState.HighContrast) return;
        Current = next;
        Changed?.Invoke(this, EventArgs.Empty);
    }

    private void OnColorValuesChanged(UISettings sender, object args)
    {
        // 不保證在 UI 執行緒上送達,所以切回 UI 之後再判斷並通知。
        // Dispose 之後才送達(已經排進佇列)的呼叫由 Refresh 這邊的旗標忽略
        _ui.Post(_ => Refresh(), null);
    }

    public void Dispose()
    {
        // 取消訂閱只能停止今後的傳遞,已經丟進 UI 執行緒的呼叫仍然留著。
        // 立起旗標,讓殘留的呼叫自己被忽略(要在 UI 執行緒上呼叫)
        _disposed = true;
        _settings.ColorValuesChanged -= OnColorValuesChanged;
    }
}

在 Win32 層面,設定變更時 WM_SETTINGCHANGE 會被送到所有最上層視窗,22 在 .NET 中它以 SystemEvents.UserPreferenceChanged 的形式送達。23 視覺樣式的切換(包含啟用對比佈景主題)會發出 WM_THEMECHANGED24 系統色彩的變更會發出 WM_SYSCOLORCHANGE25 與其依通知的種類分別寫不同的處理,不如不管來的是哪種通知都呼叫同一個「讀取並重新上色」的處理,這樣比較不容易壞,也與上一篇文章介紹的跟隨對比佈景主題是同一個形態。以上面的 SystemThemeWatcher 來說,就是從 UserPreferenceChangedStaticPropertyChanged 的處理常式裡也呼叫 Refresh()

佈景主題變更的通知途徑與執行緒UISettings 的 ColorValuesChanged 可能在 UI 執行緒以外送達因此要切回 UI,WM_SETTINGCHANGE 以 SystemEvents.UserPreferenceChanged 的形式送達,WM_THEMECHANGED 與 WM_SYSCOLORCHANGE 送到視窗程序。三者都匯集到同一個重新套用的處理ColorValuesChanged切回 UI 執行緒UserPreferenceChanged讀取並重新上色WM_THEMECHANGED 等

圖 9: 通知的途徑有多條,但全部匯集到同一個「讀取並重新上色」的處理。

5.3. 重新上色 ── 把色彩集中到一處

能夠重新上色的前提,是色彩集中在一處。如果 Color.White#FFFFFF 散落在表單和 XAML 的各個角落,就無法列舉出需要重新上色的地方。WinForms 就做一個「調色盤」類別(淺色用與深色用兩個執行個體),控制項在啟動時與收到通知時從那裡取色。WPF 就把色彩集中到 ResourceDictionary,XAML 用 DynamicResource 參照。WinUI 則從一開始就以 ThemeDictionaries 的形式提供了這個結構。

把色彩集中到一處的結構應用程式中各持有一份淺色用與深色用的調色盤,各畫面依目前的模式從選中的調色盤取色,從而能夠列舉出需要重新上色的對象淺色深色目前的模式要用哪一個?淺色的調色盤深色的調色盤各畫面・各控制項散落的 Color.White無法列舉需要重新上色的地方

圖 10: 把調色盤集中放在一處就能列舉重新上色的對象,散落的硬式編碼則做不到。

這個「把色彩集中起來」的工作,在對比佈景主題支援中,以及在後文的對比度檢查中,都會直接派上用場。支援深色模式的最大成本不是 API 呼叫,而是這項整理。

6. WinForms 中的實作

6.1. .NET 9/10 ── Application.SetColorMode

WinForms 在 .NET 9 加入了深色模式的初步支援,在 .NET 10「完全整合」。傳給 Application.SetColorMode 的值有三個。67

  • SystemColorMode.Classic ── 預設。與以往一樣的淺色。
  • SystemColorMode.System ── 遵循 Windows 的淺色/深色設定。
  • SystemColorMode.Dark ── 深色。

呼叫的位置是 Application.Run 之前、建立 UI 元素之前。在 .NET 9 中它是實驗性功能,不在專案檔中隱藏 WFO5001 就會編譯錯誤,從 .NET 10 起不再出現這個錯誤。26

static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();
        Application.SetColorMode(SystemColorMode.System); // 在建立 UI 之前呼叫
        Application.Run(new MainForm());
    }
}

色彩模式改變後 System.Drawing.SystemColors 會切換到對應的色彩,標準控制項也會跟著繪製。6 其機制是:SystemColors.UseAlternativeColorSet 這個實驗性屬性(SYSLIB5002)會「讓系統的 KnownColor 傳回替代的色彩集(目前是深色模式版)」;由於 Win32 的系統色彩本身不隨淺色/深色設定改變,所以是 .NET 這邊持有替代色彩集。同一份文件還寫著,對比佈景主題生效時永遠傳回 Windows 目前的色彩27

請記住 SetColorMode 文件中寫明的三條限制。2

  1. 深色的色彩模式只能在 Windows 11 以上使用。
  2. 對比佈景主題生效時無法使用深色模式。
  3. 即使指定 SystemColorMode.System,執行期間 Windows 的設定改變時應用程式也不會自動跟隨。

第三條在商務應用程式中容易引來詢問。請把「由啟動時的 Windows 設定決定,下次啟動時生效」這個行為寫進給使用者的說明。如果無論如何都想跟隨執行期間的切換,就需要重新建立表單的設計,多數情況下並不划算。

SetColorMode 的流程與限制SetColorMode 要在 Application.Run 之前呼叫,SystemColors 切換到替代色彩集之後標準控制項跟著遵循。它有僅限 Windows 11、對比佈景主題下無效、不跟隨執行期間的設定變更這三條限制SetColorMode(System)Application.Run 之前SystemColors 切到替代色彩集標準控制項跟著遵循僅限 Windows 11對比佈景主題下無效不跟隨執行期間的變更

圖 11: SetColorMode 只在啟動前生效一次,並帶有三條有文件記載的限制。

6.2. 自行繪製的控制項與 ApplyThemingImplicitly

標準控制項會遵循應用程式的色彩模式,但 .NET 10 的文件舉了兩種例外情況。如果在自己組裝、自己繪製的控制項中使用了捲軸之類的 Win32 通用控制項,那麼不明確選擇加入的話它們會維持淺色。反過來說,如果想繼承本來會跟隨佈景主題的既有控制項並完全自己控制繪製,就要選擇退出。7

兩者都要覆寫 Control.CreateParams,並在讀取 base.CreateParams 之前呼叫 SetStyle(ControlStyles.ApplyThemingImplicitly, true/false)。由於基底類別的建構函式會讀取 CreateParams,放在自己的建構函式裡就來不及了 ── 這是這個 API 的陷阱。7

public partial class GanttChartControl : Control
{
    protected override CreateParams CreateParams
    {
        get
        {
            // 要在讀取 base.CreateParams 之前設定。放在建構函式裡已經太晚
            SetStyle(ControlStyles.ApplyThemingImplicitly, true);
            return base.CreateParams;
        }
    }
}
能夠設定 ApplyThemingImplicitly 的時機由於基底類別的建構函式讀取 CreateParams 的時間點 ApplyThemingImplicitly 就已經確定,因此必須在 CreateParams 的覆寫內、base.CreateParams 之前呼叫 SetStyle,放在衍生類別的建構函式裡已經太晚基底建構函式讀取 CreateParams在此之前需要 SetStyle衍生類別的建構函式在這裡呼叫已經太晚

圖 12: ApplyThemingImplicitly 必須在基底建構函式讀取 CreateParams 之前就確定下來。

自行繪製本身(在 OnPaint 中用 GDI+ 繪製)只要使用 SystemColors / SystemBrushes / SystemPens,就會跟隨替代色彩集。用 Color.White 上色的地方,在這裡同樣要換成前面說的調色盤。

6.3. .NET Framework 4.8 與 .NET 8 以前 ── 明確宣告「固定淺色」

在沒有 SetColorMode 的環境裡,深色模式沒有標準支援。選項有兩個。

  1. 明確宣告固定淺色。DWM 屬性維持預設的 FALSE(永遠是淺色的標題列),工作區也一如既往。只有對比佈景主題支援(第 8 章)必須做到。
  2. 自己做全面支援。集中調色盤,用 UISettings 讀取並跟隨,把 DWM 屬性設為 TRUE,連通用控制項的外觀在內全部重新上色。

第 2 條容易變成「幾乎全黑但個別地方仍是淺色」的狀態,因為 Win32 通用控制項(捲軸、標頭、樹狀結構的展開按鈕等)的繪製應用程式無法完全控制。正如官方指南所說「整個介面都必須遵循」,3 半調子的深色比固定淺色的體驗更糟。對既有資產而言,選擇第 1 條並明確宣告「本應用程式為淺色顯示」,在移轉到 .NET 10 的時機再切換到 SetColorMode,才是務實且容易說明的方針。

WinForms 依執行階段區分的選項.NET 10 以上就使用 SetColorMode,.NET 9 則在隱藏 WFO5001 的前提下使用同一個 API,.NET 8 以前或 .NET Framework 則在明確宣告固定淺色與連通用控制項一起自己全面支援之間選擇.NET 10 以上.NET 9.NET 8 以前 / .NET Framework建議有覺悟的話執行階段是?SetColorMode(System)SetColorMode + 隱藏 WFO5001怎麼辦?明確宣告固定淺色自己做全面支援通用控制項會殘留

圖 13: 在用不了 SetColorMode 的執行階段上,明確宣告固定淺色是務實的預設選擇。

7. WPF 中的實作

7.1. .NET 9/10 ── Fluent 佈景主題與 ThemeMode

.NET 9 的 WPF 隨附了符合 Windows 11 Fluent 設計的新佈景主題,支援淺色/深色與輔色。套用的方法有兩種:設定 ThemeMode 屬性,或是把 PresentationFramework.Fluent 的資源字典加入 MergedDictionaries8

ThemeMode 的值有 Light / Dark / System / None(預設。傳統的 Aero2 佈景主題)四個,設定在 Application 上作用於整個應用程式,設定在 Window 上只作用於那個視窗。8

<Application x:Class="OrderEntry.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="MainWindow.xaml"
             ThemeMode="System">
</Application>

文件明確寫著,ThemeMode 不只是把 Fluent 佈景主題的字典載入資源,還「控制視窗的背景材質與深色模式的套用」。也就是說,第 4 章的 DWM 屬性由 WPF 這邊代勞。文件還說明 ThemeModeResources 被設計成同步運作,為的是避免視窗是深色而裡面的控制項是淺色這種不一致。10

WPF 的 ThemeMode 做了什麼把 ThemeMode 設為 System 之後會依 Windows 的設定把對應的 Fluent 佈景主題字典載入資源,同時也控制視窗的深色模式與背景材質的套用ThemeMode=System讀取 Windows 的設定把 Fluent 字典載入資源視窗的深色化與背景材質與 Resources 同步以防不一致

圖 14: ThemeMode 把 Fluent 字典的載入與視窗的深色化一起控制起來。

採用之前有兩點必須先知道。第一,從程式碼讀寫 ThemeMode 的操作是實驗性功能,存取時會產生 WPF0001 錯誤。隱藏之後就能寫成 Application.Current.ThemeMode = ThemeMode.Dark,但 API 參考在 .NET 10 中仍標著 [Experimental("WPF0001")],並註明「未來可能被移除」。810 第二,Fluent 樣式的支援在 .NET 10 中仍「在進行中」。.NET 10 追加了 DatePicker、GridSplitter、GroupBox、TextBox 等的樣式,並修正了 HighContrast 相關的當機,9 反過來說就是 .NET 9 的 Fluent 缺少這些。請在做出採用判斷之前,在實際設備上確認商務應用程式中使用的控制項(尤其是 DataGrid 或第三方控制項)在 Fluent 下會不會跑版。

7.2. 維持傳統佈景主題並跟隨 ── 抽換 ResourceDictionary

不採用 Fluent 的(或者是 .NET 8 以前、.NET Framework 的)WPF,深色模式沒有標準支援。由於 Win32 的系統色彩不隨淺色/深色設定改變,即使參照 WPF 的 SystemColors 也不會變暗。自己實作跟隨的結構如下。

  1. 在淺色用的 Themes/Light.xaml 與深色用的 Themes/Dark.xaml 中,用相同的索引鍵定義色彩與筆刷。
  2. 從 XAML 像 {DynamicResource App.WindowBackgroundBrush} 這樣用 DynamicResource 參照StaticResource 在載入時就被固定,不會跟隨抽換)。
  3. 在第 5 章 SystemThemeWatcher 的通知裡,抽換 MergedDictionaries 中對應的字典。對比佈景主題下不論放入哪個字典,8.4 的觸發程序都會把色彩換成系統色彩,所以放入淺色用的即可。
public static class AppTheme
{
    private static readonly Uri Light = new("pack://application:,,,/Themes/Light.xaml");
    private static readonly Uri Dark = new("pack://application:,,,/Themes/Dark.xaml");

    public static void Apply(ThemeState state)
    {
        var merged = Application.Current.Resources.MergedDictionaries;
        var current = merged.FirstOrDefault(d => d.Source == Light || d.Source == Dark);
        // 只有深色時才用深色字典。對比佈景主題下交給系統色彩(8.4)
        var next = new ResourceDictionary { Source = state == ThemeState.Dark ? Dark : Light };
        if (current is null)
        {
            merged.Add(next);
        }
        else
        {
            merged[merged.IndexOf(current)] = next; // 在相同的位置抽換
        }
    }
}
WPF 中資源字典的抽換在淺色用與深色用的字典中用相同的索引鍵定義色彩,XAML 用 DynamicResource 參照,在佈景主題變更的通知裡抽換 MergedDictionaries 中的字典之後參照目標就會更新佈景主題變更的通知抽換 MergedDictionaries 中的字典Light.xaml(相同的索引鍵)Dark.xaml(相同的索引鍵)DynamicResource 參照被更新StaticResource 參照仍是載入時的值

圖 15: 抽換相同索引鍵的字典之後,只有 DynamicResource 的參照會跟隨。

標準控制項的範本(按鈕的背景、捲軸的色彩)帶的是傳統佈景主題的色彩,所以這裡同樣會殘留「應用程式自己的面是深色、標準控制項是淺色」的地方。請先估算逐一覆寫所需控制項樣式的工作量,再拿它與採用 Fluent 或固定淺色做比較。

7.3. 標題列

如果使用 ThemeMode,WPF 會代勞。維持傳統佈景主題自己實作跟隨時,就使用 4.2 展示的在 OnSourceInitialized 中設定 DWM 屬性的做法,並在 SystemThemeWatcher 的通知裡重新設定。

8. 對比佈景主題時的繪製 ── 守住系統色彩的配對

8.1. 偵測與通知

在 Win32 中,對 SystemParametersInfo 傳入 SPI_GETHIGHCONTRAST 接收 HIGHCONTRAST 結構,用 dwFlagsHCF_HIGHCONTRASTON 位元來判斷。呼叫時必須先設定 cbSize1128 Microsoft 把它定位為「確認高對比是否啟用的唯一受支援的方法」。12

bool IsContrastThemeActive()
{
    HIGHCONTRASTW hc{};
    hc.cbSize = sizeof(hc);
    if (!::SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(hc), &hc, 0))
    {
        // 不要用預設值掩蓋失敗。帶上錯誤碼讓原因能夠浮現
        throw std::system_error(::GetLastError(), std::system_category(),
                                "SystemParametersInfo(SPI_GETHIGHCONTRAST)");
    }
    return (hc.dwFlags & HCF_HIGHCONTRASTON) != 0;
}

各框架都有包裝了這個呼叫的屬性。

環境 判斷 變更的通知
Win32 / MFC SPI_GETHIGHCONTRAST + HCF_HIGHCONTRASTON WM_SYSCOLORCHANGEWM_THEMECHANGED
WinForms SystemInformation.HighContrast SystemEvents.UserPreferenceChanged
WPF SystemParameters.HighContrast(對應 SPI_GETHIGHCONTRAST SystemParameters.StaticPropertyChanged
WinUI 3 ThemeSettings.HighContrast(Microsoft.UI.System) ThemeSettings.Changed

WinForms 的無障礙指引要求在啟動時確認 HighContrast,並用 UserPreferenceChanged 回應變化。13 WPF 的 SystemParameters.HighContrast 對應到 SPI_GETHIGHCONTRASTHCF_HIGHCONTRASTON29 靜態屬性的變化透過 StaticPropertyChanged 通知。30 WinUI 3 的 ThemeSettingsCreateForWindowId 繫結到視窗建立並訂閱 Changed 事件,但要注意不持續保留對該物件的參照事件就會停止31

對比佈景主題的偵測途徑Win32 的 SPI_GETHIGHCONTRAST 是唯一受支援的判斷方法,WinForms 的 SystemInformation.HighContrast、WPF 的 SystemParameters.HighContrast、WinUI 3 的 ThemeSettings.HighContrast 分別以各自框架的包裝形式提供SPI_GETHIGHCONTRAST(唯一的判斷方法)WinForms SystemInformationWPF SystemParametersWinUI 3 ThemeSettingsWin32 直接呼叫

圖 16: 判斷的根是同一個 Win32 API,各框架都有包裝了它的屬性。

8.2. 繪製的原則 ── 前景與背景的配對

Microsoft 的「High contrast parameter」列出了高對比啟用時應用程式應該做的三件事。11

  1. 把所有色彩對應到前景色與背景色的一組配對上。GetSysColorCOLOR_WINDOWTEXTCOLOR_WINDOW 的一組,或是 COLOR_BTNTEXTCOLOR_BTNFACE 的一組。
  2. 省去顯示在文字背後的點陣圖影像。對需要高對比的使用者來說它是視覺上的干擾。
  3. 以多色繪製的影像,改用文字用的前景色與背景色繪製。

「配對」是關鍵。Windows 8 之後的指引說明,COLOR_HIGHLIGHTTEXT 是以與 COLOR_HIGHLIGHT 的背景組合、COLOR_WINDOWTEXT 是以與 COLOR_WINDOW 的背景組合為前提設計的,並要求不要把文字色硬式編碼因為使用者會自訂色彩,所以要做出不依賴目前佈景主題的 UI12 該指引舉的例子 ── 「在 Aero 中文字永遠是黑色、選取色是淡藍,但在 High Contrast Black 中選取色變成黑色。以黑色文字為前提再使用系統的選取色,就會變成黑底黑字」── 正是開頭那個「狀態顯示不見了」。

Windows 11 的對比佈景主題指引把這個對應關係整理成了表。1

用途 前景 背景
標題・內文・清單・框線・無法操作的 UI SystemColorWindowText SystemColorWindow
超連結 SystemColorHotlight SystemColorWindow
停用・非作用中的 UI SystemColorGrayText SystemColorWindow
選取・停留・按下・進行中 SystemColorHighlightText SystemColorHighlight
按鈕等可操作的 UI SystemColorButtonText SystemColorButtonFace

而且「不該做的事」也寫得很明確。不要把 GrayText 用於補充文字或提示文字(只用於停用狀態)不要把 Hotlight 用於超連結以外的地方不要混用不相容的前景/背景不要憑外觀挑色彩(使用者真的會改色彩)。指引還給出了設計準則:頁面、窗格、快顯的背景以 SystemColorWindow 為準,相鄰的面背景色彩會相同,所以只在必要的邊界處用對比佈景主題專用的框線分隔(飛出視窗與對話方塊建議 2px)。1

破壞配對之後變得看不清楚的過程認定文字是黑色而只把選取背景換成系統的選取色,在 High Contrast Black 下選取色變成黑色就成了黑底黑字。前景與背景成對取用的話即使使用者編輯色彩也能看清楚認定文字是黑色只有選取背景用系統的選取色High Contrast Black 下選取色是黑色黑底黑字前景與背景成對取用使用者編輯色彩也能看清楚使用者編輯色彩

圖 17: 只把一邊換成系統色彩可能變成黑底黑字,成對取用則即使色彩被編輯也能看清楚。

對比佈景主題時的繪製判斷對比佈景主題生效時,把色彩對應到系統色彩的配對,省去文字背後的圖片,把多色的影像以前景與背景兩色繪製,不使用硬式編碼的色彩對比佈景主題生效把色彩對應到配對省去文字背後的圖片多色的圖形以兩色繪製不使用硬式編碼的色彩

圖 18: 對比佈景主題生效時的繪製可以歸結為對應、省略、兩色化、去硬式編碼這四點。

8.3. WinForms 中的實作

WinForms 的標準控制項只要把 ForeColor / BackColor 維持預設,就會遵循系統色彩。只需依判斷結果切換那些加了自訂色彩的地方與自行繪製的部分。指引裡的例子是:平常是藍底黃字的標籤,在高對比時還原成 SystemColors.Window / SystemColors.WindowText13 相當於在前面的調色盤結構上,再加一條對比佈景主題用的分支。

using Microsoft.Win32;

public partial class OrderForm : Form
{
    private readonly SynchronizationContext _ui;

    public OrderForm()
    {
        InitializeComponent();
        // 控制項已經建立,所以裡面裝的是 WindowsFormsSynchronizationContext
        _ui = SynchronizationContext.Current
              ?? throw new InvalidOperationException("請在 UI 執行緒上建立。");
        ApplyColorScheme();
        SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
    }

    private void ApplyColorScheme()
    {
        if (SystemInformation.HighContrast)
        {
            // 守住配對,把配色全面交給系統色彩,並拿掉文字背後的圖片
            statusLabel.BackColor = SystemColors.Window;
            statusLabel.ForeColor = SystemColors.WindowText;
            headerPanel.BackgroundImage = null;
        }
        else
        {
            var p = AppPalette.Current; // 淺色/深色的調色盤(第 5 章)
            statusLabel.BackColor = p.PanelBackground;
            statusLabel.ForeColor = p.PanelForeground;
            headerPanel.BackgroundImage = Properties.Resources.HeaderPattern;
        }
    }

    private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
    {
        // 這個事件同樣不保證在 UI 執行緒上送達。切回 UI 之後不依類別篩選,直接重新判斷
        _ui.Post(_ =>
        {
            if (IsDisposed) return;
            ApplyColorScheme();
        }, null);
    }

    // 是靜態事件,不解除的話表單會洩漏。在沒有被關閉就被釋放的路徑上也會經過的
    // Dispose(bool) 裡解除(如果有設計工具產生的 Dispose(bool),就寫在那裡)
    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
        }
        base.Dispose(disposing);
    }
}

OnPaint 中的自行繪製要用 SystemBrushes.Window / SystemPens.WindowText 這類守住配對的系統筆刷來畫,而表示狀態的彩色圓點這種多色圖形,則換成前景色的框線加文字(「運轉中」「停止」)。不只依賴色彩的顯示方式,與上一篇文章談的成功準則 1.4.1 是同一件事。

WinForms 配色套用的分支啟動時以及每次 UserPreferenceChanged 都呼叫 ApplyColorScheme,SystemInformation.HighContrast 為真就交給系統色彩的配對並拿掉背景圖片,為假就從淺色/深色的調色盤取色啟動時 / UserPreferenceChangedApplyColorSchemeSystemInformation.HighContrast?交給 SystemColors 的配對拿掉背景圖片從淺色/深色的調色盤取

圖 19: WinForms 在啟動時與每次通知時呼叫同一個處理,是對比佈景主題就交給系統色彩。

8.4. WPF 中的實作

WPF 的 SystemColors 中,像 WindowBrushKey 這樣的資源索引鍵只要用 DynamicResource 參照,筆刷改變時就會自動更新(直接使用 WindowBrush 的靜態參照不會更新)。32 想只在對比佈景主題下切換外觀,就從 DataTrigger 參照 SystemParameters.HighContrast 的值。不過 SystemParameters.HighContrast 是靜態屬性,直接用的話無法成為繫結的活來源。請準備一個訂閱 StaticPropertyChanged 並保存值、以 INotifyPropertyChanged 發出通知的小型 Proxy,把它的執行個體指定給 Source 之後繫結。30

public sealed class ThemeSettings : INotifyPropertyChanged
{
    public static ThemeSettings Instance { get; } = new();

    public bool IsHighContrast { get; private set; } = SystemParameters.HighContrast;
    public event PropertyChangedEventHandler? PropertyChanged;

    private ThemeSettings()
    {
        // SystemParameters 的靜態屬性改變(重新取一次 SPI_GETHIGHCONTRAST)時會收到通知
        SystemParameters.StaticPropertyChanged += (_, e) =>
        {
            if (!string.IsNullOrEmpty(e.PropertyName)
                && e.PropertyName != nameof(SystemParameters.HighContrast)) return;
            IsHighContrast = SystemParameters.HighContrast;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsHighContrast)));
        };
    }
}
<!-- 必須事先宣告 xmlns:local="clr-namespace:OrderEntry" -->
<Style x:Key="CardStyle" TargetType="Border">
    <Setter Property="Background" Value="{DynamicResource App.CardBackgroundBrush}"/>
    <Setter Property="BorderBrush" Value="{DynamicResource App.CardBorderBrush}"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Style.Triggers>
        <DataTrigger Binding="{Binding Source={x:Static local:ThemeSettings.Instance}, Path=IsHighContrast}"
                     Value="True">
            <!-- 守住配對: 背景用 Window,框線與文字用 WindowText。邊界畫粗一些 -->
            <Setter Property="Background"
                    Value="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"/>
            <Setter Property="BorderBrush"
                    Value="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
            <!-- 讓裡面的文字也繼承。注意明確指定了 Foreground 的子元素會切斷繼承 -->
            <Setter Property="TextElement.Foreground"
                    Value="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
            <Setter Property="BorderThickness" Value="2"/>
        </DataTrigger>
    </Style.Triggers>
</Style>
WPF 的系統色彩參照與對比佈景主題的觸發程序用 DynamicResource 參照 SystemColors 的資源索引鍵就會自動跟隨筆刷的變更,繫結到訂閱 StaticPropertyChanged 的 Proxy 的 IsHighContrast 的觸發程序會對執行期間的切換做出反應並切換到守住配對的色彩。直接參照 WindowBrush 不會更新用 DynamicResource 參照 WindowBrushKey自動跟隨筆刷的變更StaticPropertyChangedProxy 的 IsHighContrastDataTrigger 做出反應切換到守住配對的色彩直接參照 WindowBrush不會更新

圖 20: WPF 透過資源索引鍵的動態參照,以及繫結到中繼靜態屬性變化的 Proxy,跟隨執行期間的切換。

即使使用 Fluent 佈景主題,考量到 .NET 10 才修正了 HighContrast 相關的當機,9 也請把對比佈景主題下的動作確認納入採用條件。

8.5. WinUI 3 中的實作

WinUI 3 的標準控制項從一開始就會跟隨淺色/深色/對比佈景主題,應用程式自己的色彩則用 Default(深色)、LightHighContrast 這些索引鍵定義在 ResourceDictionary.ThemeDictionaries 中。在 HighContrast 中不要把色彩硬式編碼,而要用 ThemeResource 參照 SystemColorWindowColor 這類動態的系統色彩。帶有 Light/Dark 的自訂控制項一定要同時準備 HighContrastHighContrast 會在找不到其他具名高對比佈景主題時充當後備索引鍵。331

<ResourceDictionary.ThemeDictionaries>
    <ResourceDictionary x:Key="Default">
        <SolidColorBrush x:Key="App.CardBackgroundBrush" Color="#2B2B2B"/>
    </ResourceDictionary>
    <ResourceDictionary x:Key="Light">
        <SolidColorBrush x:Key="App.CardBackgroundBrush" Color="#F3F3F3"/>
    </ResourceDictionary>
    <ResourceDictionary x:Key="HighContrast">
        <SolidColorBrush x:Key="App.CardBackgroundBrush"
                         Color="{ThemeResource SystemColorWindowColor}"/>
    </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>

還有一點,WinUI 有一個叫 HighContrastAdjustment 的機制,預設是啟用的。它為了維持對比會強制使用白色文字與黑色的醒目提示背景,指引建議的是如果已經準備好正確使用系統色彩的佈景主題字典,就把它設為 None,讓自己的樣式生效1

WinUI 的 ThemeDictionaries 的解析依目前的佈景主題選中 Default(深色)、Light、HighContrast 其中一個字典,在 HighContrast 中用 ThemeResource 參照動態的系統色彩。HighContrast 會在沒有具名高對比佈景主題時充當後備索引鍵深色淺色對比佈景主題目前的佈景主題是?Default 字典Light 字典HighContrast 字典用 ThemeResource 參照 SystemColor 系列沒有具名佈景主題時的後備

圖 21: WinUI 中每個佈景主題的字典會自動被選中,HighContrast 字典參照系統色彩。

9. 支援深色模式無法取代無障礙支援

把支援深色模式當作「已經做了無障礙支援」來回報是錯的。兩者的關係可以這樣整理。

  • 對比度的標準同樣適用於深色的調色盤。WCAG 的成功準則 1.4.3 要求文字 4.5:1、大型文字 3:1,背景變暗也不會改變。14 在深灰背景上放中灰文字的深色設計,與淺色的「白底淡灰」有著同樣的問題。
  • 避開純黑與純白是 Windows 11 的設計。Microsoft 的最佳做法說明,Windows 11 避開了純白與純黑,改用對眼睛更友善的色調。34 反過來說,在深色模式下把背景設為 #000000,會有人抱怨它與明亮文字之間對比過強產生的光暈。
  • 對色覺多樣性的照顧與佈景主題無關,始終必要。Microsoft 的色彩指南要求不要把色彩當成主要的傳達手段而是當成視覺上的強化,並且不要把紅與綠的組合當成唯一的區分依據。3515
  • 對比佈景主題是與深色模式互相獨立的需求。如第 2 章所述,對比佈景主題生效期間無法使用深色模式,所以不論深色支援做得多完美,都送不到對比佈景主題的使用者手上。
深色模式與無障礙的關係支援深色模式是對外觀偏好與環境的照顧,而對比度、不只依賴色彩的傳達方式、對比佈景主題支援這些無障礙要求與佈景主題無關,必須另外滿足無法取代深色模式支援對偏好與環境的照顧無障礙對比度 4.5:1不只依賴色彩對比佈景主題支援

圖 22: 深色模式支援是對偏好與環境的照顧,無障礙的要求要另外滿足。

另一方面,第 5 章的「把色彩集中到一處」是兩者共同的基礎。調色盤集中在一個地方,就能列舉出需要在淺色與深色下分別實測對比度的對象,對比佈景主題用的分支也能寫在同一個地方。以深色支援為契機集中調色盤,順便檢查對比度與對比佈景主題,是投資效率最高的順序。

10. 方針的決定方式 ── 依應用程式類別的建議

應用程式的種類 建議的方針
新建的 WinForms(.NET 10) 使用 SetColorMode(System)。自行繪製以 SystemColors 為基礎,含通用控制項的自訂控制項用 ApplyThemingImplicitly 選擇加入
新建的 WPF(.NET 9/10) 在通過了所用控制項的顯示確認與對比佈景主題的動作確認之後採用 ThemeMode="System"。有困難就用傳統佈景主題+字典抽換
既有的 WinForms/WPF(.NET Framework 4.8、.NET 8 以前) 明確宣告「固定淺色」,DWM 屬性維持預設(FALSE)。對比佈景主題支援一定要做,移轉到 .NET 10 時再轉向深色支援
WinUI 3 預設跟隨系統。自己的色彩定義在 ThemeDictionaries 中並包含 HighContrast,把 HighContrastAdjustment 設為 None
Win32 / MFC DWM 屬性+自備調色盤+在 WM_THEMECHANGED / WM_SYSCOLORCHANGE 中重新計算。官方指南只講到偵測與標題列,通用控制項的重新上色不在範圍內
既有資產走向深色支援的路徑既有資產先明確宣告固定淺色,一定要完成對比佈景主題支援,集中調色盤做好準備之後移轉到 .NET 10,再切換到 SetColorMode 或 ThemeMode。半調子的深色支援體驗比固定淺色更糟因此不走這條路不走這條路明確宣告固定淺色(現在)對比佈景主題支援(必須)調色盤的集中(準備)移轉到 .NET 10切換到 SetColorMode / ThemeMode半調子的深色支援比固定淺色更糟的體驗

圖 23: 既有資產從固定淺色開始,經過對比佈景主題支援與調色盤集中,在移轉到 .NET 10 時走向深色支援。

「固定淺色」不是失敗。它就是 Windows 預設行為本身,也是有文件記載的動作。與其做出半調子的深色支援、交付「只有標題列是黑的」「只有捲軸是白的」的應用程式,在淺色上保持一致對使用者來說要好得多。不過,唯獨對比佈景主題支援是「固定」不了的。這屬於上一篇文章談的合理調整的範疇,不是佈景主題的偏好,而是能不能用的問題。

11. 驗證檢查清單

做完之後,依下面的順序在實際設備上確認。全部都能從設定畫面在數十秒內切換。

  1. 在執行期間切換淺色/深色。在「設定 > 個人化 > 色彩」中改變模式,確認標題列與工作區是否都跟隨,或者是否符合「下次啟動時生效」的規格(WinForms 的 SetColorMode 不跟隨)。
  2. 觸發控制代碼重新建立。WinForms 就在執行期間切換 ShowInTaskbar,確認標題列的屬性是否被保留。
  3. 四種對比佈景主題全部試一遍。用左 Alt+左 Shift+PrintScreen 切換,在 Aquatic、Desert、Dusk、Night sky 中分別確認文字、框線、選取列、停用項目、連結是否看得清楚。1
  4. 編輯對比佈景主題的色彩。使用者真的會改色彩。把背景編輯成極端的色彩,把殘留的硬式編碼逼出來。
  5. 查看記錄檔。確認在受支援的系統上 DwmSetWindowAttributeSystemParametersInfo 失敗時是否有記錄,以及在低於組建 22000 的 Windows 10 上是否沒有呼叫 DWM 屬性而以淺色啟動。
  6. 實測對比度。在淺色與深色下分別以 4.5:1 檢查調色盤中文字色與背景色的組合。14
佈景主題支援的驗證步驟依執行期間切換淺色/深色、控制代碼重新建立、四種對比佈景主題、編輯佈景主題的色彩、確認失敗記錄、實測對比度的順序在實際設備上確認執行期間切換淺色/深色控制代碼重新建立四種對比佈景主題編輯佈景主題的色彩確認失敗記錄實測對比度

圖 24: 驗證從切換設定開始,以記錄檔與對比度的確認收尾。

12. 總結

  • Windows 的佈景主題有「淺色/深色」與「對比佈景主題」兩條軸,後者生效期間無法使用深色模式。判斷以對比佈景主題為先。
  • 既有應用程式的標題列是白的,是基於相容性的預設行為;對 DwmSetWindowAttributeDWMWA_USE_IMMERSIVE_DARK_MODE(值 20,Windows 11 組建 22000 以上)傳入 TRUE,系統是深色時就會以深色繪製。每次 HWND 建立都要設定,失敗要記進記錄檔。
  • 目前的模式用 UISettings.GetColorValue 的前景色亮度判斷,用 ColorValuesChanged 察覺變更,切回 UI 執行緒後重新上色。色彩要集中到一處。
  • WinForms 用 .NET 9/10 的 Application.SetColorMode(SystemColorMode.System)。要記住僅限 Windows 11、對比佈景主題下無效、不跟隨執行期間的變更這三條限制,以及自訂控制項的 ApplyThemingImplicitly
  • WPF 用 .NET 9/10 的 ThemeMode="System"。從程式碼進行的操作是實驗性的、Fluent 還在進行中,所以要評估後採用。傳統佈景主題就用字典抽換+DynamicResource。
  • 對比佈景主題時用 SPI_GETHIGHCONTRAST 系判斷,把色彩對應到系統色彩的配對,省去文字背後的圖片,把多色的圖形以兩色繪製。GrayText 表示停用狀態,Hotlight 只用於連結。
  • 深色支援無法取代無障礙支援。深色下同樣要 4.5:1、不只依賴色彩、對比佈景主題支援必須另外做到。
  • 既有資產明確宣告「固定淺色」是務實解,唯獨對比佈景主題支援固定不了。

作為第一步,建議的做法是:挑一個主力畫面,先用左 Alt+左 Shift+PrintScreen 啟用對比佈景主題看一看,再用同樣的按鍵切回來,然後把 Windows 的色彩設定切換成深色試試(對比佈景主題生效期間無法使用深色模式,所以兩者要分別試)。幾分鐘就能看出自家應用程式「把色彩放在了哪裡」。

相關文章

相關的諮詢領域

小村軟體有限公司承接 WinForms/WPF 商務應用程式的深色模式支援(調色盤的集中、移轉到 .NET 9/10 的 SetColorMode / ThemeMode 的評估、DWM 屬性的導入)、對比佈景主題下顯示跑版的診斷與修正,以及 Win32/MFC 資產跟隨佈景主題的諮詢。從「改成深色模式後同仁來抱怨了」這個階段開始也沒關係。

參考連結

  1. Microsoft Learn, Contrast themes. 關於對比佈景主題使用大約 7:1 以上的受限調色盤且不得與淺色/深色佈景主題混為一談、Aquatic・Desert・Dusk・Night sky 四種與色彩的編輯、用左 Alt+左 Shift+PrintScreen 切換、SystemColor 系資源的前景/背景配對與用途、把 GrayText 限定為停用狀態而把 Hotlight 限定為連結、硬式編碼色彩造成的跑版、邊界的框線、ThemeDictionaries 的 HighContrast、把 HighContrastAdjustment 設為 None,以及用 Microsoft.UI.System.ThemeSettings 偵測。  2 3 4 5 6 7 8

  2. Microsoft Learn, Application.SetColorMode(SystemColorMode) Method. 關於要在建立 UI 元素之前呼叫、即使指定 System 應用程式也不會在系統設定改變時自動適應,以及深色的色彩模式只能在 Windows 11 以上使用且在高對比模式下無法使用。  2 3 4

  3. Microsoft Learn, Support Dark and Light themes in Win32 apps. 關於色彩模式中前景與背景的定義、Windows 不知道應用程式能否支援深色因而基於相容性預設給出淺色標題列、用 UISettings.GetColorValue 取得前景色並依感知亮度判斷明暗來偵測深色模式的做法、用 ColorValuesChanged 追蹤、用 DwmSetWindowAttribute 與 DWMWA_USE_IMMERSIVE_DARK_MODE(值 20)啟用深色標題列,以及整個介面都必須遵循深色。  2 3 4 5 6 7 8 9 10

  4. Microsoft Learn, DWMWINDOWATTRIBUTE enumeration (dwmapi.h). 關於 DWMWA_USE_IMMERSIVE_DARK_MODE 在系統深色設定啟用時允許以深色繪製框架以及所有視窗預設為淺色、DWMWA_BORDER_COLOR・DWMWA_CAPTION_COLOR・DWMWA_TEXT_COLOR 的 COLORREF 指定與用 DWMWA_COLOR_DEFAULT 回到預設、Windows 11 組建 22000 以上的支援,以及 DWMWA_SYSTEMBACKDROP_TYPE 從組建 22621 起支援。  2 3 4

  5. Microsoft Learn, UISettings.ColorValuesChanged Event. 關於色彩的值改變時觸發的事件。  2

  6. Microsoft Learn, What’s new in Windows Forms for .NET 9. 關於實驗性的深色模式初步支援、色彩模式改變時 SystemColors 跟著改變、SystemColorMode 的 Classic・System・Dark 三個值、在啟動程式碼中呼叫 Application.SetColorMode,以及隱藏 WFO5001。  2 3

  7. Microsoft Learn, What’s new in Windows Forms for .NET 10. 關於深色模式的完全整合與 SetColorMode 不再是實驗性的、自行繪製控制項內的 Win32 通用控制項不選擇加入就會維持淺色,以及必須在 CreateParams 的覆寫內、base.CreateParams 之前呼叫 SetStyle(ControlStyles.ApplyThemingImplicitly) 而放在建構函式裡來不及。  2 3 4

  8. Microsoft Learn, What’s new in WPF for .NET 9. 關於支援淺色/深色與輔色的 Fluent 佈景主題、ThemeMode 的 Light・Dark・System・None 四個值與在 Application/Window 上的設定、透過資源字典套用,以及從程式碼設定 ThemeMode 是實驗性的並需要隱藏 WPF0001。  2 3 4

  9. Microsoft Learn, What’s new in WPF for .NET 10. 關於 Fluent UI 樣式的支援仍在進行中、追加了 DatePicker・GridSplitter・GridView・GroupBox・Hyperlink・Label・NavigationWindow・RichTextBox・TextBox 的 Fluent 樣式,以及修正了 HighContrast 相關的當機。  2 3

  10. Microsoft Learn, Application.ThemeMode Property. 關於它控制以淺色・深色・系統中的哪種模式載入 Fluent 佈景主題並且也控制視窗的背景材質與深色模式的套用、ThemeMode 與 Resources 被設計成同步以避免不一致,以及它帶有 Experimental(“WPF0001”) 屬性且未來可能被移除。  2 3 4

  11. Microsoft Learn, High contrast parameter. 關於在初始化時與處理 WM_SYSCOLORCHANGE 時用 SPI_GETHIGHCONTRAST 取得 HIGHCONTRAST 結構並確認 HCF_HIGHCONTRASTON、啟用時把所有色彩對應到 COLOR_WINDOWTEXT 與 COLOR_WINDOW 或 COLOR_BTNTEXT 與 COLOR_BTNFACE 的一組配對、省去文字背後的點陣圖影像,以及用前景色與背景色繪製多色的影像。  2 3

  12. Microsoft Learn, High-contrast mode. 關於 Aero 中是黑色文字與淡藍的選取色但 High Contrast Black 中選取色變成黑色因而可能出現黑底黑字、COLOR_HIGHLIGHTTEXT 以與 COLOR_HIGHLIGHT 組合為前提而 COLOR_WINDOWTEXT 以與 COLOR_WINDOW 組合為前提、不要把文字色硬式編碼、因為使用者會自訂色彩所以要做出不依賴佈景主題的 UI、在 WM_THEMECHANGED 中重新計算色彩,以及 SPI_GETHIGHCONTRAST 是唯一受支援的確認方法。  2 3

  13. Microsoft Learn, Walkthrough: Creating an Accessible Windows-based Application. 關於用 SystemInformation.HighContrast 判斷、啟用時使用系統的配色並為用色彩傳達的資訊附加視覺線索且省去文字背後的圖片、啟動時的確認與對 UserPreferenceChanged 事件的跟隨,以及用 SystemColors 切換標籤配色的例子。  2 3

  14. W3C / Web Accessibility Infrastructure Committee(WAIC) 日文譯本, Web Content Accessibility Guidelines (WCAG) 2.1 日本語訳. 關於成功準則 1.4.3(對比度(最低))的文字 4.5:1・大型文字 3:1,以及成功準則 1.4.1(色彩的使用)。  2 3

  15. Microsoft Learn, Color in Windows. 關於 Windows 擁有淺色與深色兩種色彩模式、輔色與佈景主題的選擇會反映到使用者的整體體驗,以及確保對比度與照顧色覺多樣性。  2

  16. Microsoft Learn, Reference for Windows 11 and Windows 10 settings. 關於 HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize 底下的 AppsUseLightTheme 與 SystemUsesLightTheme 是表示應用程式與 Windows 淺色/深色的 DWORD 值。 

  17. Microsoft Learn, Theming in Windows apps. 關於拿掉 RequestedTheme 之後會遵循系統設定、使用者選擇高對比佈景主題時系統會覆寫 RequestedTheme,以及在自訂範本中不要把色彩硬式編碼而要使用佈景主題筆刷。 

  18. Microsoft Learn, DwmSetWindowAttribute function (dwmapi.h). 關於設定視窗非工作區 DWM 繪製屬性的函式,以及它從 Windows Vista 起可用。 

  19. Microsoft Learn, Retrieve a window handle (HWND). 關於從 WPF 的 WindowInteropHelper 取得 Handle 的方法。 

  20. Microsoft Learn, DWM_SYSTEMBACKDROP_TYPE enumeration (dwmapi.h). 關於 DWMSBT_MAINWINDOW 在 Windows 11 上相當於 Mica、DWMSBT_TRANSIENTWINDOW 相當於 Acrylic、材質的效果在未來的 Windows 中可能改變,以及 Windows 11 組建 22621 以上的支援。 

  21. Microsoft Learn, UISettings.GetColorValue(UIColorType) Method. 關於傳回指定 UIColorType 色彩值的方法。 

  22. Microsoft Learn, WM_SETTINGCHANGE message. 關於 SystemParametersInfo 變更系統範圍的設定時或原則設定改變時送給所有最上層視窗的訊息。 

  23. Microsoft Learn, SystemEvents.UserPreferenceChanged Event. 關於使用者設定變更時觸發的靜態事件,以及不解除處理常式會造成記憶體流失。 

  24. Microsoft Learn, WM_THEMECHANGED message. 關於在佈景主題的啟用・停用・切換之後廣播給所有視窗,以及既有的佈景主題控制代碼會失效因而必須重新開啟。 

  25. Microsoft Learn, WM_SYSCOLORCHANGE message. 關於系統色彩設定變更時送給所有最上層視窗,使用系統色彩的筆刷必須重新建立,以及必須轉送給通用控制項。 

  26. Microsoft Learn, Compiler Error WFO5001. 關於 .NET 9 中 SetColorMode 與 SystemColorMode 作為評估用途的實驗性功能受到保護,以及 .NET 10 之後不再適用該錯誤。 

  27. Microsoft Learn, SystemColors.UseAlternativeColorSet Property. 關於設為 true 之後系統的 KnownColor 會傳回替代的色彩集(目前是深色模式版)、它是 SYSLIB5002 的實驗性功能,以及 Windows 上高對比佈景主題生效時系統的 KnownColor 永遠傳回 Windows 目前的色彩。 

  28. Microsoft Learn, HIGHCONTRASTW structure (winuser.h). 關於 dwFlags 的 HCF_HIGHCONTRASTON(0x00000001),以及在 SPI_GETHIGHCONTRAST 中使用時必須指定 cbSize。 

  29. Microsoft Learn, SystemParameters.HighContrast Property. 關於對應到 SPI_GETHIGHCONTRAST 與 HCF_HIGHCONTRASTON 的 WPF 靜態屬性。 

  30. Microsoft Learn, SystemParameters.StaticPropertyChanged Event. 關於 SystemParameters 的任一屬性改變時觸發的靜態事件。  2

  31. Microsoft Learn, ThemeSettings Class (Microsoft.UI.System). 關於用 CreateForWindowId 繫結到視窗建立並透過 Changed 事件接收高對比的變化,以及釋放參照之後物件會被銷毀使事件不再觸發。 

  32. Microsoft Learn, SystemColors.WindowBrushKey Property. 關於用資源索引鍵建立動態參照之後筆刷變更時會自動更新,而透過 WindowBrush 的靜態參照不會自動更新。 

  33. Microsoft Learn, ResourceDictionary.ThemeDictionaries Property (Microsoft.UI.Xaml). 關於擁有 Light 與 Dark 佈景主題字典的自訂控制項也要準備 HighContrast 的字典、HighContrast 是沒有其他高對比佈景主題時的後備索引鍵、Default 在找不到佈景主題的 ResourceDictionary 時使用,以及在 HighContrast 中可以使用 SystemColorButtonFaceColor 這類系統色彩資源。 

  34. Microsoft Learn, Windows app development best practices. 關於 Windows 11 避開純白與純黑改用對眼睛更友善的色調,以及深色/淺色佈景主題是適應使用者視覺偏好的手段。 

  35. Microsoft Learn, Color (Windows UX guidelines). 關於把色彩當成視覺上的強化而非主要的傳達手段、依用途選擇佈景主題色彩與系統色彩並把前景與背景以對應的組合使用、用 WM_THEMECHANGED 處理佈景主題變更,以及 High Contrast Black 在 Windows 11 上對應 Aquatic 而 High Contrast White 對應 Desert。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

把 Windows 切成深色模式後,只有自家 WinForms 應用程式的標題列還是白的。為什麼?
因為 Windows 沒有辦法知道一個應用程式是否支援深色模式,基於相容性考量,它把所有視窗預設當成淺色模式處理。包含標題列在內的非工作區是由桌面視窗管理員(DWM)繪製,只有應用程式自己透過 DwmSetWindowAttribute 對 DWMWA_USE_IMMERSIVE_DARK_MODE(值 20)傳入 TRUE,系統處於深色時才會以深色繪製。文件記載這個屬性從 Windows 11 組建 22000 起支援。在 .NET 9 之後的 WinForms 使用 Application.SetColorMode,或在 .NET 9 之後的 WPF 使用 ThemeMode 時,這個呼叫由框架代勞,所以需要自己呼叫的只有 .NET 8 以前、.NET Framework 以及 Win32/MFC 的應用程式。另外,如果只把標題列變黑而工作區還是白的,反而更不自然。請在準備好把整個應用程式重新塗成深色時,再啟用這個屬性。
深色模式和對比佈景主題(高對比)是同一件事嗎?
是兩回事。淺色/深色是「設定 > 個人化 > 色彩」裡的色彩模式,使用的是把前景與背景明暗對調的寬廣調色盤。對比佈景主題是在「設定 > 協助工具 > 對比佈景主題」中選擇的,使用的是被限制在大約 7:1 以上對比度的調色盤(Aquatic、Desert、Dusk、Night sky 四種,以及使用者自行編輯色彩後的版本)。Microsoft 的文件明確要求不要把兩者混為一談,而且在對比佈景主題生效期間無法使用深色模式(WinForms 的 SetColorMode 在對比佈景主題下不提供深色,XAML 的 RequestedTheme 也會被系統覆寫)。在應用程式的實作中,請採用先判斷是不是對比佈景主題、是就把配色全面交給系統色彩、不是才選擇淺色/深色調色盤的優先順序。
讓 WinForms 應用程式支援深色模式最快的方法是什麼?
如果是 .NET 9 之後,最快的方法是在 Program.cs 的 Application.Run 之前呼叫 Application.SetColorMode(SystemColorMode.System)。在 .NET 9 中它是實驗性功能,必須在專案檔中隱藏 WFO5001,從 .NET 10 起不必隱藏就能使用。呼叫 SetColorMode 之後 SystemColors 會切換到深色用的替代色彩集,標準控制項也會跟著繪製。有三點要注意。第一,深色模式只能在 Windows 11 以上使用,而且在對比佈景主題下無效。第二,即使指定 SystemColorMode.System,應用程式執行期間 Windows 的設定改變時也不會自動跟隨(下次啟動時才生效)。第三,如果自行繪製的控制項內部使用了捲軸之類的 Win32 通用控制項,就必須覆寫 CreateParams,並在 base.CreateParams 之前呼叫 SetStyle(ControlStyles.ApplyThemingImplicitly, true)(放在建構函式裡已經來不及)。
WPF 應用程式要做什麼才能跟隨深色模式?
.NET 9 之後的 WPF 隨附了符合 Windows 11 Fluent 設計的新佈景主題,只要在 App.xaml 的 Application 元素寫上 ThemeMode="System",就會載入符合 Windows 淺色/深色設定的 Fluent 佈景主題。ThemeMode 還會控制視窗的深色化(標題列)與背景材質的套用。不過,從程式碼讀寫 ThemeMode 屬性的操作在 .NET 10 中仍是實驗性的(WPF0001),Fluent 樣式本身在 .NET 10 的文件中也仍被寫為「還在進行中」。要在商務應用程式中採用,請先評估自己使用的控制項在 Fluent 下會不會跑版再決定。若要維持傳統佈景主題(.NET 8 以前與 .NET Framework 也一樣),就準備淺色用與深色用的 ResourceDictionary 並以 MergedDictionaries 抽換,XAML 這邊用 DynamicResource 參照,切換的偵測則使用 UISettings.ColorValuesChanged。標題列則在 SourceInitialized 中從 WindowInteropHelper 取得 HWND 後呼叫 DwmSetWindowAttribute。
為什麼在對比佈景主題(高對比)下文字會消失或看不清楚?
典型的原因是把色彩硬式編碼,或是破壞了系統色彩中前景與背景的組合(配對)。在對比佈景主題下,使用者可以自由編輯背景、文字、連結等色彩,因此「文字應該是黑的」「選取列應該是淡藍的」這類前提全都不成立。例如只把背景固定成 #E6E6E6,在某些佈景主題下前景會變成白色,白字疊在淡灰上就看不清楚了。原則有三條。用 SPI_GETHIGHCONTRAST(WinForms 用 SystemInformation.HighContrast,WPF 用 SystemParameters.HighContrast)判斷狀態;把所有色彩換成系統色彩中正確的配對(WindowText 與 Window、ButtonText 與 ButtonFace、HighlightText 與 Highlight);拿掉文字背後的圖片與多色的圖形,只用前景色與背景色繪製。GrayText 只能表示停用狀態,Hotlight 不得用於超連結以外的地方。變更會透過 WM_SYSCOLORCHANGE 或 WM_THEMECHANGED(在 .NET 中是 SystemEvents.UserPreferenceChanged)通知,請在那裡重新計算色彩並重繪。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽