Slate 是虚幻引擎的用户界面框架,作为开发编辑器工具和游戏界面的模块。然而在实际的开发中,开发者更愿意使用 UMG 来设计界面,在相同的需求中还实现了所见即所得的实时预览效果,而编写 Slate 相关的代码还要经过编译(一般至少 5 秒)才能看到最终效果(热编译有可能无法正确显示),这会在复杂界面或者涉及到对齐组件(涉及 margin 或 padding)时尤为痛苦。所以从个人的角度来看,Slate 代码的编写需求会缩小到一个较窄的领域内,那就是需要在编辑器某个位置(称之为 Extension Point)插入界面类组件时才使用 Slate。如果不是这个需求的话,使用编辑器提供的 Editor Utility Blueprint 或者 Editor Utility Widget 会使开发工具的工作更轻松一些,毕竟编辑器有关的 UEditorAssetLibraryUEditorUtilityLibrary 库可被蓝图调用。所以接下来本博文会涉及以下话题:

  • Extension Point 是什么及作用;
  • Slate 的基本语法以及如何找到所有窗口组件的使用方法;
  • 如何将设计好的 Slate 界面显示出来;
  • 如何向工具的用户弹出提示框。

寻找 Extension Point 并加入添加自定义的入口

Extension Point 相当于一个位置,我们找到这样一个位置后就可以在它的前面或者后面插入一个新的项目。在 Editor Performance 中勾选 Display UI Extension Points 后重启编辑器就可以看到所有的 Extension Point:

如上图所示,我们被允许在任何绿色字体的附近创建自己的组件,需要注意的是 Extension Point 并不与菜单项的名字完全一致。在虚幻引擎中,菜单、工具栏等组件被称之为 Extender 的数组,里面存储着一系列的委托。在编辑器用户进行指定的行为时(例如选择资产后按下鼠标右键将弹出菜单),就会遍历特定的 Extender 数组然后调用委托绑定的函数,而被绑定的函数可以检查被选中的资产是否满足特别的条件,从而决定要不要生成菜单项(称为 Entry,下称菜单入口),以及进一步设置菜单项的内容。以下的代码演示如何在内容浏览器中的右键菜单中在删除菜单项的附近加入新的菜单项:

// 第一步,找到模块并在 Extenders 中加入自己的委托。
void FBotaEditorModule::InitTestMenuExtension()
{
	auto& ContentBrowserModule = FModuleManager::LoadModuleChecked<FContentBrowserModule>("ContentBrowser");
	auto& MenuExtender = ContentBrowserModule.GetAllPathViewContextMenuExtenders();
	MenuExtender.Add(FContentBrowserMenuExtender_SelectedPaths::CreateRaw(this, &FBotaEditorModule::TestMenuExtender));
}

// 第二步,检查是否应该生成自己的菜单项,这里检查的是是否有被选择的文件,没有则返回空的对象。
TSharedRef<FExtender> FBotaEditorModule::TestMenuExtender(const TArray<FString>& SelectedPaths)
{
	auto MenuExtender = TSharedRef<FExtender>(new FExtender());
	if (SelectedPaths.Num() > 0)
	{
		MenuExtender->AddMenuExtension(
			"Delete", 
			EExtensionHook::After, 
			TSharedPtr<FUICommandList>(), 
			FMenuExtensionDelegate::CreateRaw(this, &FBotaEditorModule::OnMenuExtension)
		);
	}
	return MenuExtender;
}
// 第三步,创建菜单入口,定义名字、描述、图标、按下时的回调函数。
void FBotaEditorModule::OnMenuExtension(FMenuBuilder& MenuBuilder)
{
	MenuBuilder.AddMenuEntry(
		FText::FromString(TEXT("Test")),
		FText::FromString(TEXT("Hello World")),
		FSlateIcon(),
		FExecuteAction::CreateRaw(this, &FBotaEditorModule::OnTestMenuEntryClicked)
	);
}
// 第四步,编写按下后要执行的逻辑。
void FBotaEditorModule::OnTestMenuEntryClicked()
{
	UE_LOG(LogTemp, Warning, TEXT("Hello World!"));
}

上述代码编译允许后就得到了一个位于 Delete 后面的菜单入口 Test,按下后会在控制台输出 Hello World!

创建界面并尝试添加几个组件

现在希望在 Test 菜单入口按下后生成一个窗口,并使用 Slate 声明性语法来创建一个界面。Slate 算是虚幻引擎提供一种语法较为“怪异”的机制了,但与往常一样,我们需要从定义一个 Slate 类开始,Slate 的类名要求 S 作为前缀,通常派生自 SCompoundWidget,以下是常见基础模板样例:

class SBotaTestTab : public SCompoundWidget
{
	SLATE_BEGIN_ARGS(SBotaTestTab) {};

	SLATE_ARGUMENT(FString, MyTestString);

	SLATE_END_ARGS()

public:
	void Construct(const FArguments& InArgs);
};

SLATE_BEGIN_ARGS(类名) {};SLATE_END_ARGS() 之间可以通过 SLATE_ARGUMENT(类型, 变量名); 定义若干个形参。在上面的代码例子中,我们可以在实例化这个窗口组件时,以 .MyTestString(值) 形式初始化它,然后在 Construst 函数中用 inArgs._MyTestString 来获得值。而组件的创建则涉及到两条宏语句 SNew()SAssignNew() ,如果有 UMG 开发经验的话,前者可以理解为仅仅是创建了一个窗口组件;而后者则相当于勾选了 Is Variable 选项,函数的第二个参数就作为到绑定新窗口组件的智能指针了,类型为 TSharedRef<类型>。先来看看如何使用 Slate 语法创建的一个 Vertical Box 内含一个 TextBlock 和 Button:

