从零开始让你的 WinForms 应用程序也用上原生深色主题

本文最后更新于:9 天前

文章较长,请耐心阅读~

前言

如果你不喜欢 WinForms 仅仅是因为它不支持深色主题的话,那一定要看看这篇文章。要注意的是:

  • 最新版 .NET 貌似已经支持深色模式,但本文只针对低版本 .NET;
  • 推荐有一定 Win32/C++ 编程经验的朋友们阅读,没有也没关系,我会尽量用通俗的语句来讲解,很简单的;
  • 原生深色主题作用范围有限 (毕竟不是公开的),不能达到 100% 覆盖,且仅支持 Windows 10 1903 及以上 系统。本文将对以下类型的控件的深色主题进行讨论,其他控件请自行尝试 套用类似的方法 或者 查阅相关资料 或者 直接使用第三方 UI 库 或者 转向 WPF 或者 放弃。😅
  • 理论上来说深色主题支持热重载,即在运行时开启或关闭深色主题。但我不推荐,我采用的是用户更改主题时询问是否重启应用程序。若你有需求也可以尝试一下。
  • 以下所有详细实现都能在 PlainCEETimer 中找到。

支持的控件

简单实现

修改 ForeColorBackColor 属性就能实现。

  • Form (除标题栏以外)
  • Panel
  • GroupBox (除标题以外)
  • Label (跟随父容器自动适应)
  • LinkLabel (跟随父容器自动适应)
  • PictureBox (跟随父容器自动适应)

原生支持

调用 Win32 API 即可实现。

  • Form (标题栏)
  • ContextMenu
  • Button
  • TextBox
  • CheckBox
  • RadioButton
  • ComboBox
  • TreeView
  • NumericUpDown
  • ListView (初步)
  • TabControl (改用 TreeView)
  • ContextMenuStrip (改用 ContextMenu)

自绘实现

重写 OnPaint 等方法才能实现

  • GroupBox

Hook 实现

  • 重写 HookProc 拦截相关消息即可实现
    • ColorDialog
    • FontDialog
  • 子类化即可实现
    • ListView (完整)
  • 需要借助 IAT Hook 才能实现
    • ScrollBar

基础知识

窗口句柄

窗口句柄 (hWnd, Handle of Window),可以理解为窗口/控件的唯一标识符。在 Win32 中,所有的控件和窗口在创建时都会被分配一个全新的 HWND,有了它我们基本上就可以完全控制所属控件了。它在 Win32 中类型为 HWND,在 C# WinForms 中对应实现了 IWin32Window 的对象的 Handle 属性,类型为 IntPtr。

另外,在 Win32 中 控件即窗口,窗口即控件,两者概念几乎相同。这也就是为什么 WinForms 里 Form 和其他大多数控件都继承自 Control,因为 WinForms 本质就是对 Win32 的封装 (类似于 MFC),架构什么的肯定要与 Win32 一致。

你知道吗?
个别软件 (比如 System Informer) 提供了很神金的功能。这个软件具有枚举应用程序所有窗口/控件的功能,并且还提供了很多 侵入式 的选项,比如销毁、隐藏窗口/控件等,这都还能李姐。最离谱的是还有最小化、最大化等本应该用在窗口的功能,现在也可以应用一切在具有 HWND 的对象上。也就是说,你可以通过此功能对控件进行最小化、最大化。这也更加印证了 Win32 里窗口和控件的概念是相似的,你甚至可以直接说 Win32 里只有窗口,没有控件。

消息循环

消息循环是 Win32 应用程序的核心,一个应用程序一般只有一个消息循环。有了它,应用程序的窗口才能获取系统、用户对它的指示,从而做出相应的回应。

你知道吗?
当在应用程序内打开一个模态 (Modal) 对话框时,会额外启动一个临时的消息循环,会在模态对话框关闭时结束这个消息循环。如果是 WinForms 的话,可以通过 Form.ShowDialog() 打开一个模态对话框。我们通过一些手段,发现在正式运行对话框之前,WinForms 内部先调用 Application.BeginModalMessageLoop() 或类似方法启动模态消息循环,并在对话框关闭后调用 Application.EndModalMessageLoop() 或类似方法结束此消息循环。这样做的目的就是防止用户打开模态对话框的同时操作应用程序其他窗口,这也就是为什么我们无法在通过 ShowDialog() 打开的窗体的外面操作其他窗体。

消息的载体在 Win32 和 WinForms 中被定义为 MSG/Message 类型,它是一种结构体 (struct),主要字段有:

Win32 声明 WinForms 声明 含义
HWND hwnd IntPtr HWnd 消息接收方的句柄
UINT message int Msg 消息编号,区分是什么消息
WPARAM wParam IntPtr WParam 可大致理解为附加的小型数据
LPARAM lParam IntPtr LParam 可大致理解为附加的大型数据

你可能注意到 消息编号 在 Win32 是 UINT (C# 里也有 uint),在 WinForms 里是 int,难道不会出错吗?

这个问题我只能说 Win32 里多半又是什么历史遗留下来的原因,才用 UINT。但可以肯定的是,Win32 使用的消息,范围都不会超出 int 的最大值,而在 C# 中 int 又是很常用的类型,所以就理所当然的变成了 int,也不用担心会溢出。

窗口过程

窗口过程 (WndProc, Window Procedure),就是该窗口用于处理消息的 “工厂”,在 Win32 里对应 WNDPROC,它一般长这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_CREATE:
// 准备创建窗口
return 0; // 表示已被处理

// 其他消息

case WM_DESTROY:
// 窗口已销毁
break; // 跳出 switch,走外面的语句

default:
return DefWindowProc(hWnd, uMsg, wParam, lParam); // 让默认的消息函数处理上方未处理的消息
}

return 0; // 被 break 跳到这里
}

在 WinForms 里,它长这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected virtual void WndProc(ref Message m)
{
switch (m.Msg)
{
case WM_CREATE:
// 准备创建窗口
m.Result = IntPtr.Zero; // 表示已被处理
return;

// 其他消息

case WM_DESTROY:
// 窗口已销毁
break; // 跳出 switch,走外面的语句

default:
m.Result = DefWindowProc(hWnd, uMsg, wParam, lParam); // 让默认的消息函数处理上方未处理的消息
return;
}

m.Result = IntPtr.Zero; // 被 break 跳到这里
}

可见尽管两者的函数签名不同,但内部都干着同样的事,当然以上代码仅供参考,实际会比这更复杂。

你知道吗?
随着现代 UI 技术的发展,很多软件已经不再使用 Win32/MFC/WinForms 这种套路来创建控件了,而是使用性能更好、外观更优雅的 UI 框架,比如 Material、Flutter、Electron、Tauri、Qt、WinUI、Avalonia 等。原因可能有:
    1. Win32 太丑了。但我觉得还好,我甚至认为有些现代 UI 框架还不如 Win32。
    2. 需要更高级的动画和 UX。Win32 是基于 GDI/GDI+ 的,也就是 CPU 渲染,但大多数情况下并不会遇到性能瓶颈,除非在需要渲染大量内容,才会出现各种忍无可忍的闪烁卡顿。而现代 UI 框架完美地解决了这一点,因为它们使用了 DirectX 渲染,也就是利用了 GPU 进行界面绘制。Win32 的动画由系统提供,虽然不如现代 UI 库那样更精美丝滑,但也还算看得过去。
    3. 跨平台支持。可悲的是,Win32 写的应用程序只能运行在 Windows 系统中,跨平台性直接无了。而现代 UI (比如 Avalonia) 完美的解决了这一问题,它们提倡 UI 与业务逻辑分离,让开发者不再担心 UI 的跨平台性,一套代码就能走遍天下所有平台系统。

如果你选择现代 UI 框架,需要知道:
    1. 控件就是控件,窗口就是窗口。现代 UI 框架消除了 Win32 中 控件即窗口,窗口即控件 的概念,也就是说现在一个窗口及内部总共就只有一个 WndProc,控件全部由所属窗口画上去的。但不变的是本质,毕竟 Windows 本身就是基于消息循环的,即便是再现代的 UI,只要运行在 Windows 上,是不会完全脱离消息循环的。
    2. 较大的体积和较高的内存占用。UI 框架本身也要消耗一定的性能。因为要重新设计几乎所有消息循环干的事情,以及自身的各种界面、动画、资源等,这无论是应用程序体积,还是运行时内存占用,都比 Win32 高上一个层次。但随着个人电脑的内存越来大,性能越来越高,多个几十百兆其实也不在话下。

钩子过程

钩子过程 (HookProc, Hook Procedure,也叫挂钩过程) 有两种含义,一是通过 SetWindowsHookEx 函数设置的全局钩子,二是 Common Dialogs 的 ColorDialog、FontDialog (CHOOSECOLOR、CHOOSEFONT) 提供的一个私有的类似于 WndProc 的东西,仅供自己使用,用于添加一些自定义的功能,与前者是两个不同的概念。本文主要讨论后者。

在 WinForms 里,它通常长这个样子,非常类似于 Win32 的 WndProc:

