摆脱 TabControl,教你打造高颜值原生样式的垂直导航栏

本文最后更新于:10 天前

前言

总所周知,目前很多软件的设置页面都基本上采用的是垂直导航栏。而看向 WinForms,能起到多页面导航的控件似乎只有 TabControl,虽然它是水平导航,外观也还凑合,但在大部队面前仍然显得格格不入。

在此之前,我也想在 WinForms 里制作一个垂直导航栏,但觉得只有 WPF 才能办到,于是就放弃了这个念头。直到有一天,我偶然发现 System Informer (原 Process Hacker) 的设置页面居然采用的是垂直导航栏,并且样式还是非常原生的那种,虽然没有 WPF 那样的 Fluent Design,但原生爱好者狂喜!

想了想 WinForms 中除了 ListBoxListView 能勉强达到这样的效果,除此之外好像就没有哪个控件可以了。且这个 C 语言程序也不可能自制出这么原生的控件。于是我打开了 Spy++ 来看看这究竟是何方神圣,然而结果让我做梦也想不到。

看到类名了没,SysTreeView32,这不就对应 WinForms 里的 TreeView 吗?(TreeView 是 WinForms 对 SysTreeView32 的封装,底层仍是 SysTreeView32) 不得不说,这个软件的开发者真的是太聪明了。说起 TreeView 那可太常见了,屏幕前的你不说用过但肯定也见过,比如 Windows 资源管理器的左侧导航栏,注册表编辑器的左侧导航栏,都是 SysTreeView32

废话不多说,这期就教大家如何利用 TreeView 制作一个垂直导航栏。

正文

我们先在设计器里添加一个 TreeView,然后随意添加一些设置项,看看效果

然后对比 System Informer 的垂直导航栏,它具有的属性应该是:无边框,整行选择,无节点之间的连线,每个节点的宽度为 25px,节点与左侧边框间距为 10px (经测试后要写 5px),有热跟踪,失焦仍选中节点,即:

1
2
3
4
5
6
7
BorderStyle = BorderStyle.None;
FullRowSelect = true;
ShowLines = false;
ItemHeight = 25;
Indent = 5;
HotTracking = true;
HideSelection = false;

再来看看效果:

怎么样,是不是有那味儿了?但总感觉少了点细节,System Informer 的导航栏的选中框是空心的,然后这里却是实心的,而且并没有相关属性可以设置。

于是我毫不犹豫打开它的设置窗口的源代码,在窗口过程那里 (他写的是 PhOptionsDialogProc) 直接找 WM_INITDIALOG 分支,高亮出有关 Tree 的字段,然后发现了这段代码,毫无疑问,这就是关键所在。

那么看到这个函数 PhSetControlTheme 还传了个 explorer 进去,我马上就想到了 uxtheme.dll 里面的 SetWindowTheme 函数 (因为之前做 PlainCEETimer 的深色模式用到过),但还是看看它在这里的定义:

果然如此,接着在文档里查看签名之后就可以 P/Invoke 导入了:

1
2
[DllImport("uxtheme.dll", CharSet = CharSet.Unicode)]
private static extern int SetWindowTheme(IntPtr hWnd, string pszSubAppName, string pszSubIdList);

然后调用它

1
SetWindowTheme(treeView1.Handle, "Explorer", null);

看看效果:

嗯,舒服了,就是这样。然后就是重点环节了,导航页应该用什么控件?那只有 Panel 了,但还有一个细节。我们在设计器里设计 TabControl 的时候,它的标签是可以点击的,这可以让我们切换到另一个标签页来添加控件了,那么这个怎么实现呢?我也不会,但肯定要自定义一个 Designer,这就有点小题大做了,所以建议大家直接把导航页平铺到窗口里,就像这样:

不过还有一种偷梁换柱的方法,大家可以试试,我就不试了,那就是设计时仍采用 TabControl,然后在每个标签页里放一个 Fill 停靠的 Panel,之后所有控件都添加在这个 Panel 里。运行时移除在 TabPage 里添加的 Panel,再把这个 TabControl 删除然后 Dispose(),最后将 Panel 添加到导航页里。

但是仍需要一个容器来存放这些导航页,肯定也用 Panel,方便我们把单个导航页移动到这个 Panel 里,然后将 Dock 设置为 Fill,这样就避免了手动调整大小。

