UniDock - 新的多平台对接框架 (介绍)





5.00/5 (23投票s)
本文描述了一个新的多平台对接框架 - UniDock
引言
在本文中,我将介绍我最近创建的一个新的多平台对接框架。该框架基于 Avalonia 跨平台包。
Avalonia 是一个很棒的 UI 包,非常类似于 WPF,但可以在多个平台(Windows、MacOS 和 Linux)上运行,而不会遇到 Web 和 Xamarin 框架遇到的许多问题。特别是,它具有 100% 的可组合性(与 Web 和 Xamarin 不同),并且可以生成一个单一的 .NET 可执行文件,可以在多个平台上运行(与 Xamarin 不同)。
要了解更多关于开始使用 Avalonia 的信息及其与 JavaScript/TypeScript 和 Xamarin 相比的优势,请阅读以下文章:使用 Avalonia 进行跨平台 UI 编码:简单示例。第一部分 - Avalonia 构建块。该文章还解释了如何安装 Avalonia Visual Studio 模板以及如何创建您的第一个 Avalonia 项目。
已经存在一个基于 Avalonia 的对接框架,但它存在以下不足之处
- 它完全基于视图模型,因此要创建最简单的对接基础设施,您需要编写大量 C# 代码。有些人觉得这违反直觉。
- AvaloniaDock 缺乏文档。
- AvaloniaDock 存在一些视觉问题。
- 无法将窗口拖出 - 相反,您必须使用“
浮动
”菜单选项。 - 此外,即使是对接窗口,也不会以视觉方式拖出 - 相反,您需要单击标题栏并在框架内拖动鼠标,直到出现罗盘,在此过程中没有任何视觉提示表明正在发生任何事情。
- 无法将窗口拖出 - 相反,您必须使用“
最重要的是,良性的竞争对产品和它们所构建的框架(在我们的例子中是 Avalonia 框架)总是有益的。
UniDock 框架不是任何 WPF 或其他框架产品的移植版,事实上,我很久以前就有了它的想法——主要目的是简化对接功能,使其易于使用和实现,同时确保软件可以在三大主要平台:Windows、Linux 和 MacOS 上运行。
为了简单起见,我只实现了我接触过的许多金融、健康、科学和军事软件客户请求的功能。因此,我放弃了一个从未有人向我请求过的功能——工具窗格和文档窗格之间的分割。如何使用 UniDock 实现类似效果将在未来的文章中进行解释。
我追求视觉上的完美(至少对于 Windows 平台——这是我的主要平台)。我也在 MacOS 和 Linux Ubuntu 20.04 上测试了该框架。它们都存在相同的问题(根据我的经验,用户可以很好地处理)——当您将标签页或窗格从对接的集群中拖出时,您必须再次单击浮动窗口才能进一步拖动它。
然而,在 Windows 10 上,这个问题不存在,视觉体验几乎是完美的。
UniDock 框架核心目前已构建完成并可供使用,尽管在不久的将来仍将添加许多增加其灵活性的功能。
即将添加的一项功能是能够固定(临时隐藏)窗格(目前固定功能不是该功能的一部分)。
请注意,此时我还没有时间测试 UniDock 在多屏幕上的运行情况,所以可能也会在那里发现一些问题。
UniDock 框架根据最宽松的 MIT 许可证(与 Avalonia 相同)发布。请在UniDock github 项目下使用它并提交改进请求和错误报告。
另外,请评论这篇文章,说明您想添加和改进的内容。
未来的 UniDock 文章将讨论高级功能(其中一些仍在开发中)和自定义。
UniDock 的代码位于UniDock 代码下。
我使用 .NET 5 代码和 Visual Studio 2019 构建了 UniDock。我不认为我使用了任何 .NET 5 特有功能,所以我认为代码可以很容易地降级到例如 .NET 3.1 及更早版本。
对接演示
本文演示的源代码位于 github 上的UniDock 演示。我使用 .NET 5 和 VS 2019 来构建和运行演示。
尝试运行演示项目 NP.Demos.UniDockWindowsSample
。您将看到以下内容
请注意,我选择的演示布局与示例 AvaloniaDock 应用程序相似,目的是展示 UniDock 框架下生成的演示代码的简洁性。
您可以通过移动浅灰色水平和垂直分隔符或拉出标签页来玩转演示。
例如,拉出“LeftTop 1”标签页。它将变成一个浮动窗口,同时标签页结构将被移除(因为它只剩下一个“LeftTop 2”标签页)并变成一个更简单的单一窗格(没有标签页)。
现在,您可以使用窗格的标题栏将“LeftTop 2”窗格拉出,创建另一个浮动窗口。
您还可以通过其标题栏将其拖到“LeftTop 1”窗口,该窗口将在其中间显示所谓的“罗盘”,让您可以选择如何将这两个窗口对接在一起。
选择罗盘的中间将创建一个类似主窗口中我们看到的标签页结构,而使用 4 个侧面之一可以将文档对接在一起,放在彼此旁边或之上。在这里,我们展示了将拖动的窗口放到罗盘底部部分的结果。
您可以使用中间的分隔符调整窗格大小,或者拉出窗格或使用 X 按钮移除窗格。让我们纵向扩展窗口,并将第二个窗格设置得比第一个小一些。
现在,让我们将另一个窗格从主窗口中拖出,并将其对接到底部窗格的右侧。
底部面板现在垂直拆分。
现在,让我们将另一个窗格从主窗口中拖出,并将其放到浮动窗口中“LeftTop 1”窗格罗盘的中间部分。
由于我们将新窗格放在中间,我们再次得到了顶部的标签页。
,
而且我们最后放置的文档将是左侧的标签页并被选中。
顺便说一句,标签页的顺序可以通过在标签行中拖动标签页并将其放到所需位置来轻松更改。
现在,按主窗口上的“序列化”按钮。布局将被序列化。
之后,重新启动应用程序并按“恢复”按钮。保存的布局将被恢复(包括主窗口在屏幕上的原始位置 - 您可能会观察到一个跳转)。
对接原则
涉及以下四种不同的对接类:
RootDockGroup
- 这是一个对接对象,用作窗口内的对接根(无论是预定义的窗口,例如主窗口还是浮动窗口)。它只能有一个对接子对象——任何其他类型的Docking
类。StackDockGroup
- 一个对接对象,根据其TheOrientation
属性,垂直或水平排列其对接子对象。我使用“The
”前缀来命名某些属性的原因是,我不喜欢属性的名称与类型名称相同(在我们的例子中,Orientation
也是一个枚举类型)。TheOrientation
属性一旦在开始时设置,就不应更改。其对接子对象可以是任何类型(除了RootDockGroup
- 如上所述,仅用于窗口根)。TabbedDockGroup
包含标签页 - 每个标签页代表一个DockItem
对象。DockItem
对象是表示已对接文档的叶子对象。它们不能包含任何对接子对象。
TabbedDockGroup
和 DockItem
对象可以在拖放过程中显示罗盘,并且它们的罗盘可以用于放置被拖动的窗口。
当您将浮动窗口拖放到罗盘的中间正方形时,可能会出现两种情况:
- 罗盘属于一个标签页组 - 在这种情况下,来自被拖动的浮动窗口的所有
DockItems
将被插入到该标签页组中,位于已有项目的前面,并且第一个新插入的项目将被选中。 - 如果罗盘属于一个不在标签页组中的
DockItem
,则将在该DockItem
的位置创建一个新的TabbedDockGroup
对象,该DockItem
将作为最后一个标签页插入其中,而之前的标签页将对应于被拖动的浮动窗口中的所有DockItems
。
现在我将描述当被拖动的浮动窗口被放到 TabbedDockGroup
或 DockItem
的罗盘的侧面正方形时会发生什么。在不失一般性的前提下,我们可以假设它被拖放到罗盘的右侧正方形。也有两种情况:
- 罗盘对象的父对象是水平方向的
StackDockGroup
。在这种情况下,我们只需将浮动窗口的顶级子对象(浮动窗口的根组的子对象)插入到被拖放对象(显示罗盘的对象)的下一个项目(右侧)即可。 - 如果不是(如果父对象是
RootDockGroup
或垂直方向的StackDockGroup
对象),我们将创建一个新的水平方向的StackDockGroup
对象,将其插入到拖放的对象(显示罗盘的对象)的位置,将拖放的对象从其之前的父对象中移除并插入到新创建的组中,然后将浮动窗口的顶级子对象插入到这个新创建的组中,放在之前的对象之后(这样它就在它的右侧)。
如果我们通过拖出或按 X 按钮从组中删除一个 DockItem
,也可能会进行一些重组。
- 如果其父组没有剩余的子对象(并且其
AutoDestroy
属性未设置为false
),则该组将从其父对象中删除。 - 如果其父组只剩下一个子对象(并且其
AutoDestroy
属性未设置为false
),则父组将从其父对象中删除,其以前剩余的单个子对象将被插入到其父对象中,代替该组。
上述移除操作的目的是尽可能简化对接结构,这些操作将自动传播到组的根。
现在我们描述了构建对接树的原则,我们可以描述演示的默认布局。
有一个 RootDockGroup
作为根对接组。它的子对象是水平方向的 StackDockGroup
,包含演示的三个主列。它有三个子对象——每个子对象对应三列之一。
其最左边的子对象是垂直的 StackDockGroup
,包含两个 TabbedDockGroup
,每个 TabbedDockGroup
包含两个 DockItem
对象。
其中间的子对象是一个 TabbedDockGroup
,包含两个 DockItem
对象——“文档 1”和“文档 2”。
其最右边的子对象是垂直的 StackDockGroup
,包含两个水平的 StackDockGroup
对象,每个对象包含两个 DockItem
对象。
演示代码说明
演示代码全部位于 github.com 上的NP.Demos.UniDockIntroductionDemo 项目下。
请注意,除了对 Avalonia 0.10.7 包的依赖外,还需要对 NP.Avalonia.UniDock
包的依赖。
只有三个文件被修改
- MainWindow.axaml - 包含大部分代码 - 对接树
- App.axaml 包含样式引用
- MainWindow.axaml.cs 包含一些用于调用布局保存/恢复功能的代码
您可以将其与相似复杂度的示例 AvaloniaDock 应用程序进行比较,该应用程序有许多不同的源文件代表不同的视图模型。
演示特有的代码大部分是 MainWindow.axaml 文件中的 XAML 代码。
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:np="https://np.com/visuals"
Width="700"
Height="700"
np:DockAttachedProperties.TheDockManager="{DynamicResource TheDockManager}"
np:DockAttachedProperties.DockChildWindowOwner=
"{Binding RelativeSource={RelativeSource Mode=Self}}"
np:DockAttachedProperties.WindowId="TheMainWindow"
x:Class="NP.Demos.UniDockWindowsSample.MainWindow"
Title="NP.Demos.UniDockWindowsSample">
<Window.Resources>
<ResourceDictionary>
<np:DockManager x:Key="TheDockManager"/>
</ResourceDictionary>
</Window.Resources>
<Grid RowDefinitions="*, Auto, Auto">
<np:RootDockGroup DockId="RootGroup"
np:DockAttachedProperties.TheDockManager=
"{StaticResource TheDockManager}">
<np:StackDockGroup TheOrientation="Horizontal">
<np:StackDockGroup TheOrientation="Vertical">
<np:TabbedDockGroup TabStripPlacement="Bottom">
<np:DockItem Header="LeftTop 1"
DockId="LeftTop1">
<TextBlock Text="Left Top 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
<np:DockItem Header="LeftTop 2"
DockId="LeftTop2">
<TextBlock Text="Left Top 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
</np:TabbedDockGroup>
<np:TabbedDockGroup TabStripPlacement="Bottom">
<np:DockItem Header="LeftBottom 1"
DockId="LeftBottom1">
<TextBlock Text="Left Bottom 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
<np:DockItem Header="LeftBottom 2"
DockId="LeftBottom2">
<TextBlock Text="Left Bottom 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
</np:TabbedDockGroup>
</np:StackDockGroup>
<np:TabbedDockGroup>
<np:DockItem Header="Document 1"
DockId="Document1">
<Grid Background="Gray">
<TextBlock Text="Document 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</np:DockItem>
<np:DockItem Header="Document 2"
DockId="Document2">
<Grid Background="Gray">
<TextBlock Text="Document 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</np:DockItem>
</np:TabbedDockGroup>
<np:StackDockGroup TheOrientation="Vertical">
<np:StackDockGroup TheOrientation="Horizontal">
<np:DockItem Header="RightTop 1"
DockId="RightTop1">
<TextBlock Text="Right Top 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
<np:DockItem Header="RightTop 2"
DockId="RightTop2">
<TextBlock Text="Right Top 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
</np:StackDockGroup>
<np:StackDockGroup TheOrientation="Horizontal">
<np:DockItem Header="RightBottom 1"
DockId="RightBottom1">
<TextBlock Text="Right Bottom 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
<np:DockItem Header="RightBottom 2"
DockId="RightBottom2">
<TextBlock Text="Right Bottom 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
</np:StackDockGroup>
</np:StackDockGroup>
</np:StackDockGroup>
</np:RootDockGroup>
<Grid x:Name="Separator"
Grid.Row="1"
Height="3"
Background="LightGray"/>
<StackPanel Grid.Row="2" Orientation="Horizontal"
HorizontalAlignment="Right">
<Button Width="100"
Height="50"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="Serialize"
np:CallAction.TheEvent="{x:Static Button.ClickEvent}"
np:CallAction.TargetObject="{Binding RelativeSource=
{RelativeSource AncestorType=Window}}"
np:CallAction.MethodName="Serialize"
Margin="10,20"/>
<Button Width="100"
Height="50"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="Restore"
np:CallAction.TheEvent="{x:Static Button.ClickEvent}"
np:CallAction.TargetObject="{Binding RelativeSource=
{RelativeSource AncestorType=Window}}"
np:CallAction.MethodName="Restore"
Margin="10,20"/>
</StackPanel>
</Grid>
</Window>
请注意,我们为序列化定义了 DockId
(它将被映射到 WindowId
)。另外,我们仅在根组级别定义了 np:DockAttachedProperties.TheDockManager
属性(它应该始终是 RootDockGroup
对象)。其余的 DockGroups
和 DockItems
将自动获取 DockManager
。这样做是因为将来我想扩展功能以处理多个 DockManager
(目前只允许一个)。
请注意,唯一的 DockId
属性仅在根 RootDockGroup
和叶子(DockItem
)对象上定义。这是因为组不需要以唯一的方式保存——它们是灵活的,我们只需要知道组类型就可以显示它。
演示的保存/恢复代码位于 MainWindow.axaml.cs 文件中,由两个简单的方法 Save()
和 Restore()
组成。
const string SerializationFilePath = "../../../SerializationResult.xml";
public void Save()
{
DockManager dockManager = DockAttachedProperties.GetTheDockManager(this);
dockManager.SaveToFile(SerializationFilePath);
}
public void Restore()
{
DockManager dockManager = DockAttachedProperties.GetTheDockManager(this);
dockManager.RestoreFromFile(SerializationFilePath);
}
用户单击相应按钮时会调用上述每个方法。
关于保存/恢复的说明:此介绍性演示仅显示如何恢复窗口的位置和 UI 中存在的项目的对接。如果您通过单击 X 按钮删除了一个项目,然后尝试恢复它,则项目的内容将不会被正确恢复。此类情况的处理将在后续文章中描述。
文件 App.axaml 包含对接应用程序中使用的所有样式的 StyleInclude
标签。
<Application.Styles>
<StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/>
<StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/TextStyles.axaml"/>
<StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/CustomWindowStyles.axaml"/>
<StyleInclude Source="avares://NP.Avalonia.UniDock/Themes/DockStyles.axaml"/>
</Application.Styles>
结论
UniDock 是一个新推出的、简单、快速开发的、基于 Avalonia 的多平台框架,前景广阔。我的项目需要它,并计划为其添加许多令人兴奋的新功能,同时致力于其维护和文档。
我根据最宽松的MIT 许可证发布此框架。请将其用于您的跨平台项目,并告诉我改进的方法和需要修复的错误。
另外,如果您能发表几行关于您对本文的看法以及可以改进的地方,我将不胜感激。
历史
- 2021 年 8 月 29 日:初始版本