65.9K
CodeProject 正在变化。 阅读更多。
Home

UniDock - 全新多平台 UI 停靠框架。UniDock 强大功能。

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (34投票s)

2021年11月3日

CPOL

26分钟阅读

viewsIcon

55389

介绍了 UniDock(全新的多平台 UI 停靠框架)的强大新功能。

引言

请注意,文章和示例代码都已更新,可与最新版本的 Avalonia - 11.0.6 配合使用。另请注意,新的 UniDock 演示位于 [NP.Ava.Demos](https://github.com/npolyak/NP.Ava.Demos) 存储库中,而不是像以前那样在 NP.Avalonia.Demos 中。

关于 UniDock

UniDock 是一个全新的多平台停靠框架。它建立在 Avalonia 可视化开发包之上。

Avalonia 是一个优秀的跨平台开源UI框架,用于开发

  • 可在Windows、Mac和Linux上运行的桌面解决方案
  • 在浏览器中运行的Web应用程序(通过WebAssembly)
  • 适用于Android、iOS和Tizen的移动应用程序。

要了解有关 Avalonia 的更多信息,请参阅 使用 AvaloniaUI 进行多平台 UI 编码的简单示例。第一部分 - AvaloniaUI 构建模块多平台 AvaloniaUI .NET 框架编程基本概念的简单示例多平台 Avalonia .NET 框架 XAML 基础的简单示例多平台 Avalonia .NET 框架编程高级概念的简单示例 文章以及 Avalonia 文档。

本文演示的 UniDock 功能

UniDock 最近已修改为在 Avalonia 11(仅限桌面)下工作。

以下是本文讨论的功能:

  • 停靠到窗口侧面。
  • 可编辑的停靠状态,允许用户更改一些停靠参数,并使用组标题(在可编辑状态下可见)一次性拉出整个窗格组,而不是逐个窗格。
  • 在可编辑状态下将多个停靠窗格锁定在一起,使它们像单个窗格一样。也可以在可编辑状态下解除之前存在的锁定。
  • 在可编辑状态下更改选项卡式组的一些参数,允许将选项卡放在右侧、顶部、左侧或底部,还允许选项卡不可拖动和不可销毁。
  • 稳定组 - 可以从某些窗口中拉出并添加到某些窗口中,但不能被销毁的组。如果浮动窗口中有一个或多个稳定组,则除非将稳定组从中拉出,否则该窗口无法销毁。
  • 组和窗格的默认位置 - 它们可以指定一个稳定组作为其默认父级,然后可以提供功能将这些窗格和组移动到其默认位置。
  • 在 XAML 中创建浮动窗口。
  • GroupOnlyById 标志允许窗格和组仅相互停靠 - 当您有一个特殊区域并希望某些类型的停靠窗格仅停靠在那里时,这很有用。
  • 控制停靠窗格的可见性。
  • 使用非可视化(不依赖于 Avalonia 框架)视图模型来添加、删除、选择或更改停靠窗格的可见性。视图模型还可以用于保存和恢复不属于 UniDock 框架的参数(它们允许几乎任意扩展 UniDock 的保存/恢复功能)。
  • 突出显示窗口中的活动窗格和活动窗口中的不同突出显示。
  • 将主窗口作为所有浮动窗口的所有者。
  • 在停靠组中使用 MainWindowDataContext

已知问题

目前,UniDock 在 Windows 上运行完美或接近完美。唯一已知的问题是,当浮动窗口被拖动到多个重叠窗口上方时,UniDock 会感到困惑。这是 Avalonia 目前的限制 - 希望它要么在 Avalonia 中解决,要么我会在 UniDock 中添加一些功能来处理这个问题。

Linux 版本存在指南针定位不正确的问题。我计划尽快解决。

Mac 上已知的问题是浮动(自定义)窗口无法调整大小。

Linux 和 Max 都存在一个问题,即在将新创建的浮动窗口从另一个窗口拖出后,需要再次单击其标题。用户通常会很快自行学习,并且不会破坏用户体验。

UniDock 功能演示

代码位置

这些示例的代码位于 NP.Ava.Demos 存储库的 NP.Demos.UniDockFeatures 文件夹下。

停靠到窗口侧面

UniDock 功能允许将浮动窗口的内容停靠到空组中,或停靠到顶级组内的侧面(对于浮动窗口而言,这意味着停靠到其侧面,因为顶级组占据整个浮动窗口)。

该示例位于 NP.Demos.DockingToWindowSidesDemo 项目下。

在 Visual Studio 中打开并运行该项目。您将看到以下内容:

顶部有两个窗格,底部有三个选项卡。拉出一个选项卡,例如选项卡 2。然后将其移动到主窗口上方,并选择其左侧的放置区域(如下图中的红色椭圆形所示:)

将鼠标(连同窗口)移动到该区域上方并释放鼠标以将“选项卡 2”放置在其上。“选项卡 2”停靠窗格将添加到窗口的右侧。

同样,您可以选择将浮动窗口的内容放置在主窗口的顶部、左侧或底部。

为了使用 UniDock 功能,您必须从 nuget.org 位置安装 NP.Ava.UniDock nuget 包。之后,您甚至可以删除对 Avalonia 包的引用,因为 UniDock 包已包含对它们的引用。

特定于示例的代码位于两个 XAML 文件中:App.axamlMainWindow.axaml

App.axaml 仅包含对 XAML 样式和资源文件的引用。

<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="NP.Demos.DockingToWindowSidesDemo.App">
    <Application.Styles>
      <SimpleTheme/>
      <StyleInclude Source="avares://NP.Ava.Visuals/Themes/CustomWindowStyles.axaml"/>
      <StyleInclude Source="avares://NP.Ava.UniDock/Themes/DockStyles.axaml"/>

    </Application.Styles>
</Application>  

MainWindow.axaml 文件包含重要的代码。

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="NP.Demos.DockingToWindowSidesDemo.MainWindow"
        Title="NP.Demos.DockingToWindowSidesDemo"
        xmlns:np="https://np.com/visuals"
        Width="600"
        Height="400">
  <Window.Resources>
    <!-- Define the dock manager-->
    <np:DockManager x:Key="TheDockManager"/>
  </Window.Resources>
  <Grid>
    <!-- top level group should reference the dock manager-->
    <np:RootDockGroup TheDockManager="{StaticResource TheDockManager}">
      <!-- Second Level group arranges the top and the bottom parts 
           vertically-->
      <np:StackDockGroup TheOrientation="Vertical">
        <!-- top group arranges two top Dock Panes horizontally-->
        <np:StackDockGroup TheOrientation="Horizontal">
          <np:DockItem Header="Hi">
            <TextBlock Text="Hi World!"/>
          </np:DockItem>
          <np:DockItem Header="Hello">
            <TextBlock Text="Hello World!"/>
          </np:DockItem>
        </np:StackDockGroup>

        <!-- Tabbed group at the bottom -->
        <np:TabbedDockGroup>
          <np:DockItem Header="Tab1">
            <TextBlock Text="This is tab1"/>
          </np:DockItem>
          <np:DockItem Header="Tab2">
            <TextBlock Text="Tab2 is here"/>
          </np:DockItem>
          <np:DockItem Header="Tab3">
            <TextBlock Text="Finally - Tab3"/>
          </np:DockItem>
        </np:TabbedDockGroup>
      </np:StackDockGroup>
    </np:RootDockGroup>
  </Grid>
</Window>

UniDock 概念的一些解释

基于上述示例,我们可以解释(或对于那些阅读过上一篇文章的人来说,回顾)UniDock 概念。

所有主要的 UniDock 类和接口都定义在 NP.Ava.UniDock 项目和同名命名空间 NP.Ava.UniDock 下。

有四个主要类用于创建停靠布局:

  1. DockItem 表示可停靠或选项卡式窗格。
  2. StackDockGroup 用于垂直或水平排列其内容(停靠窗格或其他停靠窗格组)。其属性 TheOrientation 用于选择 VerticalHorizontal 方向。
  3. TabbedDockGroup 表示选项卡式窗格组(每个窗格由 DockItem 定义)。
  4. RootDockGroup 是顶级组 - 在停靠层次结构中不应有任何父级。用户定义的窗口(例如,我们的 MainWindow)可以包含多个 RootDockGroups,但每个浮动窗口只包含一个 RootDockGroupRootDockGroup 最多只能有一个子组或 DockItem

上述四个类都实现了 IDockGroup 接口。

IDockGroup 在停靠中扮演核心角色。

其最重要的属性是:

  1. IDockGroup? DockParent 是停靠对象在停靠层次结构中的父级。代表树顶部的 RootDockGroup 对象的 DockParent 始终为 null
  2. IList<IDockGroup> DockChildrenIDockGroup 对象在停靠层次结构中的子级集合。代表树底部的 DockItem 对象的 DockChildren 始终为空。

每个浮动窗口都具有一个停靠层次结构,该层次结构以树顶部的单个 RootDockGroup 开始。用户定义的窗口可以包含多个停靠层次结构,也可以不包含任何层次结构,具体取决于开发人员的意愿。

可编辑停靠状态

UniDock 新版本提供的最重要的功能之一是能够将 DockManager 切换到可编辑状态并返回。

可编辑状态提供了以下修改停靠配置的功能:

  • 每个 StackDockGroupTabbedDockGroup 都会获得自己的标题,并且可以一次性从窗口中拖出,而无需用户逐个拖动每个 DockItem
  • StackDockGroup 的标题允许锁定(或解锁)其内容,使其变得像单个文档窗格一样。
  • TabbedDockGroup 标题提供了一个重要的按钮,允许更改选项卡的位置 - 可以选择 TopRightBottomLeft 位置。另一个按钮允许使选项卡不可拖动。
  • 组标题还提供了一些开发人员或高级用户可能需要的信息 - 组的唯一 DockId

所有上述功能都在我们位于 NP.Demos.EditableDockingStateDemo 项目下的示例中得到演示。

在 Visual Studio 中打开解决方案,编译并运行它。初始布局将与上一个示例的初始布局几乎完全相同,除了右下角有一个带有铅笔图标的小 ToggleButton

按下按钮,然后瞧,每个组都获得了自己的标题,其中包含一些信息和按钮。

要将窗口移动到正常状态,需要再次单击按钮(但暂时不要这样做)。

将鼠标指针放在“StackDockGroup_3”标题上方 - 组内容将以浅蓝色突出显示。

如果您将指针放在“StackDockGroup_2”标题上方,也会发生同样的情况,只是现在整个停靠布局将以浅蓝色突出显示,因为该组包含整个停靠布局。

单击“StackDockGroup_3”标题内的某个位置,然后拉出两个水平堆叠的窗格 - “Hi”和“Hello”。

目前有两个窗口 - 包含选项卡“Tab1”、“Tab2”和“Tab3”的主窗口,以及包含两个水平堆叠的可停靠窗格“Hi”和“Hello”的浮动窗口。

请注意,以前的顶级组“StackDockGroup_2”已从主窗口中消失 - 它不再需要,因为它只有一个子组,并且在停靠优化期间已自动删除。

此外,请注意,浮动窗口未处于可编辑状态,并且窗口标题包含一个带有铅笔的编辑按钮。

单击编辑按钮,浮动窗口将更改为可编辑状态,显示其唯一组“StackDockGroup_3”的标题。

查看组标题中的锁定/解锁 ToggleButton

单击按钮,组内容进入锁定状态 - 两个停靠窗格“Hi”和“Hello”失去其各自的标题,并且按钮现在通过将图标更改为锁定并变为深蓝色来指示该组已锁定。

对于几乎所有目的,锁定组都像单个停靠窗格一样,只是您仍然可以使用它们之间的窗格分割器调整窗格的大小。此时唯一不能做的事情是,将锁定窗格作为其中一个选项卡添加到选项卡式组中。此功能将在以后添加。

要使锁定的停靠窗格再次像单独的停靠窗格一样工作,您可以解锁它们的组。

现在,将注意力转向包含选项卡式组的主窗口。查看用于更改选项卡侧面的 ComboBox

在所有四个选项中,选择左侧。

选项卡的行为仍然完全相同 - 您可以通过在选项卡区域内拖动它们来更改选项卡顺序,或者您可以完全拖出一个选项卡,或者您可以单击其“X”按钮来删除选项卡。

现在取消选中“允许选项卡停靠”复选框。

您无法再拖出选项卡、更改其顺序或删除它们。

现在让我们看看代码。所有相关代码都位于 MainWindow.axaml 文件中(App.axaml 仅包含对我们使用的一些样式的引用)。

除了两个重要区别外,此示例的所有功能都与上一个示例相同:

  1. DockManagerIsInEditableState 属性设置为 true
    <ResourceDictionary>
      <np:DockManager x:Key="TheDockManager"
                      IsInEditableState="True"/>
    </ResourceDictionary>
  2. MainWindow.axaml 文件的底部添加了一个“EditToggleButton
    <ToggleButton Classes="WindowIconButton IconButton IconToggleButton"
                  np:AttachedProperties.IconData="{StaticResource Pencil}"
                  IsChecked="{Binding Path=$parent[Window].
                  (np:DockAttachedProperties.IsInDockEditableState), Mode=TwoWay}"
                  Margin="5,0"
                  Grid.Row="1"
                  HorizontalAlignment="Right"/>  

    按钮的 IsChecked 属性绑定到 MainWindownp:DockAttachedProperties.IsInDockEditableState 附加属性。

