多窗口悬浮合并系列

这个系列绝对是大家最喜欢的系列,因为这个功能非常常见,像是IDE的多窗口悬浮合并,页签拖出来,形成一个新的窗口,这个是编辑器和一些桌面生产软件的核心功能,但是一般的软件都依赖三方UI库,如果是自绘的话,会很困难,网上对它的资料几乎没有,这篇文章会详细介绍多窗口页签的悬浮合并。

用法

unreal里面的每个模块,都使用了unreal封装的多窗口悬浮合并功能,只要这个模块需要可视化编辑。

每个模块基本上会在StartupModule里面进行编辑器的初始化,我们来看看关卡编辑器的初始化。

FGlobalTabmanager::Get()->RegisterTabSpawner("LevelEditor", FOnSpawnTab::CreateRaw(this, &FLevelEditorModule::SpawnLevelEditor))
.SetDisplayName(NSLOCTEXT("LevelEditor", "LevelEditorTab", "Level Editor"))
.SetAutoGenerateMenuEntry(false);

这是在LevelEditor(关卡编辑器)的StartupModule里面初始化的,也就是绑定一个页签和生成LevelEditor的回调,这个回调是FLevelEditorModule::SpawnLevelEditor,当我们点击页签(tab)的时候,会显示相应的编辑器,然后我们的页签LevelEditor可以设置相应的显示名字,还可以设置Icon等功能,FGlobalTabManager提供了很多接口让我们用。

我们看一下FOnSpwanTab这个回调,每个带编辑器的模块都用了这个。

DECLARE_DELEGATE_RetVal_OneParam(TSharedRef<SDockTab>, FOnSpawnTab, const FSpawnTabArgs&);
//定义了一个委托叫做FOnSpawnTab,参数是FSpawnTabArgs&,返回值是TSharedRef<SDockTab>

SDockTab就是小页签,我们看一下FLevelEditorModule::SpawnLevelEditor这个函数:

TSharedRef<SDockTab> FLevelEditorModule::SpawnLevelEditor(const FSpawnTabArgs& InArgs)
{
	//创建一个页签
	TSharedRef<SDockTab> LevelEditorTab = SNew(SDockTab)
	.TabRole(ETabRole::MajorTab)
	.ContentPadding(FMargin(0))
	.IconColor(FAppStyle::Get().GetColor("LevelEditor.AssetColor")); //提供一个小画刷作为Icon的颜色
	
	LevelEditorTab->SetTabIcon(FAppStyle::Get().GetBrush("LevelEditor.Tab"));//提供一个小画刷作为Icon
	
    //省略一堆噪音,留下精华,这里创建了了关卡编辑器
    TSharedPtr<SLevelEditor> LevelEditorTmp;//创建一个SLevelEditor
	LevelEditorTab->SetContent(SAssignNew(LevelEditorTmp, SLevelEditor));//在页签SDockTab的数据成员里面保存SLevelEditor
	SetLevelEditorInstance(LevelEditorTmp);
	LevelEditorTmp->Initialize(LevelEditorTab, OwnerWindow.ToSharedRef());
}

SLevelEditor就是Unreal的各种编辑场景数据的地方,如果有项目需要自定义,可以修改这个类。

SDockTab的SetContent不会把SLevelEditor这个关卡编辑器挂在自己的控件槽下,每个SDockTab只会保存相应的控件到它们的数据成员里面,挂到控件槽下,就意味着渲染遍历控件树的每个控件,并进行渲染。这意味着,当我们点击每个SDockTab,我们就要修改控件树,把点击到的SDockTab保存的SLevelEditor挂到控件树下,这样就能渲染了,我们看看SDockTab的OnMouseButtonDown函数:

FReply SDockTab::OnMouseButtonDown( const FGeometry& MyGeometry, const FPointerEvent& MouseEvent )
{
	//我只保留了这个,这个就是非常重要的函数,当点击SDockTab的时候,切换子树的挂载
	ActivateInParent(ETabActivationCause::UserClickedOnTab);
}

void SDockTab::ActivateInParent(ETabActivationCause InActivationCause)
{
	TSharedPtr<SDockingTabWell> ParentTabWell = ParentPtr.Pin();//我们获取管理SDockTab的SDockingTabWell
	if (ParentTabWell.IsValid())
	{
		ParentTabWell->BringTabToFront(SharedThis(this));//挂载这个SDockTab保存的Content
	}
	//当激活页签的时候,会触发页签激活的回调,我们可以绑定一个
	OnTabActivated.ExecuteIfBound(SharedThis(this), InActivationCause);
}

SDockingTabWell是什么呢?SDockingTabWell管理和控制页签的关闭和打开,还有布局页签,进行绘制。

../_images/Dock1.png

