停靠窗口¶
Microsoft.UI.Reactor(Reactor)的停靠系统让单个外壳承载多个用户可自由重排的界面 —— 也就是 Visual Studio / VS Code / Photoshop / Figma 那种布局范式。用户可以在分组之间拖动标签页、拆分窗格、把工具窗口钉到侧边,以及把窗格撕离成浮动子窗口;布局跨会话持久保存。
对应的元素是 DockManager。它的 Layout 是一棵不可变的 DockNode 树,描述期望的排布;协调器把这棵树变成原生 WinUI 控件,并在每次重渲染时施加最小化的变更。
最小配置¶
停靠功能位于可选的 Microsoft.UI.Reactor.Advanced 包中(spec 062 §7)。添加它的包引用:
停靠是一种需显式开启的元素类型 —— 在宿主构造时注册它,然后像使用任何其他 Reactor 元素一样使用 DockManager:
ReactorApp.Run<DockingApp>(
title: "Docking",
width: 900,
height: 600,
configure: host => DockingNativeInterop.Register(host.Reconciler));
DockingNativeInterop.Register 把 DockManager、拆分条与放置目标元素接入协调器。没有它,你树中的 DockManager 不会被识别。
一个双窗格水平拆分:
class TwoPaneDemo : Component
{
public override Element Render() => new DockManager
{
Layout = new DockSplit(
Orientation.Horizontal,
new DockNode[]
{
new ToolWindow
{
Title = "Solution Explorer",
Key = "tool:solution",
Width = 260,
Content = VStack(6,
TextBlock("MyApp.sln").SemiBold(),
TextBlock(" src"),
TextBlock(" App.cs"),
TextBlock(" MainView.cs"),
TextBlock(" tests"),
TextBlock(" MainViewTests.cs")
).Padding(12)
},
new DockTabGroup(
Documents: new DockableContent[]
{
new Document
{
Title = "App.cs",
Key = "doc:app-cs",
Content = VStack(4,
TextBlock("// App.cs"),
TextBlock("ReactorApp.Run<MainView>(title: \"MyApp\");"),
TextBlock(""),
TextBlock("class MainView : Component"),
TextBlock("{"),
TextBlock(" public override Element Render() => TextBlock(\"Hello\");"),
TextBlock("}")
).Padding(16)
}
},
SelectedIndex: 0),
}),
};
}

树的叶子是窗格记录。可关闭的编辑器式窗格优先用 Document,可隐藏、可侧钉的工具优先用 ToolWindow;两者都派生自源码兼容的 DockableContent 基类。每个窗格携带一个 Title(显示在标签页 / 浮动窗口上)、一个可选的 Content 元素子树,以及 —— 很重要 —— 一个稳定的 Key。
任何状态需要在重排、标签移动与撕离中幸存的窗格,都必须有 Key。 Reactor 的键控协调器按 Key 匹配窗格,并在树重建时保留元素子树(及其 UseState 槽位)。这里没有「以 Title 隐含作键」的回退;请始终显式提供。
DockNode 代数有三类核心节点族(全为不可变 record),外加文档/工具叶子的特化:
| 类型 | 用途 |
|---|---|
DockSplit(Orientation, Children, …) |
沿一个轴拆分其子项,子项之间有可拖拽调整的拆分条 |
DockTabGroup(Documents, TabPosition, CompactTabs, …) |
把子项呈现为标签页 |
Document |
可关闭的叶子窗格,用于编辑器/文档界面;默认不可钉住 |
ToolWindow |
可隐藏的叶子窗格,用于工具界面;默认可自动隐藏/钉住 |
DockableContent |
为旧式位置构造代码保留的基类叶子窗格 |
DockManager 自身接受这些 props:
| Prop | 用途 |
|---|---|
Layout |
DockNode 树的根 |
LeftSide / TopSide / RightSide / BottomSide |
沿某条边钉住的工具窗口 |
ActiveDocument |
按 Key 对 Layout 解析;键不匹配时不动激活状态 |
Adapter |
用于重建与浮动外壳的 IDockAdapter |
PersistenceId |
让布局 JSON 走 WindowPersistedScope |
LayoutStrategy |
在默认摆放之前为程序化插入文档/工具指定路由 |
OnLayoutChanging / OnLayoutChanged 及窗格生命周期事件 |
观察或取消布局、关闭、隐藏、浮动与停靠转换 |
ShowDropTargets |
为拖拽、键盘或测试驱动的移动显示放置目标覆盖层 |
SplitRatios / OperationLog |
可选的诊断信息与外部拥有的拆分尺寸 |
标签分组¶
DockTabGroup 持有 N 个 DockableContent 叶子,并把它们呈现为标签页。用户可拖拽重排;SelectedIndex 报告当前活动标签:
class TabGroupDemo : Component
{
public override Element Render() => new DockManager
{
Layout = new DockTabGroup(
Documents: new[]
{
new Document
{
Title = "App.cs",
Key = "doc:app",
Content = VStack(4,
TextBlock("// App.cs"),
TextBlock("public sealed class App : Component"),
TextBlock("{"),
TextBlock(" public override Element Render() =>"),
TextBlock(" TextBlock(\"hello, world\");"),
TextBlock("}")
).Padding(16)
},
new Document
{
Title = "MainView.cs",
Key = "doc:main",
Content = TextBlock("// MainView.cs body").Padding(16)
},
new Document
{
Title = "Readme.md",
Key = "doc:readme",
Content = TextBlock("# Readme").Padding(16)
},
},
SelectedIndex: 0),
};
}