正如您所看到的,一旦 DockManager 切换到可编辑状态,浮动窗口的编辑/停止编辑切换按钮将自动出现在窗口标题中,但对于用户定义的窗口(我们的 MainWindow 当然是用户定义的窗口),开发人员应自行添加此类按钮并为其提供所有布线。

将窗口的 np:DockAttachedProperties.IsInDockEditableState 附加属性切换为 true 将使该窗口内的所有组切换到可编辑状态。

稳定组

StackDockGroupsTabbedDockGroups 具有属性 IsStableGroup。此属性默认为 false,但如果设置为 true,则使组稳定。稳定组不能被删除,包含它们的浮动窗口不能被(合法地)销毁,除非您将这些组从中拉出。

组的稳定性应仅在组创建时设置一次(例如,在 XAML 代码中),并且在应用程序的整个生命周期中不应更改。

为什么需要稳定组将在后续示例中解释。

位于 NP.Demos.StableGroupDemo 解决方案下的稳定组示例与之前的示例具有完全相同的代码,除了在 MainWindow.xaml 文件中设置了一个组属性。

<np:StackDockGroup TheOrientation="Horizontal"
                   IsStableGroup="True">

如果您运行应用程序并将主窗口切换到可编辑状态,您将在组标题中看到锚点图标。

