Skip to content

停靠窗口

Microsoft.UI.Reactor(Reactor)的停靠系统让单个外壳承载多个用户可自由重排的界面 —— 也就是 Visual Studio / VS Code / Photoshop / Figma 那种布局范式。用户可以在分组之间拖动标签页、拆分窗格、把工具窗口钉到侧边,以及把窗格撕离成浮动子窗口;布局跨会话持久保存。

对应的元素是 DockManager。它的 Layout 是一棵不可变的 DockNode 树,描述期望的排布;协调器把这棵树变成原生 WinUI 控件,并在每次重渲染时施加最小化的变更。

最小配置

停靠功能位于可选的 Microsoft.UI.Reactor.Advanced 包中(spec 062 §7)。添加它的包引用:

<PackageReference Include="Microsoft.UI.Reactor.Advanced" Version="0.1.0-preview.15" />

停靠是一种需显式开启的元素类型 —— 在宿主构造时注册它,然后像使用任何其他 Reactor 元素一样使用 DockManager

ReactorApp.Run<DockingApp>(
    title: "Docking",
    width: 900,
    height: 600,
    configure: host => DockingNativeInterop.Register(host.Reconciler));

DockingNativeInterop.RegisterDockManager、拆分条与放置目标元素接入协调器。没有它,你树中的 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),
            }),
    };
}

双窗格停靠布局:左侧是 Solution 工具,右侧是 App.cs 编辑器

树的叶子是窗格记录。可关闭的编辑器式窗格优先用 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 KeyLayout 解析;键不匹配时不动激活状态
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 上的 LeftSideTopSideRightSideBottomSide 承载已钉住的工具窗口。每个都会折叠为一个边缘图标;点击图标展开弹出层,点击外部则收回去:

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)
            },
        },
    };
}

左侧编辑器,右侧钉着 Properties 工具

侧钉内容请使用 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 回调,例如 OnContentFloatingOnContentDockingOnDocumentClosingOnLayoutChanged;较旧的行为接口已废弃。

窗格 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 重新渲染来完成修改。键控协调负责差异比对;底层控件不需要你自己管理。

下一步

  • 窗口 —— 顶层窗口生命周期,也就是停靠功能所栖身的宿主界面。
  • 持久化 —— UsePersisted、作用域,以及停靠布局所走的 WindowPersistedScope
  • 组件 —— Key 规则与 DockableContent.Key 接入的协调器身份模型。
  • Reactor —— 回到文档集索引。