TabPosition.Bottom 配合 CompactTabs: true 会产生 Office 的工具窗格形态。Document 窗格默认可关闭;关闭的文档会触发文档生命周期回调,并从原生布局中移除。
塑造文档区¶
在 DockTabGroup 上设置 Role,即可声明 IDE 级的「文档 vs 工具」区分。三个取值:
DockGroupRole.General—— 默认值;接受所有类别,并在被清空时剔除。这是 spec 046 之前的行为。DockGroupRole.DocumentArea—— 文档区。Document窗格上的Dock(Center)首选落点;默认拒绝ToolWindow的放置。空置时仍然存活(不需要逐组的ShowWhenEmpty),因此最后一个文档关闭后文档区仍是可见的放置目标。DockGroupRole.ToolWindowStrip—— 工具窗口的边条。ToolWindow窗格的首选落点;拒绝Document的放置。
一个 Visual Studio 形状的布局:左侧工具条、中间文档区、右侧工具条。model.Dock(doc, DockTarget.Center) 会落在中间那一组,无论它在树序中位于何处:
var galleryItemsToolWindow = new ToolWindow { Title = "Gallery", Key = "tool:gallery" };
var configurationToolWindow = new ToolWindow { Title = "Configuration", Key = "tool:configuration" };
return new DockSplit(Orientation.Horizontal, new DockNode[]
{
new DockTabGroup(
new[] { galleryItemsToolWindow },
Width: 260,
Role: DockGroupRole.ToolWindowStrip),
new DockTabGroup(
Array.Empty<DockableContent>(),
Role: DockGroupRole.DocumentArea),
new DockTabGroup(
new[] { configurationToolWindow },
Width: 320,
Role: DockGroupRole.ToolWindowStrip),
});
程序化的
Dock(content, DockTarget.Center)会路由到第一个DockGroupRole.DocumentArea分组,若失败则回退到第一个兼容分组(若没有任何分组接受该载荷,则记录一条诊断信息)。要无视角色直接指定某个分组,请使用Dock(content, DockTabGroup, DockTarget)重载 —— 显式摆放是被信任的,会跳过角色兼容性检查。
当用户通过拖拽在一个 DocumentArea 内部拆分 Document 时,新的兄弟分组会继承该角色 —— 拆分一个文档区产生的是两个文档区,而不是一个文档区加一个 General 分组。同样的传播也会在用户把工具放到布局边缘时对 ToolWindowStrip 生效:带 ToolWindow 载荷的 DockBottom 会在底部创建一个新的 ToolWindowStrip 角色分组,即使此前根本不存在任何边条。
约束工具窗口的摆放¶
ToolWindow.AllowedSides 是一个 [Flags] 掩码,限制工具窗口可以停靠到哪些边(相当于 Qt 的 setAllowedAreas)。默认的 DockSides.All 保持不受约束的摆放。把它设为 DockSides.Bottom 即可把一个 Errors 窗格锁到底部条:
var errors = new ToolWindow
{
Title = "Errors",
Key = "tool:errors",
AllowedSides = DockSides.Bottom,
};
效果:
- 拖拽期间,放置目标覆盖层会把掩码排除的边变暗,命中测试也忽略那些目标 —— 在禁止的边上松开会让窗格弹回。
- 当
Left不在掩码中时,model.PinToSide(tw, DockSide.Left)抛出InvalidOperationException。需要绕过的策略应先通过tw with { AllowedSides = DockSides.All }克隆再调用。 DockSides.None是允许的,意思是该工具窗口只能浮动 —— 每次PinToSide都会抛异常,拖拽期间每个停靠边目标都会变暗。
侧钉(自动隐藏)¶
DockManager 上的 LeftSide、TopSide、RightSide 与 BottomSide 承载已钉住的工具窗口。每个都会折叠为一个边缘图标;点击图标展开弹出层,点击外部则收回去:
class SidePinDemo : Component
{
public override Element Render() => new DockManager
{
Layout = new Document
{
Title = "Document",
Key = "doc:main",
Content = VStack(8,
TextBlock("Document area").SemiBold(),
TextBlock("Click the pinned tab on the right to expand it."),
TextBlock("Pin / unpin from inside the popup to toggle.")
).Padding(16)
},
RightSide = new[]
{
new ToolWindow
{
Title = "Properties",
Key = "tool:properties",
Content = VStack(4,
TextBlock("Name").SemiBold(),
TextBlock("Width: 240"),
TextBlock("Height: 120")
).Padding(12)
},
},
};
}