将组从主窗口拖到浮动窗口中。您会看到浮动窗口没有关闭按钮或菜单选项。

如果浮动窗口包含稳定组,则它们不会关闭。当主窗口关闭时,通常整个应用程序都需要关闭;但是,如果您有一些窗口不关闭应用程序但可能包含一些停靠窗格,则开发人员有责任提供检查和行为,以防止此类窗口具有稳定组时窗口关闭。

停靠组和窗格的默认父组和位置

每个停靠组和停靠窗格都可以给定默认父级和默认父级子级中的默认顺序。然后,当它们不在默认父级下时,可以使用一些特殊功能将组或窗格移动到其默认父级下。此功能作为可视菜单和可供开发人员使用的 public 方法提供。

请注意,这是一个稳定组派上用场的示例 - 默认父组都应该是稳定的,否则,如果此类组被删除,子级将无法找到其默认父级。

编译并运行位于 NP.Demos.DefaultParentDemo 项目下的示例。将停靠框架更改为可编辑状态,并将底部的整个选项卡组拉入一个单独的浮动窗口。右键单击浮动窗口的标题,然后选择“恢复默认位置”菜单项。

浮动窗口将消失,选项卡组将重新出现在其原始位置。

尝试将选项卡或窗格从其默认位置拉出,您将能够将它们全部返回到其原始位置。

MainWindow.axaml 代码与前两个示例几乎相同,只是现在每个组(除了根组)都是稳定的,并且具有手动分配的 DockId,它具有一些含义。例如:

<np:StackDockGroup ...
                 DockId="TopLevelGroup"
                 IsStableGroup="True">

在此示例中,大多数组和 DockItems 也设置了 DefaultDockGroupIdDefaultDockOrderInGroup 属性(对于组中的第一个项目,DefaultDockOrderInGroup 不必总是设置,因为它默认为 0)。

<np:DockItem Header="Hello"
             DefaultDockGroupId="TopStackGroup"
             DefaultDockOrderInGroup="1">
    <TextBlock Text="Hello World!"/>
</np:DockItem>  

重要提示:目前,开发人员有责任确保 DefaultDockGroupId 属性指定的每个组都是稳定的。如果不使其稳定,如果用户删除此类组,可能会导致应用程序崩溃。

在 XAML 中创建浮动窗口

有时,可能希望将其组的默认位置不在主窗口中,而是在浮动窗口中。新的 UniDock 版本允许在 XAML 中指定默认浮动窗口。

编译并运行 NP.Demos.DefineFloatingWindowInXamlDemo 示例。您将看到两个窗口弹出 - 主窗口,包含选项卡,以及包含两个停靠窗格“Hi”和“Hello”的浮动窗口。

XAML 在主窗口的停靠结构中包含相同的选项卡组,而以前位于顶部的两个窗格现在已分离到单独的浮动窗口中。