接下来就要实现导航页的切换了,要求点击 TreeView 节点时显示对应的页面。思路就是,首先所有的页面都要在那个存放页面的 Panel 里面,当节点选中时,将对应的页面设置为可见,其他页面隐藏,就完成了。

既然是导航页,我们肯定要专门制作一个类型,并且由于是基于 Panel,默认背景颜色是透明的,不统一,所以我们可以把背景颜色设置为 SystemColors.Window,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
public class NavigationPage : Panel
{
/// <summary>
/// 初始化新的 <see cref="NavigationPage"/> 实例。
/// </summary>
public NavigationPage()
{
BackColor = SystemColors.Window;
Visible = false; // 默认就不可见,可见度应该由那个 TreeView 控制,不然会增加 UI 压力
}
}

导航栏的实现如下:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public sealed class NavigationBar : TreeView
{
private readonly int ItemsCount;
private readonly NavigationPage[] Pages;

/// <summary>
/// 初始化新的竖直导航栏实例。
/// </summary>
/// <param name="navItems">导航栏的项 (不支持多级)。</param>
/// <param name="pages">导航栏关联的相关页面。</param>
/// <param name="pagePresenter">用于显示页面的可滚动的控件</param>
public NavigationBar(string[] navItems, NavigationPage[] pages, ScrollableControl pagePresenter)
{
BorderStyle = BorderStyle.None;
Dock = DockStyle.Fill;
FullRowSelect = true;
HideSelection = false;
HotTracking = true;
ShowLines = false;
ItemHeight = 25;
Indent = 5;
ItemsCount = navItems.Length;

if (pages.Length == ItemsCount)
{
var collection = pagePresenter.Controls;

for (int i = 0; i < ItemsCount; i++)
{
var page = pages[i];
page.Dock = DockStyle.Fill; // 让导航页充满容器
page.Tag = i; // 设置导航页的索引
Nodes.Add(navItems[i]); // 添加导航项
collection.Add(page); // 将页面全部添加到那个容器里面
}

Pages = pages;
}
}

/// <summary>
/// 切换到对应的页面。
/// </summary>
/// <param name="page"></param>
public void SwitchTo(NavigationPage page)
// 用于实现相当于在 TabControl 中编程设置 SelectedTab 的效果
{
SwitchTo(Nodes[(int)page.Tag]);
}

protected override void OnHandleCreated(EventArgs e)
{
SetWindowTheme(Handle, "Explorer", null); // 保持原生外观
base.OnHandleCreated(e);
}

protected override void OnAfterSelect(TreeViewEventArgs e)
// 这个是关键,当你选中一个节点后,这个事件就会触发,e 里面包含了选中的是哪个节点
{
SwitchTo(e.Node);
base.OnAfterSelect(e);
}

private void SwitchTo(TreeNode navItem)
{
var index = navItem.Index; // TreeNode (导航项) 也有索引,且与导航页的索引一致
SelectedNode = navItem; // 同步导航栏选中项,用于编程设置选中的导航页

for (int i = 0; i < ItemsCount; i++)
{
Pages[i].Visible = i == index; // 设置导航页可见度,两边索引相等才显示导航页
}
}
}

在这个实现中,我们在构造函数里面添加了一些参数来方便我们添加导航项和导航页面以及存放页面的控件 (Panel 就是一种 ScrollableControl)。不过这个类还有不完善的地方,但程序内部用已经足够,若想开发为 SDK,还请根据需求自行处理。然后我们创建实例时只需要这样就行:

1
new NavigationBar(["基本", "显示", "外观", "工具"], [PageGeneral, PageDisplay, PageAppearance, PageTools], PageNavPages)

编译出来后还有一个意外情况,导航栏不会默认选中第一项,这导致导航页为空白。原因很简单,因为我们可能会在设置窗口中添加一些按钮,然而这些按钮会夺走焦点。解决方法就是在设置窗口打开的时候手动让导航栏获得焦点,即

1
2
3
4
5
6
7
public partial class SettingsForm : Form
{
protected override void OnShown()
{
treeView1.Focus();
}
}

最终效果长这样,我就直接把 PlainCEETimer 里面的拿来了,效果是一样的,大家也可以去试试:

好了,到此结束,感谢各位的阅读。

相关链接


摆脱 TabControl,教你打造高颜值原生样式的垂直导航栏
https://wanghaonie.github.io/posts/818a54b277f2/
作者
WangHaonie
发布于
2025-05-04 16:44:37
更新于
2025-05-04 16:51:23
许可协议