1
2
3
4
5
6
7
8
9
protected virtual IntPtr HookProc(IntPtr hWnd, int msg, IntPtr wparam, IntPtr lparam)
{
switch (msg)
{
// ... 处理各消息
}

return IntPtr.Zero; // 钩子过程最后一般都返回 0
}

子类化

子类化 (Subclassing) 的作用有点类似于钩子,只不过它多用于钩住窗口中的子控件。只有在通常用于无法通过常规手段更改控件样式或行为时使用。

因为上面说了,每个控件/窗口都有一个独立的 WndProc,而我们在窗口中处理控件样式/行为时只能获取到本窗体的 WndProc,虽然能收到一些发送给控件的消息,但有关绘制的消息就必须拦截控件自己的 WndProc,所以 子类化 就相当于在窗口中监听所有系统发送给控件的消息 (因为系统给控件发送消息基本不会经过所在窗体的 WndProc),从而进行更多自定义的功能。

在 Win32 中,我们通常使用 CommCtrl.h 提供的 SetWindowSubclass 和 RemoveWindowSubclass 来对窗体进行/移除子类化。

但在 WinForms 中,我们不需要导入这两个函数来子类化,.NET 为我们提供了类似子类化的功能,叫 NativeWindow 类。它继承自 Component,是对 Win32 窗口的低级封装 (只提供 WndProc,不提供或提供少量其他功能就叫低级封装),也就是传入 句柄 给 NativeWindow,我们相当于获取到了目标窗口的实例,而这个类也提供了 WndProc 虚方法,可供我们重写,来达到子类化的效果。

调用约定

调用约定 (CallingConvention),就是调用本机函数的方式 (好比你上网的方式,WiFi,移动数据等。简单知道有这么个东西就行了,不用纠结有什么区别,保持约定一致就行)。WinAPI 默认就是 StdCall,C# 里 Interop 的 [DllImport] 默认也是 StdCall,后面我们导出函数也要声明为 StdCall。

封送