<np:RootDockGroup TheDockManager="{StaticResource TheDockManager}">
    <np:RootDockGroup.FloatingWindows>
      <np:FloatingWindowContainer WindowSize ="400, 200"
                                  WindowRelativePosition="800,100"
                                  WindowId="AnotherWindow"
                                  Title="My Floating Window">
        <np:StackDockGroup TheOrientation="Vertical"
                           DockId="TopLevelGroup"
                           IsStableGroup="True">
          <!-- top group arranges two top Dock Panes horizontally-->
          <np:StackDockGroup TheOrientation="Horizontal"
                             DockId="TopStackGroup"
                             DefaultDockGroupId="TopLevelGroup"
                             IsStableGroup="True">
            <np:DockItem Header="Hi"
                         DefaultDockGroupId="TopStackGroup"
                         DefaultDockOrderInGroup="1">
              <TextBlock Text="Hi World!"/>
            </np:DockItem>
            <np:DockItem Header="Hello"
                         DefaultDockGroupId="TopStackGroup"
                         DefaultDockOrderInGroup="1">
              <TextBlock Text="Hello World!"/>
            </np:DockItem>
          </np:StackDockGroup>
        </np:StackDockGroup>
      </np:FloatingWindowContainer>
    </np:RootDockGroup.FloatingWindows>
    ...
</np:RootDockGroup>  

RootDockGroupFloatingWindows 属性可以包含任意数量的 FloatingWindowContainer 对象,每个对象都将生成一个具有某些停靠结构的浮动窗口。请注意,WindowSizeWindowRelativePositionWindowId 属性是必需参数(没有它们,浮动窗口将不会出现)。FloatingWindowContainer 的其余属性(例如 Title)是可选的。

使所有浮动窗口成为主窗口的子窗口

在许多应用程序中,您希望浮动窗口是主窗口的子窗口 - 这样主窗口就无法阻止它们(子窗口始终位于其父窗口之上),并且它们在主窗口关闭时也会自动关闭。这通过 MainWindow 的 XAML 打开标签中的一行实现:np:DockAttachedProperties.DockChildWindowOwner="{Binding RelativeSource={RelativeSource Self}}"。在上一节的 NP.Demos.DefineFloatingWindowInXamlDemo 演示的 MainWindow.axaml 文件中,您可以找到这样一行。

 <Window xmlns="https://github.com/avaloniaui"
        ...
        np:DockAttachedProperties.DockChildWindowOwner=
                       "{Binding RelativeSource={RelativeSource Self}}"
        ...>   

只能停靠到某些位置的项

在 Visual Studio 中,文档通常只能相互停靠,或者停靠到 Visual Studio 的所谓主区域,但不能停靠到所谓工具窗口区域或解决方案资源管理器区域。UniDock 中添加了一个简单功能,允许相同的行为。

尝试运行 NP.Demos.GroupOnlyByIdDemo 项目。您可以从主窗口的底部部分拉出各种选项卡,但您只能将它们停靠回选项卡区域,而不能停靠到其他任何地方。此外,您甚至在可编辑模式下也无法将选项卡区域从主窗口中拉出,因为其 IsFloating 属性设置为 false

此功能通过将 GroupByOnlyId 属性设置为相同的非 null 字符串(在我们的示例中,它是字符串“Documents”)仅在选项卡及其 TabbedDockGroup 上实现。

<np:TabbedDockGroup IsStableGroup="True"
                    GroupOnlyById="Documents"
                    CanFloat="False">
  <np:DockItem Header="Tab1"
                GroupOnlyById="Documents">
    <TextBlock Text="This is tab1"/>
  </np:DockItem>
  <np:DockItem Header="Tab2"
                GroupOnlyById="Documents">
    <TextBlock Text="Tab2 is here"/>
  </np:DockItem>
  <np:DockItem Header="Tab3"
                GroupOnlyById="Documents">
    <TextBlock Text="Finally - Tab3"/>
  </np:DockItem>
</np:TabbedDockGroup>

UniDock 将确保 GroupOnlyById 设置为非 null 的组和项目只能停靠到具有相同 GroupOnlyById 值的其他组或项目。

将视图模型与 DockManager 结合使用

DockManager 还允许使用视图模型来创建部分或全部 DockItem 窗格。视图模型以及 DockManagerIUniDockService 非可视接口也可以用于简单地操作 DockItems,包括创建新的停靠项、删除停靠项、选择停靠项。所有这些都将在后续示例中解释。

使用视图模型演示

NP.Demos.UsingViewModelsDemo 展示了如何使用视图模型操作停靠功能。

构建并运行项目。多次单击底部的“添加选项卡”按钮,将在窗口下半部分创建多个选项卡,同时在顶部创建允许控制选项卡可见性的条目。

尝试使用顶部的复选框更改选项卡的可见性。选项卡应相应地消失和重新出现。

现在按下底部的“保存”按钮。添加、删除或重新排列选项卡。然后按下“恢复”按钮。您保存时的配置应恢复(包括选项卡可见性)。

现在让我们看看这个功能的实现。与以前的情况(只有 MainWindow.axaml 文件有重大更改)不同,这个示例也更改了 MainWindow.axaml.cs 文件。

首先,看看 MainWindow.axaml 文件。窗口标签现在有一行将 np:DockAttachedProperty.TheWindowManager 分配给资源。

np:DockAttachedProperties.TheDockManager="{DynamicResource TheDockManager}"

这是因为我们想要保存和恢复布局 - 因此 DockManager 需要知道每个具有停靠层次结构的窗口。

请注意,我们使用 DynamicResource 扩展,因为资源是在 Window 标签之后定义的。

在 XAML 文件的顶部,DockManager 被定义为一个资源,之后,我们添加了控制选项卡可见性的项目 ListBox