void SBotaTestTab::Construct(const FArguments& InArgs)
{
	bCanSupportFocus = true;

	ChildSlot
		[
			SNew(SVerticalBox)

				+SVerticalBox::Slot()
				.AutoHeight()
				[
					SNew(STextBlock)
						.Text(FText::FromString(InArgs._MyTestString))
						.Font(TitleTextFont)
						.Margin(FMargin(55.f, 0.f))
				]

				+SVerticalBox::Slot()
				.AutoHeight()
				.HAlign(EHorizontalAlignment::HAlign_Center)
				[
					SNew(SButton)
						.OnClicked(FOnClicked::CreateRaw(this, &SBotaTestTab::OnTestButtonClicked))
						[
							SNew(STextBlock)
								.Text(FText::FromString(TEXT("This is a button")))
								.Font(TitleTextFont)
								.Justification(ETextJustify::Center)
						]
				]
		];
}

可以在上面的代码中看到,SCompoundWidget 中有一个 ChildSlot,该组件为默认的根窗口组件。紧随着一个方括号括起来的区域,这代表着方括号前面组件的子项(Slot)。在使用 SNew 创建一个窗口组件后就可以使用一系列 .属性名(属性值) 来配置新窗口组件的形式,例如对于 TextBlock 组件可以设置文本内容、字体、范围等参数,等同于用鼠标去点击组件细节面板中的参数。而对于 VerticalBox 这样允许多个子项的窗口组件,我们需要在每个新子项前通过 +SVerticalBox::Slot() 来显式地声明新子项的存在,而像 ChildSlot 一样子项的区域要用方括号括起来。

上面的语法结构的确是不常见的写法,虚幻引擎将这种语法称之为声明性语言。大概是想说在描述添加窗口组件时并没有任何额外的语句,例如条件语句。使用 + 和 . 运算符来指示窗口的属性。如果硬要指出这种写法带来的收益,可能会有两点:一个是 TextBlock 都使用了相同的字体参数,而不像 UMG 那样每个 TextBlock 都是单独设置的,但这个问题已经在 CommonUI 插件中得到了改善;另一个是当涉及到绑定回调函数时可以额外传递一些参数,这是在 UMG 中无法直接做到的。请看下面的代码展示:

// TitleTextFont 可以被有 Font 属性的窗口使用,如果需要修改则可以统一地修改。
auto TitleTextFont = FCoreStyle::Get().GetFontStyle("EmbossedText");
TitleTextFont.Size = 30.f;

// 在按钮按下的回调函数中,额外传递了字符串,这在 UMG 中无法直接做到回调函数中增加额外信息。
void SBotaTestTab::Construct(const FArguments& InArgs)
{
	// 与之前一样 ...
	SNew(SButton)
		.OnClicked(FOnClicked::CreateRaw(this, &SBotaTestTab::OnTestButtonClicked, InArgs._MyTestString))
		[
			SNew(STextBlock)
				.Text(FText::FromString(TEXT("This is a button")))
				.Font(TitleTextFont)
				.Justification(ETextJustify::Center)
		]
	// 与之前一样 ...
}

FReply SBotaTestTab::OnTestButtonClicked(const FString InString)
{
	UE_LOG(LogTemp, Warning, TEXT("%s"), *InString);
	return FReply::Handled();
}

在按钮的回调函数中可以看到返回类型为 FReply,这在 UMG 中覆盖一些函数时也会出现要求返回一个这种类型的值。无论在哪里,都应该是 FReply::Handled()FReply::Unhandled() 中的一个,表示是否已处理该回调事件。

现在需要在编辑器中展示这个窗口了,在模块的 StartupModule 函数中,找到全局面板管理器并在那里注册一个新的面板,然后在回调函数中实例化一个我们的窗口组件即可,而在模块退出时及时清理资源。以下是代码演示:

// 类内定义了 const inline static FName Name_TestTab = "TestTab";
void FBotaEditorModule::StartupModule()
{
	FGlobalTabmanager::Get()->RegisterNomadTabSpawner(
		Name_TestTab, 
		FOnSpawnTab::CreateRaw(this, &FBotaEditorModule::OnSpawnTestTab)
	).SetDisplayName(FText::FromString(TEXT("TestTab")));
}
TSharedRef<SDockTab> FBotaEditorModule::OnSpawnAdvanceDeletionTab(const FSpawnTabArgs& TabArgs)
{
	return SNew(SDockTab).TabRole(ETabRole::NomadTab)
		[
			SNew(SBotaTestTab)
			.MyTestString(TEXT("Init test string from code"))
		];
}
void FBotaEditorModule::ShutdownModule()
{
	FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(Name_TestTab);
}

最终显示的效果如下:

提示框与提示条

虚幻引擎有两种提示对话框,一种出现在屏幕中央且只有按下了选择后才能进行其他操作,另一种是出现在右下角的提示条(类似安卓开发中的 Toast),最常见的就是自动保存提示条弹出来。以下的代码展示如何使用它们来显示信息:

// 独占式提示框,EAppMsgType 代表按键类型,第二个为要提示的文本内容,返回类型 EAppReturnType 代表用户选择结果。
FMessageDialog::Open(EAppMsgType::Ok, FText::FromString(TEXT("Test Dialog")));

// 提示条,可以设置文本内容、渐出事件等等参数,最终向 NotificationManager 添加即可展示。
FNotificationInfo NotifyInfo(FText::FromString(TEXT("Test NotificationInfo")));
NotifyInfo.FadeOutDuration = 3.f;
NotifyInfo.bUseLargeFont = true;
FSlateNotificationManager::Get().AddNotification(NotifyInfo);