侧钉内容请使用 ToolWindow。它默认启用钉住与自动隐藏的交互能力,因此用户可以在运行时钉住与取消钉住,而「已移到侧边」的状态也能通过持久化往返保存。
持久化¶
设置 PersistenceId 即可启用自动保存/恢复。Reactor 让布局 JSON 走 WindowPersistedScope["docking:<id>"],因此该排布能在应用重启后幸存:
class PersistenceDemo : Component
{
public override Element Render() => new DockManager
{
// Layout JSON is auto-saved to WindowPersistedScope["docking:my-shell"].
// It is the restore fallback when a later mount leaves Layout null.
PersistenceId = "my-shell",
Layout = new DockSplit(
Orientation.Horizontal,
new DockNode[]
{
new ToolWindow
{
Title = "Outline",
Key = "tool:outline",
Width = 240,
Content = TextBlock("Rearrange me, then relaunch.").Padding(12)
},
new Document
{
Title = "Editor",
Key = "doc:editor",
Content = TextBlock("Layout restores from PersistenceId when no declarative Layout is supplied.").Padding(12)
},
}),
};
}

只要你提供了声明式 Layout,它就是真相来源。当之后的某次挂载使用同一 PersistenceId 且把 Layout 留为 null 时,持久化的 JSON 才作为恢复兜底。用不同的 PersistenceId 重新渲染即可从新开始。
浮动撕离¶
当用户把标签标题拖到空白区域时,指针处会出现一个浮动窗口,其自定义标题栏由 IDockAdapter 提供:
class FloatingChromeAdapter : IDockAdapter
{
public Element? OnContentCreated(DockableContent content) => null;
public void OnGroupCreated(DockTabGroupContext group) { }
// Custom title bar painted on torn-out floating windows.
public Element? GetFloatingWindowTitleBar(DockableContent? source) =>
HStack(8,
TextBlock("📌").Opacity(0.7),
TextBlock(source?.Title ?? "Floating").SemiBold(),
TextBlock(" — My App").Opacity(0.5)
).Padding(12, 6, 12, 6);
}
在 DockManager.Adapter 上传入该适配器。当窗格从持久化 JSON 重建时,会调用 OnContentCreated —— 返回要挂载进其中的 Reactor 子树,以 content.Key 为键。若只是为了观察与取消,请优先使用按事件划分的 DockManager 回调,例如 OnContentFloating、OnContentDocking、OnDocumentClosing 与 OnLayoutChanged;较旧的行为接口已废弃。
窗格 Hook¶
窗格内容可以通过 Hook 读取停靠上下文:
| Hook | 用途 |
|---|---|
ctx.UsePane() |
当前子树的稳定窗格身份(Key、标题、角色) |
ctx.UseDockState() |
当前窗格状态:已停靠、浮动、自动隐藏、已展开或已隐藏 |
ctx.UseIsActivePane() |
仅对活动窗格为 true |
ctx.UseDockPanePersisted(key, initial) |
以窗格键自动加前缀的窗口作用域持久化状态 |
请节制使用 ctx.UseDockLayout() —— 它会在任何结构性布局变化时重渲染,适用于 devtools 与诊断,而非普通的窗格主体。
提示¶
对有状态内容的窗格始终设置 Key。 一个没有 Key 的窗格内部的受控 TextBox,会在用户拖拽该标签页的瞬间丢掉草稿文本 —— 协调器无法判断它是「同一个」窗格,因此会重新挂载该子树。键可以是字符串、GUID、枚举,或任何可判等的领域标识符。
从数据构建树,而不是手写分支。 用 .Select(d => new DockableContent(…)) 映射一个 List<DocumentVm> 是驱动已打开文档的惯用方式。这里没有 DocumentsSource 绑定 API;闭包来完成这份工作。
每个宿主注册一次。 DockingNativeInterop.Register 是幂等的,但最自然的调用位置是 ReactorApp.Run 上的 configure: 回调。通过 ReactorApp.OpenWindow 打开次级窗口的应用,应在每个新的 ReactorHost 上注册。
布局是不可变 record —— 产生一棵新树。 与任何 Reactor 元素一样,用一个新的 DockManager 重新渲染来完成修改。键控协调负责差异比对;底层控件不需要你自己管理。