<Window.Resources>
  <!-- Define the dock manager-->
  <np:DockManager x:Key="TheDockManager"/>
</Window.Resources>
<Grid RowDefinitions="Auto, *, Auto"
      Margin="5">
  <ListBox Items="{Binding Path=DockItemsViewModels, Source={StaticResource TheDockManager}}"
            Height="70">
    <ListBox.ItemTemplate>
      <DataTemplate>
        <CheckBox IsChecked="{Binding Path=IsDockVisible, Mode=TwoWay}"
                  Content="{Binding DockId}"/>
      </DataTemplate>
    </ListBox.ItemTemplate>
  </ListBox>
   ...
</Grid>  

ListBoxItems 属性绑定到 DockManagerDockItemsViewModels 集合中。每个 CheckBoxIsChecked 属性双向绑定到相应项目视图模型的 IsDockVisible 属性,而 CheckBox 的内容显示项目的 DockId

添加视图模型项的 TabbedDockGroup 被定义为一个稳定组,其 DockId="Tabs"

<np:TabbedDockGroup IsStableGroup="True"
                    DockId="Tabs"/>

底部定义了三个按钮:“AddTabButton”、“SaveButton”和“RestoreButton”。

<StackPanel Orientation="Horizontal"
            Grid.Row="2"
            HorizontalAlignment="Right">
  <Button x:Name="AddTabButton"
          Content="Add Tab"
          Padding="10,5"
          Margin="5"/>

  <Button x:Name="SaveButton"
          Content="Save"
          Padding="10,5"
          Margin="5"/>

  <Button x:Name="RestoreButton"
          Content="Restore"
          Padding="10,5"
          Margin="5"/>
</StackPanel>  

现在,将注意力转向 MainWindow.axaml.cs 文件。请注意,我们正在处理 IUniDockInterface 而不是 DockManager 类。此接口不包含对 Avalonia 特定功能的任何引用,可以在纯非可视化项目中使用。

以下是我们获取 DockManager 引用并在构造函数中设置视图模型集合的方式:

// set the uniDockService interface to contain the reference to the
// dock manager defined as a resource.
_uniDockService = (IUniDockService) this.FindResource("TheDockManager")!;

// set the DockItemsViewModels collection to an observable
// collection of DockItemViewModelBase items.
_uniDockService.DockItemsViewModels = 
    new ObservableCollection<DockItemViewModelBase>();  

以下是 AddButton 点击事件的处理程序:

private int _tabNumber = 1;
private void AddTabButton_Click(object? sender, RoutedEventArgs e)
{
    string tabStr = $"Tab_{_tabNumber}";
    _uniDockService.DockItemsViewModels.Add
    (
        new DockItemViewModelBase
        {
            DockId = tabStr,
            Header = tabStr,
            Content = $"This is tab {_tabNumber}",
            DefaultDockGroupId = "Tabs",
            DefaultDockOrderInGroup = _tabNumber,
            IsSelected = true,
            IsActive = true
        });

    _tabNumber++;
}  

每次单击 AddButton 时,我们都会向视图模型集合添加一个 DockItemViewModelBase 对象。它应该有一个唯一的 DockId - 在处理视图模型时,确保唯一性是开发人员的责任。

其他重要属性是:

  • DefaultDockGroupId - 应指向我们希望放置与新 DockItemViewModelBase 对象对应的 DockItem 的父组的 DockId。在我们的示例中,我们使用 TabbedDockGroup 具有的 DockId“Tabs”。
  • DefaultDockOrderInGroup - 确定项在其父组下插入的顺序。

以下是我们保存和恢复布局和视图模型项的方式:

private const string DockSerializationFileName = "DockSerialization.xml";
private const string VMSerializationFileName = "DockVMSerialization.xml";

private void SaveButton_Click(object? sender, RoutedEventArgs e)
{
    // save the layout
    _uniDockService.SaveToFile(DockSerializationFileName);

    // save the view models
    _uniDockService.SaveViewModelsToFile(VMSerializationFileName);
}

private void RestoreButton_Click(object? sender, RoutedEventArgs e)
{
    // clear the view models
    _uniDockService.DockItemsViewModels = null;

    // restore the layout
    _uniDockService.RestoreFromFile(DockSerializationFileName);

    // restore the view models
    _uniDockService.RestoreViewModelsFromFile(VMSerializationFileName);

    // select the first tab.
    _uniDockService.DockItemsViewModels?.FirstOrDefault()?.Select();
} 

请注意,您必须在恢复布局之前清除视图模型 - 否则,布局可能会受到视图模型集合更改的影响 - 具有相同 DockIdDockItems 将被删除。

具有自定义内容和自定义视觉表示的停靠视图模型

运行 NP.Demos.CustomViewModelsDemo 应用程序。按下“添加股票”按钮添加多只股票。它将添加多个选项卡 - 奇数选项卡将包含 IBM 股票的模拟信息,偶数选项卡将包含 Microsoft 的模拟信息(顺便说一句,不要寻找接近真实数字的买卖价格 - 这完全是 100% 的模拟)。

尝试重新排列选项卡,包括可能拉出其中一些。保存布局。重新启动应用程序并按下恢复按钮。确保布局已恢复,并且其窗格中的数据相同。

这里有一些我们以前没有演示过的东西:

  • 我们设法创建了一些具有复杂视图模型(股票)的实体,并使用 DataTemplate 在我们的选项卡中展示了其复杂的视觉表示,如下所示。标题也由围绕相同视图模型构建的自己的 DataTemplate 表示。
  • 我们可以保存和恢复与股票对应的这些视图模型,并将它们与停靠布局同步,以便视图模型及其视觉表示将出现在正确的停靠窗格中。

现在让我们看看 MainWindow.axamlMainWindow.axaml.csStockViewModel.csStockDockItemViewModel.cs 文件中的代码。

停靠区域非常简单 - 它由 RootDockGroup 内的 DockId 为“Stocks”的 TabbedDockGroup 组成。

