简介:WeifenLuo.WinFormsUI.Docking是一款面向C#窗体程序开发的开源停靠布局控件,可模拟Visual Studio的可停靠面板效果,帮助开发者解决多文档界面中子窗口停靠、浮动、自动隐藏等复杂布局问题。资源包内含完整源代码和示例工程,压缩后共196个文件、仅540KB,以85个C#源文件为主,另有55个位图与19个图标文件提供界面样式,15个资源描述文件保存布局信息,并附工程文件、批处理脚本和可运行示例。目前已有488人学习浏览。通过源码可以理解停靠状态枚举、停靠区域属性、停靠窗口集合、自动隐藏机制等核心概念,学习自定义子窗口的关闭、最小化、最大化行为;附带的示例项目能帮助快速上手,无论初学者还是资深工程师,都能从源码研究与实例演练中获得实际收获,适合打造专业级IDE风格界面。
1. 搜 WeifenLuo.WinFormsUI.Docking 源代码和例子,通常是因为手里的 WinForms 项目要做成 Visual Studio 那种“面板能拖、能停、能浮、能自动隐藏”的界面
这套库堪称 WinForms 时代的标准答案:DockPanel 接管主窗体剩余区域,DockContent 变成面板,布局状态由库维护,还能序列化成 XML。真正动手时缺的往往不是 DLL,而是对源码和例子的拆解。下面按这条路径走:状态机原理、源码编译与示例跑通、写出第一个可停靠窗口、避坑清单、布局持久化与源码调试。适合三类人:WinForms 新手、维护旧系统的人、想读源码学窗口布局算法的进阶者。
2. 从 DockContent 与 DockState 看懂停靠状态机:源码不白读的前提
2.1 DockContent 与 DockPanel 的分工:内容与容器的边界
我见过不少新手第一个动作就是去源码里翻布局算法,却连 DockContent 和 DockPanel 的关系都没分清。这两个类恰恰是整个库的地基。DockPanel 是一个控件,通常以DockStyle.Fill塞进主窗体,它本身不绘制任何业务界面,只负责管理一块矩形区域,并在内部把区域切成若干条带。DockContent 则是每个可停靠窗口的基类,它继续继承System.Windows.Forms.Form,所以你可以在里面放任何 WinForms 控件、享受已有的消息循环和焦点逻辑,代价是必须接受 DockPanel 的托管。
这个设计的直接好处是业务代码不用学新 API:你在 DockContent 里放 TreeView、PropertyGrid、RichTextBox,和在一个普通 Form 上放控件没有区别;停靠、浮动、自动隐藏这些行为全部被 DockPanel 接管。理解了这一点,你再去看源码里的 DockContent.cs,会发现它多数代码都在处理状态迁移、序列化标识和事件通知,而不是绘制。绘制相关的东西在 DockPanel、DockPane、AutoHideStrip 这几个类里。
再往下分一层,还要认识 DockPane。DockPanel 的某个停靠区域可能同时摞着多个 DockContent,同一区域的一组标签页就由一个 DockPane 管理。DockPane 本身也是一个控件,你拖动标签页时,本质上是在切换这个标签在哪个 DockPane 里。四个关键对象的分工可以这样记:
| 对象 | 职责 | 常见入口 |
|---|---|---|
| DockPanel | 全局布局容器,唯一根 | 主窗体Dock = DockStyle.Fill |
| DockContent | 单个业务窗口 | Show(dockPanel) |
| DockPane | 一组标签页的容器 | ActiveContent、CloseActiveContent |
| FloatWindow | 浮动状态的外层窗体 | DockState = Float时自动创建 |
至于选型,常见对比对象是 DevExpress DockManager、Infragistics Docking。如果你的产品允许商业授权,付费库确实省心;但内部工具、需要定制布局算法、需要阅读源代码排错的项目,选 WeifenLuo 这套更实际。它依赖少、源码量可控,改起来不用跟黑匣子搏斗。当然代价是文档不多、很多细节要靠示例和源码硬啃,这也是我写这篇文章的原因。
2.2 DockState 的 8 个状态与合法迁移
DockState 是全部核心。它的取值包括:Unknown、Float、DockTop、DockBottom、DockLeft、DockRight、Document、Hidden。展开说,Float 表示窗口脱离主窗体,悬浮在独立窗体里;四个 Dock 方向对应主窗体四周的停靠槽;Document 对应中间文档区;Hidden 表示逻辑存在但界面不显示。Unknown 通常在反序列化前出现,运行中的内容不会停留在这个状态。
迁移规则分两套路径。用户拖拽时,库会先进入浮动预览,再根据鼠标落点决定停靠方向;代码里可以直接给 DockState 赋值,或设置 ShowHint 让首次显示落在某个位置。值得注意的状态是 Hidden:它和关闭是两回事,关闭会销毁对象,Hidden 只是隐藏。很多项目把“关闭面板”改成一个显式 Hide,就是为了保留内容状态。
想看状态迁移,与其猜不如打日志。我建议所有初接这个库的人都在 DockContent 构造里挂一个DockStateChanged事件,把它打印出来:
using WeifenLuo.WinFormsUI.Docking; public class LoggedDockContent : DockContent { public LoggedDockContent() { // DockStateChanged 在每次停靠状态变化后触发 DockStateChanged += (s, e) => { // e.OldValue 是迁移前的状态,当前状态从 e.DockState 取 Console.WriteLine( $"{Text}: {e.OldValue} -> {e.DockState}"); }; } }这段代码的价值在于,你会发现一次拖拽往往不是一步到位:窗口先变成 Float,再落到 DockRight,日志里会连续出现两行。很多布局 bug 都是因为代码在状态中途去查 DockState,查到 Float 就误判了。所以判断当前停靠位置,最好在拖拽结束、也就是第二跳之后再读,或者接受中间会有 Float 过渡。
2.3 停靠布局的树结构:理解嵌套分栏才能看懂序列化输出
第三个要紧概念是布局的树形嵌套。DockPanel 不是简单地把区域均分成上中下,而是采用嵌套分栏法:先占一个方向,剩下的矩形再递归给下一个 pane。这种结构与 Visual Studio 的布局一致,优点是任意组合都可能,缺点是不存在一个“位置表”,要表示布局就必须走树形结构。
打开布局保存得到的 XML,你会看到多层嵌套的节点,每层记录一个停靠方向、一个比例、一组内容标识。用下面的结构来想象它:
<layout> <pane direction="Left" percent="0.25"> <content key="ProjectExplorer" /> </pane> <split> <pane direction="Bottom" percent="0.3"> <content key="Output" /> </pane> <pane direction="Document"> <content key="Editor" /> </pane> </split> </layout>注意这是示意,不是库生成的原始 XML,真实的节点命名和嵌套层级以你当前源码版本为准,但方向能帮你理解:左边资源管理器窗口占 25% 宽度,剩余区域再拆成下部输出窗口和中间文档区。
源码里跟这块相关的是 DockPane 的 NestedDockingStatus 和 DockPanel 的布局引擎。初读源码的人最容易绕晕的是递归拆分逻辑,我通常从 DockPanel 的四个方向属性入手,观察它们返回的 pane 列表,再对照 XML 看比例值。把树结构记在脑子里后,再去看停靠预览的绘制代码,就明白它为什么能画出带方向指示的蓝色高亮框——它只是遍历了树里每个可插入的位置。先理解树,再看绘制,比直接读源码高效得多。
3. 把官方源码和例子跑起来:获取、编译、定位演示代码的完整路径
3.1 先确定要源码还是 DLL,两种来源各取所需
标题既然带“源代码和例子”,你要拿到的显然不只是编译好的程序集,而是整套工程。常见做法是去 GitHub 搜索 WinFormsUI.Docking 或 DockPanel Suite,代码托管公开、版本演进清楚。如果你暂时不方便拉取远程仓库,也可以先装 NuGet 包把功能跑通,之后再补源码阅读。功能使用和源码阅读可以分开,不必卡在第一周。
# 拉取源代码(仓库地址请用你自己搜索到的源替换占位符) git clone <repository-url> DockingSource cd DockingSource # 查看本地分支与历史 tag,按目标框架选择合适的版本 git branch -a git tag -l第一条命令克隆整个仓库,第二条把历史分支和 tag 列出来。这个库维护周期长,不同 tag 的目标框架可能完全不同,先看 tag 再决定切到哪个版本,能避免源码和你的项目框架对不上。
3.2 编译前的环境准备:老 csproj 要用对构建工具
这个库经历过 WinForms 的老中青三个时期:早期 .NET Framework 2.0,中期 4.x,后期 netcore/net5+。源码里可能同时存在多个 csproj 和多个目标框架,直接用新版 Visual Studio 双击旧 sln 会碰见几类问题:旧工程目标框架未安装、packages.config 还原失败、引用了不存在的本地库路径。
# 用 MSBuild 构建(sln 文件名以你实际解压出的为准) msbuild <solution>.sln /p:Configuration=Release如果你习惯 dotnet CLI,这里要留个心眼:老工程不是 SDK-Style 项目,dotnet build不一定认。优先在 Visual Studio 开发者命令提示符里跑 MSBuild,或者直接打开 VS 编译。编译完成后,核心程序集通常输出在 Release 目录,名字以 WeifenLuo.WinFormsUI.Docking 开头。
3.3 跑通两个官方示例:一个看交互,一个看外观
官方仓库通常会带两个示例工程,一个是偏教学的 DockSample,另一个是模仿 Visual Studio 2005 风格的界面演示。前者适合学交互:面板拖拽出蓝色停靠预览、拖动标签页合并、右键标签出现上下文菜单、自动隐藏按钮变成垂直 Tab;后者适合直接借外观,能看出停靠条、标题栏、标签页在不同主题下的状态。
把两个工程依次设为启动项目跑一遍,重点操作几个动作:把左侧窗口拖到右侧、把文档区标签拆分成浮动窗口、点自动隐藏按钮、保存布局再重新加载。体验完交互,再回到源码里定位展示点。
# 在示例源码里搜索所有 DockContent 的挂载位置 grep -R "\.Show(dockPanel" DockSample/这条命令会把示例程序里所有 DockContent 的入口一次列出来。以 VS 的全局搜索也能做同样的事。从这些 Show 调用向上溯源,能很快理解每个窗口的 ShowHint 与 DockAreas 配置。我拿到陌生源码的习惯是:先搜 Show 和 DockState 的赋值点,再看构造和属性初始化,最后才读绘制。
3.4 引用到你自己项目的三种方式,按场景选
| 方式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| DLL 直接引用 | 最快,改动最小 | 无法调试库内部 | 只集成不折腾 |
| 引用源码工程 | 能下断点跟进去 | 编译时间更长,工程文件偏老 | 研究源码、排疑难 bug |
| NuGet 包 | 版本干净,还原方便 | 定制改动不方便 | 快速起步、原型验证 |
如果你只是想让项目跑起来,DLL 引用最快;如果你打算长期在这个库上做二次开发,比如调整停靠预览颜色、修改自动隐藏条的绘制,那就直接引源码工程,省得每次用反编译工具找逻辑。
4. 用 DockContent 写第一个可停靠窗口:最小复现路径与三个必调属性
4.1 新建工程与主窗体布局:一个 DockPanel 占满窗口
前面的铺垫到这里该落地了。新建一个 WinForms 工程,目标框架按你的业务环境选,之后在主窗体里声明字段并在构造函数里搭好容器。
public partial class MainForm : Form { private WeifenLuo.WinFormsUI.Docking.DockPanel _dockPanel; private OutputWindow _outputWindow; public MainForm() { InitializeComponent(); // 1. DockPanel 是布局容器,它必须占满主窗体剩余空间 _dockPanel = new WeifenLuo.WinFormsUI.Docking.DockPanel { Dock = DockStyle.Fill, DocumentStyle = DocumentStyle.DockingWindow }; // 2. 最后加入 Controls,让它垫底,避免盖住工具栏和状态栏 Controls.Add(_dockPanel); // 3. 首次启动直接显示输出窗口,位置由 ShowHint 决定 _outputWindow = new OutputWindow { ShowHint = DockState.DockBottom }; _outputWindow.Show(_dockPanel); } }这里有两个细节。第一,DockPanel 一定要最后 Add 到 Controls,或者用Controls.SetChildIndex把它放到最底层,否则它会遮住先加进来的菜单栏;第二,DocumentStyle.DockingWindow让文档区也以停靠窗口的形式存在,适合内部工具。如果你的产品需要多文档 MDI 风格,再换成对应的 DocumentStyle 枚举值。
4.2 定义第一个 DockContent:三个必调属性一次配好
创建 OutputWindow 类,继承 DockContent。DockContent 从 Form 来,所以你在构造函数里添加控件、设置 Text 的方式与普通窗体完全一致。
using WeifenLuo.WinFormsUI.Docking; public class OutputWindow : DockContent { private TextBox _textBox; public OutputWindow() { // 窗口标题与标签文字可以分开设置 Text = "输出"; TabText = "输出"; // DockAreas 用位或组合,声明允许出现的位置 DockAreas = DockAreas.DockLeft | DockAreas.DockRight | DockAreas.DockBottom | DockAreas.DockTop | DockAreas.Document; // 关闭按钮变成隐藏而不是销毁,配合菜单重新打开 HideOnClose = true; _textBox = new TextBox { Dock = DockStyle.Fill, Multiline = true, ReadOnly = true }; Controls.Add(_textBox); } }三个必调属性:DockAreas、HideOnClose、Text/TabText。DockAreas 不设置时,默认行为是哪些地方都不能停靠,窗口只能浮动或隐藏;HideOnClose 一旦忘了设,用户点右上角 X,窗口实例会被 Dispose,再想从菜单打开就得重新 New 一个;TabText 影响标签页文字,Text 影响标题栏,两者不一致时优先检查 TabText。
4.3 在主窗体里挂载窗口:Show 方法才是唯一入口
using System.Linq; private void ShowOutputWindow() { // 同一窗口只保留一份实例:先查找已存在的 OutputWindow var existing = _dockPanel.Contents .OfType<OutputWindow>() .FirstOrDefault(); if (existing != null) { // 已存在就显示;如果之前是 Hidden 状态,这里也会恢复出来 existing.Show(); return; } var window = new OutputWindow(); window.Show(_dockPanel); }_dockPanel.Contents返回所有已挂载的 IDockContent,用 OfType 筛选类型是常见做法。这里容易踩的坑是不做查重,每次点菜单都 New 一个 OutputWindow,结果用户开五个输出窗口,状态还各自独立。窗口显示状态复杂时,还可以把existing.DockState == DockState.Hidden作为判断条件,区分“从未打开”和“只是隐藏”。
4.4 显示与隐藏的菜单联动:状态不同步是 WinForms 老问题
有了窗口,还需要菜单或工具栏按钮来控制它。我一般把按钮的勾选状态直接绑定到窗口状态:
private void tsbOutput_Click(object sender, EventArgs e) { if (_outputWindow == null) { _outputWindow = new OutputWindow(); _outputWindow.Show(_dockPanel); return; } if (_outputWindow.DockState == DockState.Hidden) _outputWindow.Show(_dockPanel); else _outputWindow.Hide(); }注意这里的 Hide 是显式隐藏,窗口实例还在,和 HideOnClose 是两码事。HideOnClose 影响的是用户点关闭按钮时的行为,显式 Hide 影响的是程序主动隐藏时的行为。两者配合才能做到“菜单能开能关、用户也能手动关、关完还能再打开”。
5. 避坑:源码编译、布局读写与高 DPI 的 5 次翻车记录
5.1 编译报错“命名空间中不存在 Docking”
现象:已经引用了项目或 DLL,写using WeifenLuo.WinFormsUI.Docking;依然红色波浪线,编译直接失败。
原因:最常见是同一解决方案里既有 NuGet 包引用,又引进了源码工程,两个来源的程序集版本打架;另一个常见原因是目标框架不匹配,比如 .NET 6 项目引用了 net40 编译的老版本 DLL,类型加载器直接拒绝。
解决:在引用管理器里把所有名字里带 Docking 的引用列出来,只保留一份。如果必须用源码工程,就先删掉 NuGet 包。目标框架方面,老项目建议统一到 .NET Framework 4.x 的对应版本,新项目优先找支持 netstandard2.0 的分支或 tag。
5.2 布局保存后再 LoadFromXml,窗口全部变成浮动
现象:SaveAsXml 保存的 XML 没有报错,再 LoadFromXml 之后,所有窗口漂在左上角,停靠关系全部丢失。
原因:LoadFromXml 需要一个反序列化回调,你没有传,库拿到 persistString 后不知道该创建哪种 DockContent,只能用空内容顶上,布局自然全乱。另一个隐性原因是保存和加载时 DocumentStyle 不一致,导致文档区节点识别失败。
解决:实现DeserializeDockContent委托,并在加载时传进去:
private IDockContent DeserializeContent(string persistString) { // persistString 是随布局 XML 保存的内容标识,默认是类型全名 if (persistString == typeof(OutputWindow).ToString()) { return new OutputWindow(); } return null; } // 加载时这样调用 _dockPanel.LoadFromXml("layout.xml", DeserializeContent);提示:如果业务类型名在重构后变了,旧布局文件会全部失效。稳妥做法是在每个 DockContent 里覆写 PersistString 属性,给一个稳定字符串标识。
5.3 DockContent 点右上角 X 之后,窗口彻底消失
现象:用户关掉一个面板,再从菜单里打开,找不到窗口实例,程序只能重新 New,刚填的搜索条件、滚动位置全没了。
原因:DockContent 继承自 Form,默认 Close 会触发 Dispose,窗口对象被销毁,DockPanel.Contents 中不再有它。
解决:设置HideOnClose = true是最直接的办法。如果还想再控制一下,重写 OnClosing 把关闭改成隐藏:
protected override void OnClosing(CancelEventArgs e) { // 把窗口关闭按钮变成“隐藏”,保留对象供下次显示 e.Cancel = true; Hide(); base.OnClosing(e); }这里要和菜单里的显式 Hide 区分:菜单里是主动隐藏,OnClosing 是拦截用户行为。两处都覆盖,行为就完全可控。
5.4 高 DPI 屏幕下拖拽停靠预览错位
现象:在 150% 缩放的屏幕上拖动 DockContent,蓝色停靠指示框和鼠标位置明显对不上,松手后窗口落位也不是用户预期的地方。
原因:WinForms 进程默认不启用 PerMonitorV2 DPI 感知,DockPanel 的停靠计算基于物理像素,而窗体坐标基于逻辑像素,缩放下两者偏移。
解决:在 Program.cs 启动入口设置高 DPI 模式:
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);同时给 app.manifest 加 dpiAware 声明,保证从进程启动阶段就按正确模式创建窗体:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> </windowsSettings> </application>设置后再测拖拽,指示框和落点应该对齐。这个问题在 4K 显示器上几乎是必现的,越早处理越好。
5.5 老示例工程包还原失败,项目直接无法加载
现象:打开源码解决方案,Visual Studio 提示 packages 缺失,或者 NuGet 还原失败,工程树里示例项目显示灰色不可用。
原因:源码维护跨度长,老工程用 packages.config,新工程用 PackageReference,混合状态下 VS 的自动还原容易顾此失彼。
解决:先手动还原一次。
nuget restore <solution>.sln如果还原后仍然失败,打开 csproj 看引用节点。老式 packages.config 项目的引用路径通常带..\packages\前缀,检查 packages 文件夹确实存在;新式 PackageReference 项目则看包名与版本是否有效。实在不行就把本地 packages 文件夹整个删掉重新还原,比逐个排查快得多。
6. 进阶:布局持久化、源码断点与 DockState 联动
6.1 布局记忆:两行代码存下用户的停靠习惯
工具类软件最实用的功能是把布局存下来,下次启动原样恢复。SaveAsXml 和 LoadFromXml 的分工很清晰:
// 保存布局 _dockPanel.SaveAsXml("layout.xml", Encoding.UTF8); // 加载布局:必须提供反序列化回调 _dockPanel.LoadFromXml("layout.xml", DeserializeContent);保存时,每个 DockContent 会把 PersistString 写进 XML;加载时,回调负责按 PersistString 创建对应实例。默认的 PersistString 是类型全名,但建议在每个 DockContent 子类里显式指定,我一般用常量字符串,避免类名重构后布局失联。
6.2 源码调试:三类最优断点位置
引用源码工程的好处是能直接下断点。第一类断点放在 DockContent 的属性赋值处,看是谁把状态改成了 Float 或 Hidden;第二类放在 DockPanel 布局引擎遍历 pane 的地方,观察拆分方向和比例如何计算;第三类放在停靠预览绘制处,看鼠标命中哪个停靠区域。调试时打开“调用堆栈”窗口,能很快看出拖拽操作是从哪个事件入口触发到这里的,比自己按 F11 一步一步追快得多。
6.3 DockStateChanged 联动界面状态
菜单勾选、工具栏按钮 Enabled、面板标题栏文字,这些都可以挂到 DockStateChanged 上。窗口被拖到新位置、进入自动隐藏、变成浮动时,事件都会触发,界面状态跟着同步,避免传统 WinForms 里“数据变了按钮没变”的脱节问题。只要注意第五节讲的 Float 中间态,在事件里判断最终状态而不是第一个动作,联动就可靠。
这么多年下来,我在这套库上翻车最多的地方永远是序列化:要么忘写回调,要么 PersistString 被重构改掉。现在我的习惯是每个 DockContent 子类都显式给 PersistString,并在构造函数里就挂好状态日志,宁可初始化多两行,也不要等业务逻辑复杂了再回来猜状态。希望帮到你。
本文还有配套的精品资源,点击获取