这是我的独立应用(unreal可以写独立应用,比如配置导出工具)如何使用页签布局的地方,Unreal不直接使用SDockingTabWell、SDockTab、SDockingSplitter等能渲染和操控实际页签逻辑的控件,因为slate不能序列化和保存布局,所以unreal创建了一些类,这些类能保存布局进行序列化,下次打开编辑器的时候,再还原出来,它们一一对应,比如FTabManager::FLayout,FTabManager::NewArea创建出来的SDockingArea,FTabManager::NewStack创建出来的SDockingTabStack,相当于一张草图,然后我们把这个草图喂到RestoreFrom这个函数里面,RestoreFrom就会创建各式各样的控件,把这个复杂的过程省略了。

../_images/Dock2.png

FLayoutNode是FStack、FArea、FSplitter的基类,FLayout是容纳它们的容器,相当于一张可以序列化的草图,还有FTab,FTab就是代表SDockTab,FArea代表窗口,FStack是FTab的数组,FSplitter是分割线,它们有相应的插入、删除FTab的接口,我们可以任意增加页签。

AddArea(FTabManager::NewArea(800,600))//创建一个窗口,800x600像素宽度的FArea

//FArea有Splitter接口,Split,添加一个分割线,可以拉动的
Split(
	FTabManager::NewStack(
		->AddTab(FName("配置生成",ETabState::OpenedTab))//添加一个FTab
		->SetForegroundTab("配置生成")//设置这个Tab是第一个显示的
	)
);

FStack可以任意AddTab,添加一堆FTab,FStack是FTab的数组。

RegisterNoamedTabSpanwer,我们可以绑定一个FTab和相应的回调函数,这个回调函数就是刚刚讲的,生成在点击这个页签下生成的显示内容。

FGlobalManager管理者一个Map,这个Map保存着FTab和绑定着回调的委托,到时候,它RestoreFrom遍历FLayout这个草图的时候,就会查询相应的Map,然后创建它们对应的控件。

整个程序左上角的Unreal Icon,感兴趣的可以换掉,换成自己的。

../_images/Dock4.png

这个的替换在SAppIconWidget,它会调用FSlateApplicationBase::Get().GetAppIcon()获取这个画刷,然后贴上去。

../_images/dock5.png

这个文件的路径在这,我们可以替换掉,换成我们的图标。

../_images/dock6.png

../_images/dock3.png

DockTabStack分为两部分,黄色的1和2,由DockTabWell和Content(SDock要显示的内容)两部分组成,DockTabWell显示DockTab数组,当我们点击DockTabWell里面的DockTab的时候,DockTabStack会切换黄色的2部分的内容,挂载到它的子控件下。

SDockTab在它的数据成员里面保存了内容,但是它没有挂到自己的子控件下,而是挂到整体外层的绿色DockTabStack子控件下。

那DockArea是啥,DockArea比较特殊,它继承自DockSplitter,DockSplitter拥有分割线,可以放置很多很多DockTabStack,DockArea是拥有窗口的DockSplitter。

DockArea拥有一个窗口,当我们的GlobalManager创建它的时候,会调用SetParentWindow,给予它一个SWindow,并把DockArea挂在它的子控件下,然后

调用FSlateApplication::Get().MakeWindowTitleBar()创建一个带有关闭按钮,缩放按钮、UELog的标题栏,我们就能改成我们的商标了。

补充

我们来看看内容浏览器的创建吧,怎么和dock结合起来的。

首先,每个编辑器模块在StartupModule里面创建的:

void FContentBrowserModule::StartupModule()
{
	IContentBrowserDataModule::Get();

	//创建一个内容浏览器单例
	ContentBrowserSingleton = new FContentBrowserSingleton();
}

内容浏览器单例它有个函数,SpawnContentBrowserTab,然后创建一个SBox(盒子控件),再创建一个SContentBrowser,然后把它放进SBox,然后创建一个SDockTab,把SContentBrowser放进SDockTab的数据成员Content下,调用SetContent,到时候点击的时候,把它显示出来。

各种编辑资产的活都在SContentBrowser下,说到这,你应该明白了什么,也就是之前提到的SLevelEditor它是调用UE的渲染模块渲染完一张场景,然后贴到一个控件上,就形成了视口,SLevelEditor持有SLevelViewport,SLevelViewport持有一个SViewport,这个SViewport调用OnPaint的时候,会调用MakeViewport,去创建一个Image控件,然后把之前的场景的着色器资源视图shader resource view绑定到这个控件上进行绘制,就享有了它的位置布局信息。

我们可以改动SContentBrowser,检测放进去的资产的大小之类的。

GameViewportClient就是协调渲染模块和Slate的,GameViewportClient的Draw函数,里面会调用渲染模块去渲染场景,然后贴到SViewport这个控件上。

持续更新…