<np:RootDockGroup TheDockManager="{StaticResource TheDockManager}"
                  Grid.Row="1">
    <np:TabbedDockGroup IsStableGroup="True"
                        DockId="Stocks"/>
</np:RootDockGroup>  

底部有三个按钮:“Add Stock”、“Save”和“Restore”。

<StackPanel Orientation="Horizontal"
            Grid.Row="2"
            HorizontalAlignment="Right">
  <Button x:Name="AddStockButton"
          Content="Add Stock"
          Padding="10,5"
          Margin="5"/>

  <Button x:Name="SaveButton"
          Content="Save"
          Padding="10,5"
          Margin="5"/>

  <Button x:Name="RestoreButton"
          Content="Restore"
          Padding="10,5"
          Margin="5"/>
</StackPanel>  

在文件顶部,在 Window.Resources 部分,我们将 DockManager 作为资源保留,但还定义了两个 DataTemplates:一个用于标题,一个用于停靠窗格的内容。

<Window.Resources>
  <!-- Define the dock manager-->
  <np:DockManager x:Key="TheDockManager"/>

  <!-- Data template for the header of dock pane -->
  <DataTemplate x:Key="StockHeaderDataTemplate">
    <TextBlock Text="{Binding Path=Symbol, StringFormat='Symbol: \{0\}'}"/>
  </DataTemplate>

  <!-- Data template for the content of dock pane -->
  <DataTemplate x:Key="StockDataTemplate">
    <Grid Margin="5"
          RowDefinitions="Auto, Auto, Auto, Auto">
      <StackPanel Orientation="Horizontal"
                  HorizontalAlignment="Left">
        <TextBlock Text="Symbol: "/>
        <TextBlock Text="{Binding Symbol}"
                   FontWeight="Bold"/>
      </StackPanel>

      <TextBlock Text="{Binding Description}"
                 Grid.Row="1"
                 Margin="0,10,0,5"
                 HorizontalAlignment="Left"/>

      <StackPanel Orientation="Horizontal"
                  HorizontalAlignment="Left"
                  Grid.Row="2"
                  Margin="0,5">
        <TextBlock Text="Ask: "/>
        <TextBlock Text="{Binding Path=Ask, StringFormat='\{0:0.00\}'}"
                   Foreground="Green"/>
      </StackPanel>
      <StackPanel Orientation="Horizontal"
                  HorizontalAlignment="Left"
                  Grid.Row="3"
                  Margin="0,5">
        <TextBlock Text="Bid: "/>
        <TextBlock Text="{Binding Path=Bid, StringFormat='\{0:0.00\}'}"
                   Foreground="Red"/>
      </StackPanel>
    </Grid>
  </DataTemplate>
</Window.Resources>  

这两个模板都围绕 StockViewModel 类定义。

public class StockViewModel : VMBase
{
    [XmlAttribute]
    public string? Symbol { get; set; }

    [XmlAttribute]
    public string? Description { get; set; }

    [XmlAttribute]
    public decimal Ask { get; set; }

    [XmlAttribute]
    public decimal Bid { get; set; }

    public override string ToString()
    {
        return $"StockViewModel: Symbol={Symbol}, Ask={Ask}";
    }
}  

当然,在现实生活中,我们会让 AskBid 属性在它们改变时触发 PropertyChanged 事件,但对于我们的模拟,我们简化了与 UniDock 功能不直接相关的示例部分。

我们还定义了一个类 StockDockItemViewModel - 它的目的是将 StockViewModelDockItemViewModel 结合起来。

public class StockDockItemViewModel : DockItemViewModel<StockViewModel>
{
}

DockItemViewModel<TViewModel> 派生自我们在上一个示例中使用的 DockItemViewModelBase 类。它还定义了 TViewModel 类型的属性 TheVM,该属性应包含视图模型对象(在我们的示例中,它将包含 StockViewModel 对象)。其 HeaderContent 属性被覆盖以返回 TheVM 属性包含的对象。

public class DockItemViewModel<TViewModel> : DockItemViewModelBase
    where TViewModel : class
{
    #region TheVM Property
    private TViewModel? _vm;
    [XmlElement]
    public TViewModel? TheVM
    {
        get
        {
            return this._vm;
        }
        set
        {
            if (this._vm == value)
            {
                return;
            }

            this._vm = value;
            this.OnPropertyChanged(nameof(TheVM));
        }
    }
    #endregion TheVM Property

    [XmlIgnore]
    public override object? Header
    {
        get => TheVM;
        set
        {

        }
    }

    [XmlIgnore]
    public override object? Content
    {
        get => TheVM;
        set
        {

        }
    }
}  

现在让我们看看 MainWindow.axaml.cs 文件,其中所有内容都结合在一起。我们仍然将 _uniDockService 分配为包含对停靠管理器的引用。

// set the uniDockService interface to contain the reference to the
// dock manager defined as a resource.
_uniDockService = (IUniDockService) this.FindResource("TheDockManager")!;

// set the DockItemsViewModels collection to an observable
// collection of DockItemViewModelBase items.
_uniDockService.DockItemsViewModels = 
    new ObservableCollection<DockItemViewModelBase>();

我们定义了两个 StockViewModel 对象 - 一个用于 IBM,一个用于 MSFT,然后将它们放入数组 Stocks 中。

private static StockViewModel IBM =
    new StockViewModel
    {
        Symbol = "IBM",
        Description = "International Business Machines",
        Ask = 51,
        Bid = 49
    };

private static StockViewModel MSFT =
    new StockViewModel
    {
        Symbol = "MSFT",
        Description = "Microsoft",
        Ask = 101,
        Bid = 99
    };

private static StockViewModel[] Stocks =
{
    IBM,
    MSFT
};

添加新股票时,我们增加 _stockNumber,如果它是偶数,我们选择 IBM,如果它是奇数,我们选择 MSFT。