封送 (Marshaling),指从非托管环境 (Unmanaged,即 Win32) 中将对象转换为托管环境 (Managed,即 C#) 对象的过程,以及反向转换的过程。常见的就是将返回值为 Win32 BOOL 的函数转换为 C# bool,一般这么写:

1
[return: MarshalAs(UnmanagedType.Bool)]

你知道吗?
BOOL 类型实际上是 int,也就是我们可以直接将 BOOL 返回类型的函数声明为 int,这样就可以不用添加 MarshalAs 了,但当我们需要判断真假的时候就有点麻烦了,由于没有 CLR 帮我们封送为 bool,我们在判断的时候需要手动书写 != 0(true),和 == 0(false),通常不推荐这么做。

准备工作

安装 C++

某些 Interop,我们交给 C++ 会更好,用 C# 的话过于麻烦甚至无法完成。所以需要用到 C++。

不要觉得难以维护什么的,毕竟需要同时编写 C# 和 C++。要知道 工欲善其事,必先利其器,这些都是写好一个软件的关键,有助于增强用户体验的我们怎么能不做呢?Visual Studio 里一个解决方案多个项目的也不少见,一个程序由多个语言配合完成的例子就更多了。

首先关闭 Visual Studio,打开 Visual Studio Installer,点击 修改。在弹出的页面中勾选 使用 C++ 的桌面开发

在右侧按图示勾选或取消勾选相应的组件,之后点击右下角修改,开始安装

安装完成后,打开 C# 项目,右键解决方案,选择 添加 >新项目

找到 动态链接库 (C++)

命名为 MyApp.Natives 然后创建就行了,这样我们的解决方案里就有两个项目了。

接着把 C++ 项目设置为 C# 的依赖项,可以使我们编译 C# 时,先编译 C++:右键 C# 项目,选择 构建依赖 >项目依赖

把 C++ 项目勾选上

当然这也要求两个项目的输出目录是一样的,这里我就只设置 C++ 了,因为 C# 的设置过了。右键 C++ 项目选择属性,第一个页面就是

为了后面编译 C++ 不会错误,我们先设置链接器。

右键 C++ 项目选择属性 >链接器 >输入 >编辑

填入这三个静态链接库,注意不要删除自带的那两个,最后点击保存

1
2
3
uxtheme.lib
comctl32.lib
ucrt.lib

然后找到这个 dllmain.cpp,右键把它删除,因为我们主要用于导出函数供 C# 调用,不需要指定入口

接着打开 pch.h,我们添加一个宏定义,方便我们导出调用约定为 StdCall 的函数

1
#define cexport(type) extern "C" __declspec(dllexport) type WINAPI

大概就先这样,留着后面要用。

C# 版本

由于面向 .NET Framework 4.8 或其他版本,且本文部分代码使用新特性写着比较舒服,建议将 C# 版本升级到 preview。

VSCode 或记事本打开项目文件 XXX.csproj,更改以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<LangVersion>preview</LangVersion> <!-- 新建一个 PropertyGroup 加入这行 -->
</PropertyGroup>
<!-- ... -->
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">
<!-- ... -->
<LangVersion>7.3</LangVersion> <!-- 若有,删除这行 -->
<!-- ... -->
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
<!-- ... -->
<LangVersion>7.3</LangVersion> <!-- 若有,删除这行 -->
<!-- ... -->
</PropertyGroup>
<!-- ... -->
</Project>

应用程序清单文件

用于确保我们可以正确获取到当前系统版本 (微软的吊特性),可以在 Visual Studio 右键 C# 项目的 Properties 文件夹,点击添加,选择 应用程序清单 (仅 Windows)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" /> <!-- 表示支持 Windows 7 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" /> <!-- 表示支持 Windows 8 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" /> <!-- 表示支持 Windows 8.1 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <!-- 表示支持 Windows 10/11 -->
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> <!-- 不想支持高 DPI 的话可以删除这段 -->
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware> <!-- 这是启用 Windows 的路径过长感知,保持默认即可 -->
</windowsSettings>
</application>
</assembly>

代码布局

推荐将所有有关主题的代码放在一个单独的静态类里,比如 ThemeManager:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class ThemeManager
{
public static bool CanUseDarkTheme { get; /* private set; */ } // 指示是否要用深色主题

public static Color DarkFore { get; } = Color.White; // 文字颜色
public static Color DarkForeLink { get; } = Color.FromArgb(153, 235, 255); // 超链接颜色
public static Color DarkBack { get; } = Color.FromArgb(32, 32, 32); // 背景颜色
public static Color DarkBorder { get; } = Color.FromArgb(60, 60, 60); // 边框颜色

// 系统构建号,用于版本判断
public static int OSBuild => field == 0 ? field = Environment.OSVersion.Version.Build : field; // 获取当前系统版本的构建号

public const int Windows10_1903 = 18362;
public const int Windows10_20H1 = 18985;

// 一些 WinAPI Interop,后面会介绍
}

窗体/控件的话最好单独继承出来,方便复用各自的主题:

1
2
3
4
public class MyFormBase : Form
{
// 让你应用程序所有窗体继承自 MyFormBase
}
1
2
3
4
public /* sealed */ class MyButton : Button // 其他控件也是,可选是否密封 sealed
{
// 之后直接使用自己封装的 MyButton 而不是 .NET 的
}

开始

下面我们就正式开始挨个对控件应用深色主题了。

Form

窗体的深色主题非常简单,只需要设置 ForeColor、BackColor 就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MyFormBase : Form
{
public MyFormBase()
{
if (ThemeManager.CanUseDarkTheme)
{
ForeColor = ThemeManager.DarkFore;
BackColor = ThemeManager.DarkBack;
}
}
}

public partial class Form1 : MyFormBase
{
public Form1()
{
InitializeComponent();
}
}

此时编译并打开 Form1,你会发现窗体标题栏没有变黑,这是因为 Form,即窗口,由系统管理,.NET 管不到,所以要引入 WinAPI。

转到 ThemeManager,添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private const int DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19; // ≥ 1903 且 ≤ 20H1
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; // 20H1 之后

public static void FlushWindow(IntPtr hWnd)
{
if (OSBuild >= Windows10_1903)
{
var type = OSBuild >= Windows10_20H1 ? DWMWA_USE_IMMERSIVE_DARK_MODE : DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1;
var enable = 1; // 1 表示启用,0 禁用
DwmSetWindowAttribute(hWnd, type, ref enable, sizeof(int));
}
}

[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hWnd, int dwAttribute, ref int pvAttribute, int cbAttribute);

回到 MyFormBase,重写 OnHandleCreated (表示当窗口句柄 (HWND) 创建时,此时可以拿到 Handle),并密封防止子类修改 (根据具体需求决定)

为什么要在 OnHandleCreated 方法里面调用 API,不能在构造函数里面吗?
其一,此时获取句柄是最好最安全的,如果在构造函数中获取句柄会导致其提前被创建,可能会有潜在的后果;其二,为了避免有时在运行过程中需要重绘导致句柄也重新创建的极端情况,所有推荐最好在 OnHandleCreated 里调用 API,不然一旦句柄重新创建,主题就没了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyFormBase : Form
{
// ...

protected sealed override void OnHandleCreated(EventArgs e)
{
if (ThemeManager.CanUseDarkTheme)
{
ThemeManager.FlushWindow(Handle);
}

base.OnHandleCreated(e);
}
}

最终效果:

Panel/Label/LinkLabel/PictureBox

这些控件都能跟随 Form 的前背景颜色,只不过 LinkLabel 要手动设置 LinkColor

1
2
3
4
5
6
7
8
9
10
11
12
public class MyLinkLabel : LinkLabel
{
public MyLinkLabel()
{
AutoSize = true;

if (ThemeManager.CanUseDarkTheme)
{
LinkColor = ThemeManager.DarkForeLink;
}
}
}

最终效果:

GroupBox

GroupBox 的表现有点奇怪,我们先来看看默认效果:

可见标题依旧是黑色的,但里面的 Label 却正常,这说明 ForeColor、BackColor 只对它的子控件生效,不包括标题的颜色。还有一个细节不知道你发现没有,这个边框的颜色太白了,使得整个控件显得有点复古,但边框颜色也不能更改,怎么办呢?

这种情况下就要请出我们万能的 自绘 了,全称 自定义绘制 (Owner Draw),也就是利用 GDI/GDI+ 自己画控件的样式,虽然听着麻烦,但实际也能接受。也不用担心会有性能问题,因为控件本来也是这么画出来的,只不过都是系统预设而已,我们自己画说不定比系统还高效。

首先我们创建一个 MyGroupBox 继承自 GroupBox

1
2
3
4
public sealed class MyGroupBox : GroupBox
{

}

然后重写 OnPaint 方法,这里参考了 @mikecodz2821 的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected override void OnPaint(PaintEventArgs e)
{
if (ThemeManager.CanUseDarkTheme)
{
var g = e.Graphics; // 获取 Graphics,用来画画
var client = ClientSize; // 获取 GroupBox 的客户区大小
var text = Text; // 获取标题
var font = Font; // 获取字体
var textHeight = font.Height / 2 + 2; // 获取标题的高度,即字体的高度,/2 +2 是让文字在中心线上再向上偏移 2px,
// 因为 GroupBox 的文字和边框上边缘本来就有偏移,没在竖直中心
var rect = new Rectangle(0, textHeight, client.Width, client.Height - textHeight); // 表示 GroupBox 的边框位置和大小
var textRect = Rectangle.Inflate(ClientRectangle, -4, 0); // 让标题向右偏移一点,不然太靠前了

ControlPaint.DrawBorder(g, rect, ThemeManager.DarkBorder, ButtonBorderStyle.Solid); // GDI+ 画边框,颜色用我们定义的,看起来是深灰色
TextRenderer.DrawText(g, text, font, textRect, ForeColor, BackColor, TextFormatFlags.Top | TextFormatFlags.Left | TextFormatFlags.LeftAndRightPadding | TextFormatFlags.EndEllipsis); // GDI 画文字,并使用设置的 ForeColor, BackColor
}
else
{
base.OnPaint(e); // 不是深色模式就交给系统来画
}
}

看看效果:

ContextMenu

不同于 ContextMenuStrip,ContextMenu 是 WinForms 对 Win32 原生快捷菜单的低级封装 (继承自 Component),外观也比 Strip 好看很多。本文只对 ContextMenu 的深色主题进行讨论,同时如果你对右键菜单没有放置特殊控件的需求的话,也建议迁移到 ContextMenu。

有关如何创建 ContextMenu,可以看看这篇文章。

ContextMenu 的深色主题可以用系统提供的 API,这是一个未公开的 API,不排除微软有可能在未来修改/删除它的可能,但现阶段没有问题,那我们就悄悄的用。调用之后整个应用程序范围内的 ContextMenu 全部都有深色主题了。

转到 ThemeManager,添加以下 WinAPI 以及枚举。由于这个函数没有公开,且通过一些手段发现它在 uxtheme.dll 中导出的序号为 135,故需要显式指定 EntryPoint,用 # 表示让 CLR (.NET 运行时) 将 135 当作序号去调用函数而不是名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static class ThemeManager
{
// ...

[DllImport("uxtheme.dll", EntryPoint = "#135")]
public static extern int SetPreferredAppMode(PreferredAppMode preferredAppMode);
}

public enum PreferredAppMode // 我们一般把枚举写在类的外面,避免嵌套,不然在访问的时候还要加类名
{
Default,
AllowDark,
ForceDark, // 一般用这个枚举,表示强制将应用程序主题设置为深色,值为 2
ForceLight,
Max
}

转到程序的入口 (Program.cs),在 Main 方法中的 Application 系列方法之前调用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
internal static class Program
{
[STAThread]
private static void Main()
{
if (ThemeManager.CanUseDarkTheme)
{
ThemeManager.SetPreferredAppMode(PreferredAppMode.ForceDark);
}

Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}

右键窗体标题栏就能看到了,这是最终效果:

Button

按钮的深色主题的应用就简单很多了,只需在 ThemeManager 里添加以下 WinAPI

1
2
[DllImport("uxtheme.dll", CharSet = CharSet.Unicode)] // Unicode 表示传入的字符串都被当作 宽字符 处理
public static extern int SetWindowTheme(IntPtr hWnd, string pszSubAppName, string pszSubIdList);

然后创建 MyButton 继承自 Button,重写 OnHandleCreated

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public sealed class MyButton : Button
{
public MyButton(ContextMenu menu)
{
FlatStyle = FlatStyle.System; // 让按钮使用系统样式
UseVisualStyleBackColor = true;
}

protected override void OnHandleCreated(EventArgs e)
{
if (ThemeManager.CanUseDarkTheme)
{
ThemeManager.SetWindowTheme(Handle, "DarkMode_Explorer", null);
}

base.OnHandleCreated(e);
}
}

“DarkMode_Explorer” 是系统内置的 (位于 aero.msstyles 文件) 包含深色 Button 的主题的名称;第三个参数按理来说应该传入 “Button”,但传入 null 会让 API 自己去判断用哪个,所以也可以直接传 null。

你知道吗?
msstyleEditor 是专门用于解析和修改 *.msstyles 文件的工具,我们可以通过该软件来查看带有 DarkMode_ 前缀的类名,以及它支持的控件

看看效果:

RadioButton/CheckBox

这两个控件其实也和 Button 差不多,但在禁用状态时,文字会看不清,可能是 WinForms 封装时某些代码干扰了主题运作,所以就单独提出来了。

由于这两个控件在 WinForms/Win32 中都继承自 ButtonBase/Button,所以我们可以创建一个辅助类来侧载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class MyButtonBase // 注意,不用显式继承自任何基类
{
private readonly ButtonBase Target;

public MyButtonBase(ButtonBase target)
{
target.UseVisualStyleBackColor = true;

if (ThemeManager.CanUseDarkTheme)
{
Target = target;
Target.EnabledChanged += Button_EnabledChanged; // 绑定启用状态更改事件
UpdateStyle();
}
}

private void Button_EnabledChanged(object sender, EventArgs e)
{
UpdateStyle();
}

private void UpdateStyle()
{
Target.FlatStyle = Target.Enabled ? FlatStyle.Standard : FlatStyle.System;
ThemeManager.SetWindowTheme(Target.Handle, "DarkMode_Explorer", null);
// 根据多次试验,我发现当启用时使用 Standard 主题,禁用时用 System 主题可以有效解决文字看不清的问题
// 记得要再次调用 API 刷新主题。
}

~MyButtonBase()
{
if (Target != null)
{
Target.EnabledChanged -= Button_EnabledChanged; // 析构函数解绑事件,也可以不用
}
}
}

同样我们也需要创建 MyRadioButton 和 MyCheckBox,然后在构造函数中直接创建辅助类实例就完成了

1
2
3
4
5
6
7
public sealed class MyRadioButton : RadioButton
{
public MyRadioButton()
{
new MyButtonBase(this);
}
}
1
2
3
4
5
6
7
public sealed class MyCheckBox : CheckBox
{
public MyCheckBox()
{
new MyButtonBase(this);
}
}

看看效果:

你会发现在禁用状态下,文字会更偏向左侧,但这在 Win32 中是没有的,WinForms 的话也只能暂时这样了。

TextBox/ComboBox

这两玩意儿也比较好搞,不过除了需要调用 API,还需要设置 ForeColor、BackColor。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public sealed class MyComboBox : ComboBox
{
public MyComboBox()
{
DropDownStyle = ComboBoxStyle.DropDownList; // 表示 ComboBox 应具有下拉菜单
FlatStyle = FlatStyle.System;
}

protected override void OnHandleCreated(EventArgs e)
{
if (ThemeManager.CanUseDarkTheme)
{
ForeColor = ThemeManager.DarkFore;
BackColor = ThemeManager.DarkBack;
ThemeManager.SetWindowTheme(Handle, "DarkMode_CFD", null);
}

base.OnHandleCreated(e);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public sealed class MyTextBox : TextBox
{
protected override void OnHandleCreated(EventArgs e)
{
if (ThemeManager.CanUseDarkTheme)
{
ForeColor = ThemeManager.DarkFore;
BackColor = ThemeManager.DarkBack;
ThemeManager.SetWindowTheme(Handle, "DarkMode_CFD", null);
}

base.OnHandleCreated(e);
}
}

注意包含它们两个的深色主题的名称是 “DarkMode_CFD”。

看看效果:

TreeView/TabControl

可能大家很少用这个控件,这里提出来也是利用它作为垂直导航栏来代替 TabControl,因为 TabControl 的深色主题不好搞,详见 摆脱 TabControl,教你打造高颜值原生样式的垂直导航栏

当然我们也可以直接用,首先来创建 MyTreeView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public sealed class MyTreeView : TreeView
{
protected override void OnHandleCreated(EventArgs e)
{
if (ThemeManager.CanUseDarkTheme)
{
ForeColor = ThemeManager.DarkFore;
BackColor = ThemeManager.DarkBack;
ThemeManager.SetWindowTheme(Handle, "DarkMode_Explorer", null);
}

base.OnHandleCreated(e);
}
}

看看效果:

NumericUpDown

NumericUpDown 是本文中遇到的第一个复合控件,也就是说它由两个控件组成:UpDownEdit 和 UpDownButtons,当然这两个类型是专有的,所以我们不能在公开类型里找到。

但这并不代表我们不能通过代码获得它们的实例,当然也不需要反射。想想,如果是你,要将多个控件放在一个容器里面,你会怎么做?

是不是往 Controls 集合里塞就对了?所以我们直接遍历 NumericUpDown 的 Controls 集合就能看到它俩了。

首先老规矩,创建 MyNumericUpDown,设置必要属性,重写 OnHandleCreated:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public sealed class MyNumericUpDown : NumericUpDown
{
public MyNumericUpDown()
{
if (ThemeManager.CanUseDarkTheme)
{
ForeColor = ThemeManager.DarkFore;
BackColor = ThemeManager.DarkBack;
}

TextAlign = HorizontalAlignment.Right;
}

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

我们先试着在 OnHandleCreated 里遍历一下,看看有没有收获。顺便在 foreach 这里打个断点

1
2
3
4
5
6
7
8
9
protected override void OnHandleCreated(EventArgs e)
{
foreach (Control control in Controls) // <- 断点
{

}

base.OnHandleCreated(e);
}

看看效果

可见确实由两个控件组成,分别是 UpDownButtons 和 UpDownEdit。

那我们就直接应用主题。

1
2
3
4
foreach (Control control in Controls) // <- 断点
{
ThemeManager.SetWindowTheme(control.Handle, "DarkMode_Explorer", null);
}

其实 DarkMode_CFD 也可以,但只对 UpDownEdit 生效,使用 DarkMode_Explorer 的话两者都可以被应用深色主题。

看看效果:

注意,Windows 11 之前的系统,UpDownButtons 没有深色主题 (DarkMode_Explorer::Spin) 是正常现象。

ListView

ListView 就比较复杂了,因为它也是复合控件,标题栏就是一个控件 (SysHeader32),列表就是 ListView 本身,我们还是按照老办法来看看 Controls 集合。

先创建 MyListView,设置必要属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public sealed class MyListView : ListView
{
public MyListView()
{
View = View.Details; // 列表详细视图
FullRowSelect = true; // 整行选择
HideSelection = false; // 失去焦点不隐藏选中项
ShowItemToolTips = true; // 内容超出范围显示 ToolTip
BorderStyle = BorderStyle.None; // 不显示白色边框,这个看具体需求

if (ThemeManager.CanUseDarkTheme)
{
ForeColor = ThemeManager.DarkFore;
BackColor = ThemeManager.DarkBack;
}

SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true);
// 开启双缓冲,防止闪烁与卡顿
}

protected override void OnHandleCreated(EventArgs e)
{
var b = Controls; // <- 打断点

if (ThemeManager.CanUseDarkTheme)
{
ThemeManager.SetWindowTheme(Handle, "DarkMode_ItemsView", null);
}

base.OnHandleCreated(e);
}
}

注意 ListView 的主题名称应该是 DarkMode_ItemsView。

看看 Controls 集合:

这不天塌了?Controls 里啥也没有,标题栏根本就没在 Controls 里,为什么?

这是因为标题栏是 ListView 底层动态创建的控件,WinForms 根本没有封装,自然就看不到了。那我们怎么看到?

还记得我们安装的 C++ 吗,它会自带 Spy++,我们可以用这个工具探测一下,在 Visual Studio 工具栏:工具 >Spy++

这不,它在这呢。同时你也可以看到,标题栏根本没有被应用主题。

显然,如果能获取到标题栏的句柄的话,一切都好办了。你可能会想到用 EnumChildWindows 函数枚举子窗口,只能说有思路了,但不够。

如果你在搜索引擎里搜索 listview get header,你会发现:

好家伙,居然有一个跟关键词一模一样的函数 (其实是宏),这也太棒了。进去看看。

1
2
3
4
返回值
类型:HWND

返回标头控件的句柄。

看到这行描述没有?—— 返回标头控件的句柄。

那么这个宏是怎么实现的呢?其实就是用 SendMessage 给 ListView 发了一个 LVM_GETHEADER 消息,然后该函数的返回值就是标题栏的句柄。

可能这对刚接触消息循环的朋友比较难懂,那我们来仿生一下:

1
2
3
4
5
6
7
8
9
(你调用了 SendMessage ...)

SendMessage: @ListView,交出你标题栏的句柄。
(SendMessage 向 ListView 的 HWND 发送了 LVM_GETHEADER 消息 ...)

ListView: 收到,哥们儿!
(ListView 内部获取标题栏的 HWND 并传给了 SendMessage ...)

(你拿到了 SendMessage 的返回值,它就是标题栏的 HWND)

上述操作在 WinAPI 里很常见,需要新手朋友们加深理解,以及适应这种机制。

那我们就来声明导入函数和相关消息

1
2
3
4
5
private const int LVM_FIRST = 0x1000;
private const int LVM_GETHEADER = LVM_FIRST + 31;

[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);

接着在 OnHandleCreated 里,我们就可以这样写

1
2
3
4
5
6
7
8
9
10
protected override void OnHandleCreated(EventArgs e)
{
if (ThemeManager.CanUseDarkTheme)
{
ThemeManager.SetWindowTheme(Handle, "DarkMode_ItemsView", null);
ThemeManager.SetWindowTheme(SendMessage(Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero), "DarkMode_ItemsView", null);
}

base.OnHandleCreated(e);
}

看看效果

有点尴尬,你就说有没有主题吧,只是文字没有适应罢了。

那我们还得继续,怎么办呢?

通过参考 ysc3839/win32-darkmode相关代码。虽然看起来工程稍微有点大,不过没关系。原理挺简单的,作者子类化了 ListView 拦截 WM_NOTIFY,然后用一种自绘方式,来更改标题栏文字颜色。这种自绘方式不同于重写 OnPaint (WM_PAINT) 那样一棍子打死,直接从零开始,什么都要自己画,前者更加高明,允许自绘部分区域,也是微软推荐的方式。

引入必要的结构和消息 (可以通过 winuser.h 和 commctrl.h 找到)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 写在 MyListView 外部

[StructLayout(LayoutKind.Sequential)]
public struct NMHDR // winuser.h
{
public IntPtr hWndFrom;
public IntPtr idFrom;
public int code;
}

[StructLayout(LayoutKind.Sequential)]
public struct NMCUSTOMDRAW // commctrl.h
{
public NMHDR hdr;
public int dwDrawStage;
public IntPtr hdc;
public RECT rc;
public IntPtr dwItemSpec;
public uint uItemState;
public IntPtr lItemlParam;
}

[StructLayout(LayoutKind.Sequential)]
public struct RECT // windows.h (windef.h)
{
public int Left;
public int Top;
public int Right;
public int Bottom;

public static implicit operator Rectangle(RECT r)
// 添加隐式转换为 Rectangle
{
return Rectangle.FromLTRB(r.Left, r.Top, r.Right, r.Bottom);
}
}

[StructLayout(LayoutKind.Sequential)] 是什么?
该特性常见于用于 Interop 的 struct 类型上,有助于 CLR 从非托管环境中将对象封送到托管环境并映射为具体类型,它只作用于 struct 中的字段,也就是为什么我们在里面写了一个运算符重载也不会出错,因为 CLR 会自动忽略除字段以外的成员。甚至你还会发现有的开发者会直接用自动属性代替,这是因为自动属性本质上就是字段+方法,CLR 也能识别到。由于 struct 本身在内存中的布局是连续的 (Sequential),并且是无明确类型的,也就是说我们不一定非得用 RECT 这个名称,其他也可以,只要确保字段的顺序与 Win32 定义的一致就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 写在 MyListView 内部

// winuser.h
private const int WM_NOTIFY = 0x004E;

// commctrl.h
private const int NM_CUSTOMDRAW = -12;
private const int CDDS_PREPAINT = 0x00000001;
private const int CDRF_NOTIFYITEMDRAW = 0x00000020;
private const int CDDS_ITEMPREPAINT = 0x00010000 | CDDS_PREPAINT;
private const int CDRF_DODEFAULT = 0x00000000;

[DllImport("user32.dll")]
public static extern int SetTextColor(IntPtr hdc, int color);

那我们需要像 ysc3839 那样子类化吗?仔细思考

肯定不用啊,我们本来就在 MyListView 内部,重写 WndProc 就相当于子类化了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected override void WndProc(ref Message m)
{
if (ThemeManager.CanUseDarkTheme && m.Msg == WM_NOTIFY &&
Marshal.PtrToStructure<NMHDR>(m.LParam).code == NM_CUSTOMDRAW)
{
var nmcd = Marshal.PtrToStructure<NMCUSTOMDRAW>(m.LParam);

switch (nmcd.dwDrawStage)
{
case CDDS_PREPAINT:
m.Result = new(CDRF_NOTIFYITEMDRAW);
return;
case CDDS_ITEMPREPAINT:
Natives.SetTextColor(nmcd.hdc, ThemeManager.DarkForeHeader);
m.Result = new(CDRF_DODEFAULT);
return;
}
}

base.WndProc(ref m);
}

看看效果

你以为就完了吗,如果我们把内容弄长一点,然后鼠标放上去,你就会发现…

惊不惊喜,意不意外!还有一个控件呢!

这是什么?它的学名叫 ToolTip,用于… emmm … 反正就长这样,但它实际上是一个窗口。那么这个玩意儿的句柄怎么获取,然后应用深色主题呢?老样子,先看文档,文档没有再想其他办法。

转到我们之前找到的 ListView_GetHeader 的页面,看看左侧,这是什么?你不觉得对它有个很微妙的感觉吗,感觉有什么好东西就要来了?

直接点进去,看看它是干什么的

1
2
3
4
返回值
类型:HWND

返回工具提示 (ToolTip) 控件的句柄。

看吧,果然,这不就是我们想要的?开搞!

定义一个 LVM_GETTOOLTIPS 消息,然后回到 OnHandleCreated

1
2
3
4
5
6
7
8
9
10
11
12
13
private const int LVM_GETTOOLTIPS = LVM_FIRST + 78;

protected override void OnHandleCreated(EventArgs e)
{
if (ThemeManager.CanUseDarkTheme)
{
ThemeManager.SetWindowTheme(Handle, "DarkMode_ItemsView", null);
ThemeManager.SetWindowTheme(SendMessage(Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero), "DarkMode_ItemsView", null);
ThemeManager.SetWindowTheme(SendMessage(Handle, LVM_GETTOOLTIPS, IntPtr.Zero, IntPtr.Zero), "DarkMode_Explorer", null);
}

base.OnHandleCreated(e);
}

看看效果:

你以为又完了吗?如果我们把 ListView 的列和行多弄几个,这里就缩小窗体及 ListView 来模拟同样的效果。你会发现…

是不是有点心肺骤停,这俩滚动条 (H/VScrollBar) 太刺眼了,这可怎么办呢?先来看看 Spy++ 有没有探测到

完辣!简直就是道高一尺魔高一丈,我们每想个新方法总能遇到把我们打回原形的情况。这俩小白条压根就没句柄,那我们也没必要指望什么 ListView_GetH/VScrollBar 了,因为微软也没有提供相关的宏。

但要知道,在标准 Win32 控件里,ScrollBar 是个独立的控件,拥有 HWND。那这是怎么回事呢?

因为这两小白条是 ListView 的 “私生子”,是被 ListView 画出来的,压根就没有走创建控件的方式,自然就没有 HWND 了。

那这要怎么办呢?这就涉及到更底层的原理了,如果我们将控件的绘制想象为贴图的渲染,那这些贴图总得有个来源吧。所以我们要修改内部贴图?不,这工作量太大了。我们可以劫持!用 IAT Hook。这个 Hook 的工作原理通俗来说就是在对方毫不知情的情况下进行偷梁换柱。当 ListView 访问滚动条的贴图时,我们给它来个重定向,让它去画有深色主题的滚动条。

可能对于新手朋友来说理解起来又有些吃力了,那我们再来仿生一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(ListView 来到贴图商店 ...)

ListView: 老板,给我来一份 ScrollBar 的贴图
(ListView 调用了一个未公开的 API)

UxTheme: 好的,给你 Explorer::ScrollBar
(这是正常情况,但现在我们通过 IAT Hook 黑了老板的号,于是就变成了...)

IAT_Hook: (小声) @Natives (我们的 DLL),ListView 来买 ScrollBar 了,快把新老板给我,我帮你替换

Natives: 收到!@IAT_Hook
(我们让 IAT Hook 更换了商店的老板,这个老板只听我们的话,且让新老板站在旧老板的位置)

UxTheme (Hacked): 好的,给你 DarkMode_Explorer::ScrollBar

大概就是这样,具体实现,我们放到下一个专题。

ScrollBar

ScrollBar,即滚动条,是标准的 Win32 控件 (也就是有 HWND),WinForms 里也有封装 (HScrollBar 和 VScrollBar)。在创建 ScrollBar 时,我们可以通过指定 SBS_HORZ 或 SBS_VERT 来决定滚动条的朝向。

如果能获取到句柄的话,那我们直接调用 SetWindowTheme 函数设置为 DarkMode_Explorer 主题就完事了,那没有句柄呢?

没有句柄的情况常见于多数可滚动的控件 (比如上方提到的 ListView、TreeView),它们的滚动条都是自己画的,而不是创建的标准控件,是不具有 HWND 的。

插播一条,你有没有发现 TreeView 的滚动条在我们处理后就直接变深色了,而 ListView 却不行?因为 TreeView 也是用的是 DarkMode_Explorer 主题,这个主题是包括滚动条的,而 ListView 使用的 DarkMode_ItemsView 是不包括滚动条的,自然就没有深色主题了。

这种情况下,要想应用深色主题,就要先明白它们是通过怎么样的方式获取到贴图。

通过一些手段,我们发现在 uxtheme.dll 的第 49 个入口,导出了一个叫 OpenNcThemeData 的函数,这个函数就是用来获取控件的非客户区的主题的数据的,其中就包括了滚动条。

如果能监测应用程序访问 OpenNcThemeData 的话,我们就能进行偷梁换柱,让应用程序获取到 DarkMode_Explorer 里的 ScrollBar。

这个监测手段就叫 IAT Hook,它的作用是修改目标模块的导入表,把原本指向某个 API 的函数指针替换成我们定义的钩子函数。

具体怎么实现呢?这里我们又参考了 ysc3839/win32-darkmode代码

我们下载并导入 (右键 Natives 项目,选择 添加 >现有项) ysc3839 大佬提供的头文件

然后再次右键我们的项目,选择添加 >新项

选择头文件 (*.h),命名为 ListViewHelper

头文件第一行默认就是 #pragma once,它是用于防止多次引用的,不要删除

同样方式我们添加一个源文件 (*.cpp),也命名为 ListViewHelper,并加上预编译头 (Visual Studio 规定的) 和我们的 ListViewHelper.h

转到 ListViewHelper.h,添加一个导出函数,表示 修复滚动条(的深色主题):

1
cexport(void) FixScrollBar();

转到 ListViewHelper.cpp,添加 OpenNcThemeData 函数指针和 FixScrollBar 主体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include "pch.h"
#include <Windows.h>
#include <Uxtheme.h>
#include "ListViewHelper.h"
#include "IatHook.h"

using fnOpenNcThemeData = HTHEME (WINAPI*)(HWND hWnd, LPCWSTR pszClassList);
fnOpenNcThemeData OpenNcThemeData = nullptr;

void FixScrollBar()
{
HMODULE hUxtheme = LoadLibraryEx(L"uxtheme.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
HMODULE hComctl = LoadLibraryEx(L"comctl32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
// 动态加载 uxtheme.dll 和 comctl32.dll (这个是 Win32 控件库)

if (hUxtheme)
{
auto addr = GetProcAddress(hUxtheme, MAKEINTRESOURCEA(49));

if (addr)
{
OpenNcThemeData = reinterpret_cast<fnOpenNcThemeData>(addr);
// 将指针指向 49 号入口,我们就能直接调用 OpenNcThemeData 了,这就类似于我们在 C# Interop 指定 DllImport #49
}
}

if (hComctl)
{
auto* addr = FindDelayLoadThunkInModule(hComctl, "uxtheme.dll", 49);

if (addr)
{
DWORD oldProtect;

if (VirtualProtect(addr, sizeof(IMAGE_THUNK_DATA), PAGE_READWRITE, &oldProtect))
{
auto MyOpenThemeData = [](HWND hWnd, LPCWSTR classList) -> HTHEME
{
if (wcscmp(classList, L"ScrollBar") == 0)
{
hWnd = nullptr;
classList = L"DarkMode_Explorer::ScrollBar";
}

// 偷梁换柱,检测是否访问了名为 ScrollBar 的主题,若是则替换为 DarkMode_Explorer::ScrollBar

return OpenNcThemeData(hWnd, classList);
};

addr->u1.Function = reinterpret_cast<ULONG_PTR>(static_cast<fnOpenNcThemeData>(MyOpenThemeData));
VirtualProtect(addr, sizeof(IMAGE_THUNK_DATA), oldProtect, &oldProtect);
}
}
}
}

转到 ThemeManager.cs,声明导入函数

1
2
[DllImport("Natives.dll")]
public static extern void FixScrollBar();

转到程序的入口 (Program.cs),在我们之前设置 ContextMenu 深色主题的那个地方之前调用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
internal static class Program
{
[STAThread]
private static void Main()
{
if (ThemeManager.CanUseDarkTheme)
{
ThemeManager.FixScrollBar();
ThemeManager.SetPreferredAppMode(PreferredAppMode.ForceDark);
}

Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}

看看效果:

有点匪夷所思了呢,回头看看我们的代码有没有漏写什么?

… 加载了 uxtheme.dll 和 comctl32.dll,然后找入口,OpenNcThemeData 函数指针也理论上来说不是 nullptr … 没有问题啊,怎么回事呢?

于是你打开了某工具 (我常用 System Informer),看看我们的应用程序是否加载了相应的 dll。

我们发现 uxtheme 已加载了,但 … 怎么有两个 comctl32?

展开文件路径,发现两个 comctl32 的版本都不一样,一个 v6 一个 v5!

这又是什么情况呢?经查询,我们得知:

  • WinForms 的 Application.EnableVisualStyles() 会使 Win32 控件具有现代样式,也就是要加载 CommCtrl v6,否则就是复古样式 (v5)
  • 传统 Win32 应用程序中,要想达到和 WinForms 一样的效果,必须要在清单里声明 CommCtrl v6

也就是说那个 v5 是我们的 FixScrollBar 函数加载的,钩子都钩错地方了,我们预期要钩住 v6 的。

打开我们之前为我们 C# 程序添加的 app.manifest 文件,添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">

<!-- 添加以下的,其他不变 -->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>

</assembly>

下面就是见证奇迹的时刻:

简直可以说是完美!ListView 的深色主题就到此为止了

另外,要注意的是 FixScrollBar 的作用范围是整个应用程序内的滚动条,而不是只有这个 ListView。

ColorDialog/FontDialog

这两个对话框大家都不陌生吧,就是系统内置的挑选颜色、字体的对话框。你有没有想过更改它们的样式或者行为呢?

可能你想过,但你发现它们并没有 Handle 属性,也就是获取不到 HWND,就什么也干不了,于是你放弃了这一想法。

今天,在这里,我们可以把这个想法变成现实。

打开微软文档,查询 CommonDialog,我们发现该类提供了一个虚方法:

1
HookProc(IntPtr hWnd, int msg, IntPtr wparam, IntPtr lparam);    // 定义要重写的通用对话框挂钩过程,以便向通用对话框添加特定功能。

并且这个方法在 ColorDialog 和 FontDialog 里面也并没有声明为 sealed,也就是说我们可以进一步重写。更关键的是,看到 hWnd 参数了吗,它在此处就是对话框的句柄。

所以我们可以重写 HookProc 来拿到句柄,而不是用什么 Handle 属性。

在重写之前,我们先新建一个辅助类,因为这两个对话框处理方式是一样的,命名为 CommonDialogHelper。该类具有一个构造函数,允许我们传入当前对话框实例,以及 HookProc

1
2
3
4
5
6
public class CommonDialogHelper(CommonDialog dialog, MyFormBase parent, WNDPROC DefHookProc) // C# 12 的新特性 主构造函数,允许我们声明类的同时声明构造函数
{
// DefHookProc 表示默认的 (Default) HookProc,我们依然要保留它,后面会用到
}

public delegate IntPtr WNDPROC(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); // WNDPROC 委托,写在类的外面

为什么要用委托?
因为我们这里要传入对话框的 HookProc,直接写的话相当于执行这个方法并拿到返回值,但实际上,我们只是想封装这个方法,还不急着执行。所以就要用到委托了,委托就可以帮我们封装方法,等我们需要执行的时候再拿出来执行。

为什么 WNDPROC 要全大写?
一是因为使用 WndProc 会产生歧义 (重名了),二是这在 Win32 里定义也是 WNDPROC

为什么要用 WNDPROC 封装 HookProc 而不是用 HOOKPROC?好怪哦
因为 Font/ColorDialog 提供的不算真正意义上的 HookProc,它的作用更像是 WndProc,文章基础知识提到过。

因此我们的辅助类还要提供一个开放的 HookProc 来供对话框自己调用:

1
2
3
4
public IntPtr HookProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
{
return IntPtr.Zero;
}

接着我们创建 MyColorDialog 和 MyFontDialog,重写 HookProc 返回我们自己的 HookProc,并设置一些参数,让对话框更易使用,以及加入我们的辅助类实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public sealed class MyColorDialog : ColorDialog
{
private CommonDialogHelper Helper;

public MyColorDialog()
{
AllowFullOpen = true;
FullOpen = true;
}

public DialogResult ShowDialog(MyFormBase parent)
{
Helper = new(this, parent, HookProc);
return ShowDialog();
}

protected override IntPtr HookProc(IntPtr hWnd, int msg, IntPtr wparam, IntPtr lparam)
{
return Helper.HookProc(hWnd, msg, wparam, lparam);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public sealed class MyFontDialog : FontDialog
{
private CommonDialogHelper Helper;

public MyFontDialog()
{
AllowVerticalFonts = false;
FontMustExist = true;
ScriptsOnly = true;
ShowEffects = false;
}

public DialogResult ShowDialog(MyFormBase parent)
{
Helper = new(this, parent, HookProc);
return ShowDialog();
}

protected override IntPtr HookProc(IntPtr hWnd, int msg, IntPtr wparam, IntPtr lparam)
{
return Helper.HookProc(hWnd, msg, wparam, lparam);
}
}

那我们在 HookProc 里应该写什么呢?这里参考了 System Informer 的代码

我们得知,当对话框创建时,会有一个 WM_INITDIALOG 消息,当对话框设置控件颜色时,会有 WM_CTLCOLOR* 系列消息,此时的返回值表示带有背景颜色的画刷。

这里我们就不用 C++ 了,因为 HookProc 在 C# 侧就有实现了,不然整个调用链就太长了。所以我们需要在 C# 里定义这些消息。

消息的具体值怎么找?还记得我们安装的 Windows SDK 吗,我们直接打开 Everything 搜索 winuser.h,打开后查找它们,将这些宏定义转写为 C# 常量字段:

1
2
3
4
5
6
private const int WM_INITDIALOG = 0x0110;
private const int WM_CTLCOLORDLG = 0x0136;
private const int WM_CTLCOLOREDIT = 0x0133;
private const int WM_CTLCOLORSTATIC = 0x0138;
private const int WM_CTLCOLORLISTBOX = 0x0134;
private const int WM_CTLCOLORBTN = 0x0135;

以及声明导入函数,它们是处理 WM_CTLCOLOR* 的四件套

1
2
3
4
5
6
7
8
9
10
11
[DllImport("gdi32.dll")]
private static extern int SetBkMode(IntPtr hdc, int mode);

[DllImport("gdi32.dll")]
private static extern int SetBkColor(IntPtr hdc, int color);

[DllImport("gdi32.dll")]
private static extern int SetTextColor(IntPtr hdc, int color);

[DllImport("gdi32.dll")]
private static extern IntPtr CreateSolidBrush(int color);

由于 CreateSolidBrush 会产生一个句柄,我们需要在对话框关闭的时候清理这些资源:

1
2
3
4
5
private const int WM_DESTROY = 0x0002; // 表示对话框被销毁 (关闭)

[DllImport("gdi32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeleteObject(IntPtr hObject);

那怎么处理 WM_INITDIALOG 呢?我们应该在这里完成对控件主题的应用,可是 CommonDialog 并未提供 Controls 集合让我们遍历所有控件。

这就要用到 EnumChildWindows 函数了,以及 GetClassName 让我们判断是什么控件:

1
2
3
4
5
6
7
8
9
10
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumChildWindows(IntPtr hWndParent, EnumChildProc lpEnumFunc, IntPtr lParam);

[UnmanagedFunctionPointer(CallingConvention.StdCall)] // 表示函数指针:C# 的委托对应的就是 C++ 的函数指针,StdCall
[return: MarshalAs(UnmanagedType.Bool)]
private delegate bool EnumChildProc(IntPtr hWnd, IntPtr lParam);

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);

现在就可以来完成 HookProc 了,首先缓存一些字段,防止重复计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private readonly StringBuilder builder = new(256); // 用于作为获取类名时的 动态字符串 的缓冲区,大小一般 256
private readonly IntPtr hBrush = CreateSolidBrush(BackColor);
private readonly static bool CanUseDarkTheme = ThemeManager.CanUseDarkTheme;
private readonly static int ForeColor = ColorTranslator.ToWin32(ThemeManager.DarkFore);
private readonly static int BackColor = ColorTranslator.ToWin32(ThemeManager.DarkBack);

public IntPtr HookProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
{
switch (msg)
{
case WM_INITDIALOG:

if (CanUseDarkTheme)
{
ThemeManager.FlushWindow(hWnd);

EnumChildWindows(hWnd, (child, _) => // 这就是 Win32 枚举子窗口的用法,难李姐的话下面会解释
{
ThemeManager.SetWindowTheme(child, GetThemeName(child), null);
return true; // 继续枚举,不要停
}, IntPtr.Zero);
}

break;

case WM_CTLCOLORDLG:
case WM_CTLCOLOREDIT:
case WM_CTLCOLORSTATIC:
case WM_CTLCOLORLISTBOX:
case WM_CTLCOLORBTN:

if (CanUseDarkTheme)
{
SetBkMode(wParam, 1);
SetTextColor(wParam, ForeColor);
SetBkColor(wParam, BackColor);
return hBrush;
}

break;
}

return IntPtr.Zero;
}

private string GetThemeName(IntPtr child) // 判断控件类型以及返回主题名称
{
GetClassName(child, builder, 256);
var className = builder.ToString();

if (className == "ComboBox" || className == "Edit") // 表示 ComboBox 和 TextBox
{
return "DarkMode_CFD";
}

return "DarkMode_Explorer"; // 其他就默认 Explorer 主题
}

字符串的比较
使用 == 运算符比较字符串是较为高效的,相当于调用 a.Equals(b, StringComparison.Ordinal),即通过序号来比较字符串。要注意的是该比较方式是要区分大小写的,不过在这里也不用担心会匹配不到控件,因为像 CommonDialog 这种标准 Win32 对话框,其中控件的类名也都是标准的。

EnumChildWindows 枚举方式
可能对于新手朋友们来说,这种枚举 (或者说遍历) 方式有点难以理解,毕竟没有出现 for 或 foreach 之类的字眼。首先我说 (child, _) => { } 是在封装一个方法没问题吧?(委托的简单理解,本节开始也讲过) 该委托 (函数指针) 被传入 Win32 之后,Win32 会在内部进行 for 循环或类似的其他循环方式,每一次循环都会调用一次这个委托,仅此而已。还是不能理解的话,我们 C# 也有类似的遍历方式,可以去用一下List<>提供的 ForEach方法,你就悟了。那 return true 是什么呢,这就类似于 for 循环里的那个 bool 语句 for (; i < count;),为 true 就继续遍历,false 就退出。如果还需要了解 EnumChildWindows 的内部实现,可以参考我写的 EnumSystemDisplays 枚举屏幕的函数,里面就通过回调函数的返回值来决定是否进行下一轮枚举,传送门

(child, _) => Lambda 弃元
这个属于 C# 基础知识了,我简单说一下,不能理解的建议上网查询。Lambda 是什么?写委托的表达式,也就是 => 这个东西,用于封装方法。弃元是什么?就是这个 _,在这里我们可以简单立即为丢弃或者说忽略方法的参数,表示这个参数不重要,我们不用了,就是这样的。但严格意义上来说,再 Lambda 表达式中,至少两个 _ 才算弃元。但不管真弃元还是假弃元,最终在编译后都会生成与委托签名一致的方法,_ 只是作为参数名而已,是面向开发者的一种特性,方便理解的。但尽管这样,我们也使用了 _ 来表示不需要这个参数,虽然这么做不是弃元,因为 _ 还是调用得到的,但无伤大雅,不行你试试。

看看效果:

一气呵成,简直不要太爽。

但,对于 FontDialog,瑕疵貌似有点多。特别是那几个发白的控件,目前说实话我也没有办法解决,因为它们都是已经自绘过的了,这就好比修电脑的师傅喜欢修一手的一样。当控件已经自绘之后,再次自绘,要么工作量巨大,要么也不会有好结果,那这个问题我们就放在这,以后有思路再来搞一下。

我们还是先搞一下那个 GroupBox,因为它看起来还能抢救一下,就显示字体预览的那个,可以看到它标题的文字是没有适应深色主题的。

是不是有点似曾相识?针对 GroupBox 标题不适应主题的情况我们前面是不是分析过?也就是重写 OnPaint 自己画边框和文字就解决的。但有个大前提,得在托管环境下。然而这个对话框是 Win32 的,属于非托管环境,应该怎么办呢?

想想我们当时是不是创建了 MyGroupBox 来重写 OnPaint?

也就是说在这里,我们需要对它进行子类化,不能在 HookProc 里直接处理 WM_PAINT,那是发给对话框的,不是这个 GroupBox。

那我们怎么用 C# 做子类化,上面基础知识里也说过了。没错!用 NativeWindow 类。我们在 CommonDialogHelper 里写一个私有的嵌套类,命名为 GroupBoxNativeWindow,并让它继承自 NativeWindow,再给它个构造函数传入 GroupBox 句柄,方便我们一创建实例就开始运作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CommonDialogHelper(CommonDialog dialog, MyFormBase parent, WNDPROC DefHookProc)
{
private sealed class GroupBoxNativeWindow : NativeWindow
{
public GroupBoxNativeWindow(IntPtr hGroupBox)
{
AssignHandle(hGroupBox); // 这是使用 NativeWindow 子类化的开始,我们需要提供一个 HWND
}

protected override void WndProc(ref Message m)
{
// 重写 WndProc,我们就相当于进入了子类化过程

base.WndProc(ref m);
}
}

// ...
}

由于我发现在非托管环境按之前的思路完全重绘 GroupBox 会导致闪烁,于是我想到一个很巧妙的方法:

我们先让它自己画,也就是保留默认样式,接着我们就只需要在原来标题文字的地方再画一个一模一样的文字将原有的盖住,不就行了?

大致步骤就是:

  • 拦截 WM_PAINT,但先让它自己画 -> 获取标题以及字体 -> 获取 GroupBox 标题的坐标和大小 -> 画文字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class CommonDialogHelper(CommonDialog dialog, MyFormBase parent, WNDPROC DefHookProc)
{
private sealed class GroupBoxNativeWindow : NativeWindow
{
private const int WM_PAINT = 0x000F;
private const int WM_GETFONT = 0x0031;

private bool Handled; // 为了防止多次触发重绘导致文字重影,所以使用一个标志来表示是否已经画过一次了

// ...

protected override void WndProc(ref Message m)
{
if (m.Msg == WM_PAINT)
{
base.WndProc(ref m); // 先让它自己画

if (!Handled) // 确保我们只画一次
{
IntPtr hWnd = Handle;

using var g = Graphics.FromHwnd((IntPtr)hWnd); // 获取 Graphics,准备画画
using var font = Font.FromHfont(Natives.SendMessage(hWnd, WM_GETFONT, IntPtr.Zero, IntPtr.Zero)); // 获取字体
using var brush = new SolidBrush(ThemeManager.DarkFore); // 创建画刷,用于画画

GetClientRect(hWnd, out RECT rc); // 获取 GroupBox 客户区位置和大小
rc.Left += 6; // 偏移 6px,可以刚好盖住原始文字
Rectangle rect = rc; // 隐式转换为 Rectangle
var sb = new StringBuilder(GetWindowTextLength(hWnd) + 1); // 创建动态字符串缓冲区,+1 是约定俗成的
GetWindowText(hWnd, sb, sb.Capacity); // 获取 GroupBox 标题文字
g.DrawString(sb.ToString(), font, brush, rect); // 画文字
Handled = true; // 标志我们已经画过了,防止重复
}

return;
}

base.WndProc(ref m);
}

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowTextLength(IntPtr hWnd);

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);

[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
}

// ...
}

现在我们的子类化已经完成了,就差获取那个 GroupBox 的句柄了。这貌似又有点挑战,怎么获取?

你可能会想到 FindWindow(Ex) 函数,指定窗口标题来获取 HWND,这在逻辑上说得过去。但是你想过 i18n 吗?就比如我使用的英文版系统中,这个 GroupBox 的标题是 Sample,中文系统中是 示例,那你该怎么办?你可能想,那我们先获取标题,再获取句柄不就行了?注意,我们要获取 GroupBox 的标题本来就要先获取它句柄。

所以你发现没有,以上这条路是行不通的,那怎么办?文档!

打开搜索引擎,搜索 win32 get dialog control handle,第一个就是:

所以说呀,关键词大法+英文检索 真的能在很多时候秒搜出你想要的。

直接一个导入

1
2
[DllImport("user32.dll")]
private static extern IntPtr GetDlgItem(IntPtr hDlg, int nIDDlgItem);

但这又有一个问题,这个函数的原理是指定对话框的 HWND 和控件的 ID 就能获取到控件 HWND,前者我们可以获取得到,那后者呢?可恶的是文档里居然也没写哪里有 ID。

通过一些手段,我们得知,在 Windows SDK 中,包含了 FontDialog 的对话框模板 (Font.Dlg,可类比 WinForms 设计器生成的代码) 以及相应的对话框的所有控件的 ID (dlgs.h)。那我们直接 Everything 搜索出来打开就行了。

先打开 Font.Dlg 看看这个 GroupBox 的 ID 是什么:

1
2
GROUPBOX        "Sample", grp2, 110, 97, 116, 43, WS_GROUP 
// 根据类型,以及设置的文本或借助上下文我们就能确定该控件

好的,它的 ID 是 grp2,那么这个 ID 的具体值是什么?接着打开 dlgs.h,直接 Ctrl+F 通缉 grp2

1
#define grp2        0x0431

看到没,这不就出来了。

那我们现在就可以在 WM_INITDIALOG 里面对它进行子类化了,但得先确保初始化 CommonDialogHelper 的是 MyFontDialog (其实也可以不用,因为控件 ID 是唯一的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public IntPtr HookProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
{
switch (msg)
{
case WM_INITDIALOG:

if (CanUseDarkTheme)
{
// ...

if (dialog is MyFontDialog) // 验证当前对话框是不是 MyFontDialog
{
IntPtr hCtrl = GetDlgItem(hWnd, 0x0431);

if (hCtrl != IntPtr.Zero) // 最后一道保险,确保已获取到 HWND
{
new GroupBoxNativeWindow(hCtrl); // 启动子类化
}
}
}
break;
}

return IntPtr.Zero;
}

看看效果

非常完美 (除了那几个白色的实在没法)

但还没完,还记得我们在重写这两种对话框的 HookProc 吗,我们直接返回了 Helper 的 HookProc,那默认的 HookProc 呢?

所以我们还要在 Helper 里补全 switch 分支,特别是 WM_COMMAND,会影响一些按钮的触发等,除此之外也要在 WM_INITDIALOG 为系统默认控件提供焦点,这是一种规范,此时只需要返回 TRUE 就行,也就是 new IntPtr(1)。

Win32 的BOOL类型本质就是int,0 表示 FALSE,其他值 (一般用 1) 表示TRUE。我们这里返回TRUE就相当于返回 1,再用 IntPtr 封装一下,就变成了new IntPtr(1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private const int WM_COMMAND = 0x0111;

public IntPtr HookProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
{
switch (msg)
{
case WM_INITDIALOG:
// ...
return new(1);
case WM_CTLCOLORDLG:
case WM_CTLCOLOREDIT:
case WM_CTLCOLORSTATIC:
case WM_CTLCOLORLISTBOX:
case WM_CTLCOLORBTN:
// ...
break;
case WM_COMMAND:
return DefHookProc(hWnd, WM_COMMAND, wParam, lParam);
// 这是实现对话框一些其他功能的关键所在,这里调用默认的 HookProc 处理
case WM_DESTROY:
DeleteObject(hBrush); // 别忘了删除那个画刷占用的资源
break;
}

return IntPtr.Zero;
}

[DllImport("user32.dll")]
private static extern IntPtr SetFocus(IntPtr hWnd);

看看效果

诶诶诶,怎么堆栈溢出了?

这个异常是我们在补全 switch 之后抛出的,虽然 Visual Studio 把错误定位在 CanUseDarkTheme 上,但我觉得不是这个问题,因为在这之前都是可以的。

那就从我们添加的内容中一个个排查:

  • return new IntPtr(1) 可能引发该异常吗?不会。
  • DefHookProc(hWnd, WM_COMMAND, wParam, lParam) 呢?貌似也不会
  • DeleteObject(hBrush) 就更不会了

那问题肯定就出在 DefHookProc 这个参数上了,它是我们 Helper 的第二个参数,用来封装默认 HookProc 的。

来看看我们是怎么初始化 Helper 的,在 MyFontDialog 和 MyColorDialog 里,我们都用到了同样的代码:

1
2
3
4
5
public DialogResult ShowDialog(MyFormBase parent)
{
Helper = new(this, parent, HookProc);
return ShowDialog();
}

我说问题就在这里,你发现了吗?

没有?那我请问我们传入的 HookProc 是默认的 HookProc 吗?

肯定不是啊,这里隐式包含了 this 关键字,也就是说我们把我们自己重写过的 HookProc 给传进去了,而它返回的就是 Helper 的 HookProc。当 Helper 的 HookProc 走到 WM_COMMAND 时,就会来调用我们重写的 HookProc,然后又进到 Helper 自己的 HookProc,如此循环往复…

是不是这个就是导致堆栈溢出的罪魁祸首?对嘛,两个方法,你调用我,我调用你,那咱谁也别想跳出去了。那正确的写法是什么?—— 加上 base 关键字

1
2
3
4
5
public DialogResult ShowDialog(MyFormBase parent)
{
Helper = new(this, parent, base.HookProc);
return ShowDialog();
}

再来运行看看,非常好,没错了。

你以为这就完了吗?还记得我们在 ShowDialog/CommonDialogHelper 传入的 parent 参数吗?还没用呢,我们计划让对话框显示在父窗体的中心。而默认的位置是屏幕中心,反正我觉得不河里。当然由于我们完全接管了 WM_INITDIALOG,所以它们现在也没在屏幕中心,而是系统随机决定的位置。

要实现这个功能,其实也不复杂。首先声明导入函数,用于获取对话框位置和大小,以及移动对话框。

1
2
3
4
5
6
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);

[DllImport("user32.dll")]
private static extern void MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);

进入 WM_INITDIALOG 分支,在所有代码之后添加:

1
2
3
4
5
case WM_INITDIALOG:
// ...
GetWindowRect(hWnd, out RECT r);
CenterDialog(hWnd, r); // 表示居中对话框,r 参数在这里触发了隐式转换为 Rectangle
return new(1);

由于涉及到很多计算,我们应该将过程单独放到一个方法里,不然直接写太难看了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void CenterDialog(IntPtr hWnd, Rectangle rect)
{
var validArea = Screen.GetWorkingArea(parent); // 获取父窗体所在屏幕的工作区 (除任务栏剩下的区域)

var w = rect.Width;
var h = rect.Height;
var x = parent.Left + (parent.Width / 2) - (w / 2);
var y = parent.Top + (parent.Height / 2) - (h / 2); // 这4行用于计算居中后对话框的坐标

var l = x;
var t = y;
var r = x + w;
var b = y + h;
if (l < validArea.X) x = validArea.X;
if (t < validArea.Y) y = validArea.Y;
if (r > validArea.Right) x = validArea.Right - w;
if (b > validArea.Bottom) y = validArea.Bottom - h; // 这4行防止对话框超出屏幕边缘

MoveWindow(hWnd, x, y, w, h, false); // 移动对话框
}

运行即可,肯定是没问题的,这里就不放截图了,截了也看不出来。

注意我们在调用 ShowDialog 时要传入当前窗体 (继承自 MyFormBase) 的实例,也就是 this

1
2
new MyFontDialog().ShowDialog(this);
new MyColorDialog().ShowDialog(this);

其实我们还漏掉了一个功能,那就是 .NET 封装的 FontDialog 还额外增加了不显示颜色选项的功能 (ShowColor),开启后 FontDialog 将不显示设置颜色的选项。

与之类似的是 Win32 提供的不显示效果的功能 (ShowEffects),这个功能开启后不会显示下划线、删除线、以及颜色这些选项。

那为什么我们的 FontDialog 看似没有问题呢?因为我们将 ShowEffects 设置为了 false,这会隐藏所有相关选项,所以看起来没有问题。一旦我们开启 ShowEffects 然后关闭 ShowColor,就可以发现所有的选项都出来了,颜色相关的根本就没有被隐藏。

因为 ShowColor 是 WinForms 自行在 WM_INITDIALOG 里处理的,我们完全接管了 WM_INITDIALOG,所以就没被隐藏了;而 ShowEffects 是 Win32 提供的,所以始终有效。

我们先来看看 WinForms 是怎样处理的,打开 WinForms 源码

看到了吧,WinForms 也是通过 GetDlgItem 获取相关控件的句柄,然后设置 SW_HIDE 将它们隐藏。也就是说我们也需要自己写这一部分的代码

可能有的朋友在想,那我们为什么不在这里调用 DefHookProc 让它自己删除呢?

看到上图的 HookProc 的 switch 语句外面是什么了吗,它调用了基类 (CommonDialog) 的 HookProc,那我们再来看看基类在 WM_INITDIALOG 时又在干什么。源码

Move!To!ScreenCenter!把对话框移动到屏幕中心,但是我们已经实现了移动到父窗体中心,那我们就不能调用 DefHookProc 了,因为会让对话框移动两次。所以,自己写更实在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private const int SW_HIDE = 0;

public IntPtr HookProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
{
switch (msg)
{
case WM_INITDIALOG:

if (CanUseDarkTheme)
{
// ...

if (dialog is MyFontDialog f) // 验证当前对话框是不是 MyFontDialog
{
// ...

if (!f.ShowColor)
{
if ((hCtrl = GetDlgItem(hWnd, 0x0473)) != IntPtr.Zero)
{
ShowWindow(hCtrl, SW_HIDE);
}

if ((hCtrl = GetDlgItem(hWnd, 0x0443)) != IntPtr.Zero)
{
ShowWindow(hCtrl, SW_HIDE);
}
}
}
}
break;

// ...
}

return IntPtr.Zero;
}

[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

运行看看,完全没问题。

虽然我们完善了 FontDialog,但还是建议不要在 FontDialog 里挑选颜色
1. 单一职责:FontDialog 还是挑选字体比较好,挑选颜色用 ColorDialog,可选颜色也更丰富
2. 这部分的深色主题还未完成,也不打算搞了,反正我们也不会用到。

至此,我们的深色主题计划就大功告成了,快去试试吧。

2025-08-28 更新
FontDialog 的深色主题已经可以进一步完善,详见 [自定义 Font/ColorDialog 等常用对话框样式]

相关链接


从零开始让你的 WinForms 应用程序也用上原生深色主题
https://wanghaonie.github.io/posts/00d12b183e8c/
作者
WangHaonie
发布于
2025-07-23 08:31:03
更新于
2025-08-28 19:41:27
许可协议