private int _stockNumber = 0;
private void AddStockButton_Click(object? sender, RoutedEventArgs e)
{
    // for even choose IBM, for odd - msft
    var stock = Stocks[_stockNumber % 2];
    int tabNumber = _stockNumber + 1;
    _uniDockService.DockItemsViewModels.Add
    (
        new StockDockItemViewModel
        {
            DockId = $"{stock.Symbol}_{tabNumber}",
            TheVM = stock,
            DefaultDockGroupId = "Stocks",
            DefaultDockOrderInGroup = _stockNumber,
            HeaderContentTemplateResourceKey = "StockHeaderDataTemplate",
            ContentTemplateResourceKey = "StockDataTemplate",
            IsSelected = true,
            IsActive = true,
            IsPredefined = false
        });

    _stockNumber++;
}  

然后我们创建 StockDockItemViewModel 对象并将其添加到我们的 _uniDockService 的视图模型的可观察集合中。

请注意,我们将 StockDockItemViewModel.TheVM 属性设置为 StockViewModel 类型的 stock 对象。此外,非常重要的是我们设置了:

  • 默认父组为“Stocks” - DefaultDockGroupId = "Stocks"
  • HeaderContentTemplateResourceKey 为为标题定义的 DataTemplate 的资源键:HeaderContentTemplateResourceKey = "StockHeaderDataTemplate"
  • ContentTemplateResourceKey 为为内容定义的 DataTemplate 的资源键:ContentTemplateResourceKey = "StockDataTemplate"
  • IsPredefined=false 意味着 DockItem 不是用户定义的,而是来自视图模型。

保存和恢复布局和视图模型的功能与之前几乎完全相同,除了在恢复状态下将 StockDockItemViewModel 类型添加到序列化类型中。

// restore the view models
_uniDockService.RestoreViewModelsFromFile
(   
    VMSerializationFileName,
    typeof(StockDockItemViewModel));  // the new type StockDockItemViewModel is added 

重要提示:通常,视图模型将与停靠功能分开保存,作为应用程序中其余视图模型的一部分,因此与其通过 DockManager 存储和恢复视图模型,不如从应用程序的其余部分获取视图模型,并为每个视图模型动态创建相应的停靠项视图模型。

使用两种不同类型的视图模型进行停靠功能演示

假设我们不仅要拥有股票选项卡,还希望拥有与系统接收到的股票订单对应的选项卡。本节将对此进行演示。

运行 NP.Demos.StocksAndOrdersViewModelsDemo 应用程序。您将看到两个窗口弹出:主窗口和旁边的“Orders”窗口。与之前的应用程序相比,主窗口底部多了一个“添加订单”按钮。当按下该按钮时,“Orders”窗口将获得一个与另一个订单对应的选项卡。

添加一些股票和订单并重塑窗口,然后保存和恢复。一切都应该正常运行。

代码与上一个示例非常相似。MainWindow.axaml 文件在其 Window.Resources 部分定义了两个额外的 DataTemplates - OrderHeaderDataTemplate 用于订单窗格的标题,OrderDataTemplate 用于内容。

<DataTemplate x:Key="OrderHeaderDataTemplate">
  <TextBlock Text="{Binding Path=Symbol, StringFormat='\{0\} Order'}"/>
</DataTemplate>

<DataTemplate x:Key="OrderDataTemplate">
  <Grid Margin="5"
        RowDefinitions="Auto, Auto, Auto, *">
    <StackPanel Orientation="Horizontal"
                HorizontalAlignment="Left">
      <TextBlock Text="Symbol: "/>
      <TextBlock Text="{Binding Symbol}"
                  FontWeight="Bold"/>
    </StackPanel>

    <StackPanel Orientation="Horizontal"
                HorizontalAlignment="Left"
                Grid.Row="1"
                Margin="0,5">
      <TextBlock Text="Number of Shares: "/>
      <TextBlock Text="{Binding Path=NumberShares}"/>
    </StackPanel>
    <StackPanel Orientation="Horizontal"
                HorizontalAlignment="Left"
                Grid.Row="2"
                Margin="0,5">
      <TextBlock Text="Market Price: "/>
      <TextBlock Text="{Binding Path=MarketPrice, StringFormat='\{0:0.00\}'}"/>
    </StackPanel>
  </Grid>
</DataTemplate>  

它还有一个额外的“添加订单”按钮。

定义了一个用于订单的 OrderViewModel 类。

public class OrderViewModel : VMBase
{
    public string? Symbol { get; set; }

    public int NumberShares { get; set; }

    public decimal MarketPrice { get; set; }


    public override string ToString()
    {
        return $"OrderViewModel: Symbol={Symbol}";
    }
} 

还有一个 StockDockItemViewModel 类用于将订单适配到停靠基础设施中。

public class OrderDockItemViewModel : DockItemViewModel<OrderViewModel>
{
}  

MainWindow.axaml.cs 文件有一个“添加订单”按钮单击的处理程序,用于创建新的 OrderViewModelOrderDockItemViewModel 对象,并将它们插入到 DockManager 的视图模型集合中。

int _orderNumber = 0;
private void AddOrderButton_Click(object? sender, RoutedEventArgs e)
{
    var stock = Stocks[_orderNumber % 2];
    OrderViewModel orderVM = new OrderViewModel
    {
        Symbol = stock.Symbol,
        MarketPrice = (stock.Ask + stock.Bid) / 2m,
        NumberShares = (_orderNumber + 1) * 1000
    };

    var newTabVm = new OrderDockItemViewModel
    {
        DockId = "Order" + _orderNumber + 1,
        DefaultDockGroupId = "Orders",
        DefaultDockOrderInGroup = _orderNumber,
        HeaderContentTemplateResourceKey = "OrderHeaderDataTemplate",
        ContentTemplateResourceKey = "OrderDataTemplate",
        IsPredefined = false,
        TheVM = orderVM
    };

    _uniDockService.DockItemsViewModels!.Add(newTabVm);

    _orderNumber++;
}

请注意,在此示例中,我们需要将两种类型传递给 DockManager.RestoreViewModelsFromFile 方法 - typeof(StockDockItemViewModel)typeof(OrderDockItemViewModel)

// restore the view models
_uniDockService.RestoreViewModelsFromFile
(   
    VMSerializationFileName,
    typeof(StockDockItemViewModel),
    typeof(OrderDockItemViewModel));  

在 DockItems 和 Groups 中使用来自主窗口的 DataContext

许多人提出了一个问题,即如何在 DockItem 和组中使用主窗口中定义的 DataContext 和资源。

请注意,不应在停靠对象中直接使用 MainWindowDataContext 和资源,因为它们可能会改变其在可视化树中的位置,并因此失去其原始数据上下文并失去对主窗口中定义的资源的引用。

一个简单的演示解决方案 NP.Demos.DataContextDemo 展示了如何将 DockItem 中定义的元素的标题和属性绑定到 MainWindow 中定义的资源。在此示例中,绑定到 Docking 层次结构之外的 DataContext 应以相同的方式完成。

MainWindow.axaml 定义了一个 TestViewModel 类型的资源。

<Window.Resources>
    <ResourceDictionary>
        ...
        <local:TestViewModel x:Key="TheViewModel"
                             Header="The is the Header"
                             Content="This is a test content"/>
    </ResourceDictionary>
</Window.Resources>  

TestViewModel 是一个简单的类,包含两个属性 HeaderContent,两者都是在主项目中定义的 object 类型。

public class TestViewModel
{
    public object? Header { get; set; }

    public object? Content { get; set; }
}   

MainWindow 中定义的第一个 DockItemDockDataContextBinding 属性包含指向此资源对象的 Avalonia Binding

<np:DockItem ...
             DockDataContextBinding="{Binding Source={StaticResource TheViewModel}}">

DockDataContextBinding 设置使 DockItem 本身定义的 DockDataContext 属性包含 TestViewModel 资源,并且在停靠窗格移动或浮动时不会更改。现在可以从 DockItem 内部或其标题绑定到该属性。

<np:DockItem Header="{Binding Path=DockDataContext.Header, RelativeSource={RelativeSource Self}}"
             DockDataContextBinding="{Binding Source={StaticResource TheViewModel}}">
    <TextBlock Text="{Binding Path=DockDataContext.Content, 
               RelativeSource={RelativeSource AncestorType=np:IDockDataContextContainer}}"/>
</np:DockItem>

IDockDataContextContainer 是一个由 DockItem 和各种 Dock 组类型实现的接口,因此 TextBlock 的绑定 RelativeSource 只是指包含此 TextBlockDockItem

运行示例,您将看到以下内容:

您可以将停靠窗格拉出窗口或将其重新停靠到任何您想要的位置,上下文不会改变(除非您将 TestViewModel 的属性设置为可通知并在 C# 代码中更改它们 - 两个可见文本字符串也会因绑定而更改)。

请注意,只有一个 Binding (DockDataContextBinding) 和一个数据上下文属性 (DockDataContext) 用于 DockItem 的标题和内容。这可能会在您需要同时连接两者(如上例所示)时造成轻微不便。您将不得不创建一个将两个对象组合在一起的 ViewModel 类型(如我们的 TestViewModel 所做)- 尽管 TestViewModel 是一个非常简单的类型,并且只增加了非常少的工作量。

默认布局示例

创建默认布局(应用程序开始时采用的布局,以及用户可以随时恢复的布局)的最佳方法是将用户喜欢的布局保存到 XML 文件中,将此 XML 文件作为主项目的一部分,并使用它来恢复默认布局。有两个项目展示了如何实现这一点 - NP.Demos.DefaultLayoutSaveDemo 和 NP.Demos.DefaultLayoutDemo。

首先运行 NP.Demos.DefaultLayoutSaveDemo - 最初应用程序将如下所示:

将布局更改为您想要的任何样子。您可以将窗格制成选项卡式或浮动等。假设您将布局修改为类似以下内容:

按下“保存布局”按钮。布局将存储在可执行文件所在的文件夹中的“DefaultLayout.xml”文件中(对于 .NET5.0,它将位于解决方案文件夹下的 bin/Debug/net5.0 文件夹中)。

保存布局的代码位于 MainWindow.axaml.cs 文件中。我们只需在资源中找到 DockManager,并在“保存布局”按钮单击时调用 _dockManager.SaveToFile("DefaultLayout.xml");

然后我们将此布局文件“DefaultLayout.xml”复制到另一个解决方案 NP.Demos.DefaultLayoutDemo。我们将其添加到解决方案的主项目,并将其属性“Build Action”设置为“Content”,将其“Copy to Output Directory”设置为“Copy if newer”。

现在,构建 DefaultLayoutDemo 项目并运行它。停靠将最初具有默认布局。

这通过从资源中获取 DockManager 并在 MainWindow 构造函数中调用其方法_dockManager.RestoreFromFile("DefaultLayout.xml")来实现。

public MainWindow()
{
    ...

    _dockManager = (DockManager)this.FindResource("TheDockManager")!;

    _dockManager.RestoreFromFile("DefaultLayout.xml");
}    

结论

新的 UniDock 功能使其达到了成熟的程度。目前,它是最优秀、功能最丰富的多平台视觉停靠框架,可在 Windows、Mac 和 Linux 上运行。

该框架在最宽松的 MIT 许可证下发布。请在您的多平台项目中使用它,并告诉我改进它的方法和需要修复的错误。

另外,请留下几行评论,告诉我您对本文的看法以及改进它的方法。

我计划再写一篇文章,描述如何在 UniDock 中自定义停靠样式和行为。

历史

  • 2021年11月3日:初始版本
  • 2023年1月1日:升级到 Avalonia 11
© . All rights reserved.