Slate 是一个跨平台的 UI 框架,它完全由 C 实现,UE 中的工具以及引擎编辑器本身都是用它实现的。它不依赖 Editor、Engine 模块,因此可以用来写一些独立的不依赖引擎的应用,不过大多数情况下我们主要还是用它开发 UE 的工具。Slate UI 框架虽然强大,但使用起来不太直观,这篇文章将解析 Slate UI 的使用方法以及其中的一些实现。
Hello World #
首先,在工程中新建一个 SExampleWidget.h
文件,内容如下:
class SExampleWidget : public SCompoundWidget {
public:
SLATE_BEGIN_ARGS(SExampleWidget) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
};
再新建一个 SExampleWidget.cpp
文件,内容如下:
#include "SExampleWidget.h"
#include "SlateBasics.h"
BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION
void SExampleWidget::Construct(const FArguments& InArgs) {
ChildSlot
[
SNew(STextBlock)
.Text(NSLOCTEXT("UIDemo", "HelloWorld", "Hello World!"))
];
}
END_SLATE_FUNCTION_BUILD_OPTIMIZATION
这里使用 BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION
和 END_SLATE_FUNCTION_BUILD_OPTIMIZATION
包裹 Construct
函数是为了加速编译,因为 Slate 框架的宏可能会生成较为复杂的代码,导致编译器花费大量时间来尝试对它进行优化。通过这两个宏来标记一个禁用这些优化的范围。
创建完这个控件后,我们新建一个 HUD Actor ADemoHUD
,并在其中声明一个对 SExampleWidget
控件指针,注意这里使用 TSharedPtr
对其进行管理,Slate 框架并不依赖于 UObject
:
UCLASS()
class ADemoHUD : public AHUD {
GENERATED_BODY()
public:
ADemoHUD(const FObjectInitializer& ObjectInitializer);
protected:
virtual void BeginPlay() override;
TSharedPtr<class SExampleWidget> MyWidget;
};
我们在 BeginPlay
的时候使用 SNew
创建对象,并通过 UGameViewportClient
将其添加到 viewport:
ABUIHUD::ABUIHUD(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer) {}
void ABUIHUD::BeginPlay() {
Super::BeginPlay();
MyWidget = SNew(SExampleWidget);
UGameViewportClient* ViewportClient = GetWorld()->GetGameViewport();
ViewportClient->AddViewportWidgetContent(MyWidget.ToSharedRef());
}
之后,在 game mode 中设置使用该 HUD 类型,运行游戏,即可看到屏幕左上角显示「Hello World!」了。
声明式语法 #
在定义了 widget 类型后,我们需要填充里面 UI 展示的内容。Slate 框架通过宏和运算符重载设计了一套声明式的 UI 描述方法,可以较为方便地描述静态结构的 UI。先通过 SNew
声明新建一个类型的控件,然后通过 .ArgName
来配置参数或者是绑定相关事件,然后再通过中括号填入该控件包含的子控件,例如:
SNew(SBox) // 创建控件
.HAlign(HAlign_Center) // 设置参数
.VAlign(HAlign_Center) // 设置参数
[ // 子控件
SNew(STextBlock)
.Text(LOCTEXT(...))
]
对于复合控件,使用 Xxx::Slot()
的形式添加 slot,容器控件一般会使用这种方式来声明被其管理的子控件,例如:
SNew(SHorizontalBox) // 创建控件
.AutoWidth() // 设置参数
.Padding(5) // 设置参数
SHorizontalBox::Slot() // 新增 slot
[ // 子控件
SNew(SImage)
]
SHorizontalBox::Slot() [ ... ]
这里的 slot 是 Slate 框架中的一个概念,如果一个控件能包含子控件,那么这个控件就会提供一个对应的 slot 类型,用来存放其包含的子控件,比如上面的 SHorizontalBox::Slot
间接继承自 TSlotBase
,其中重载了 []
运算符,其实现为:
template<typename SlotType>
class TSlotBase: public FSlotBase {
// ...
SlotType& operator[](const TSharedRef<SWidget>& InChildWidget) {
this->AttachWidget(InChildWidget);
return static_cast<SlotType&>(*this);
}
};
这就是我们能用 [SNew(SImage)]
这样的语法为其添加子控件的原因。
至于用
符号来添加 slot,是基于 SLATE_SUPPORTS_SLOT
宏实现的,这个宏用于 Slate 控件的参数声明中:
class SLATECORE_API SHorizontalBox : public SBoxPanel {
public:
class FSlot : public SBoxPanel::FSlot {};
static FSlot& Slot() { return *(new FSlot()); }
SLATE_BEGIN_ARGS(SHorizontalBox) { /* ... */ }
SLATE_SUPPORTS_SLOT(SHorizontalBox::FSlot)
SLATE_END_ARGS()
// ...
};
SLATE_SUPPORTS_SLOT(SHorizontalBox::FSlot)
重载了 SHorizontalBox
与 SHorizontalBox::FSlot
之间的
运算符。宏展开后大致为:
TArray<SHorizontalBox::FSlot*> Slots;
WidgetArgsType& operator (SHorizontalBox::FSlot& SlotToAdd) {
Slots.Add(&SlotToAdd);
return *this;
}
我们前面实现的 SExampleWidget
继承自 SCompoudWidget
,这是 Slate 框架内置的基础控件类型之一。Slate 框架中最基础的类是 SWidget
,基于 SWidget
的子类主要有三种,分别是 SLeafWidget
SPanel
SCompoudWidget
,我们主要基于这三个类来构建我们的控件。他们三个最主要的区别在于附加子控件的能力:
父类型 | 子控件 | 例子 |
---|---|---|
SLeafWidget | 无 | SImage, STextBlock |
SPanel | 动态数量 | SOverlay, SHorizontalBox |
SCompoundWidget | 显式命名、静态数量 | SBorder, SButton |
控件参数 #
控件构造使用函数 void Construct(const FArguments& InArgs)
,输入参数 FArguments
的类型通过 SLATE_BEGIN_ARGS
SLATE_END_ARGS
声明。在参数声明区域中,可以声明不同的内容,包括属性 SLATE_ATTRIBUTE
、事件 SLATE_EVENT
、参数 SLATE_ARGUMENT
、插槽 SLATE_NAMED_SLOT
和 SLATE_DEFAULT_SLOT
等。
例如:
代码语言:javascript复制class SExampleWidget : public SCompoundWidget {
public:
SLATE_BEGIN_ARGS(SExampleWidget) {}
SLATE_ARGUMENT(FText, Text)
SLATE_END_ARGS()
// ...
};
此时,我们可以在 Construct
中访问:
void SExampleWidget::Construct(const FArguments& InArgs) {
ChildSlot
[
SNew(STextBlock)
.Text(InArgs._Text)
];
}
SLATE_ARGUMENT
声明出来的参数会在变量名前面自动加上一个下划线,Text
变为了 _Text
,因此我们获取这个参数时使用的是 InArgs._Text
。而对于使用侧,则是直接通过对应的名称进行参数值设置,如此处的 Text
:
MyWidget = SNew(SExampleWidget).Text(NSLOCTEXT(...));
展开参数声明这几个宏,我们就能更清楚地看到这些变量和函数是如何被声明的了:
代码语言:javascript复制/// start SLATE_BEGIN_ARGS(SExampleWidget)
struct FArguments : public TSlateBaseNamedArgs<SExampleWidget>
{
FArguments()
/// end SLATE_BEGIN_ARGS
{} // <-- 这个需要自己写
/// start SLATE_ARGUMENT(FText, Text)
FText _Text;
FText& Text(FText InArgs)
{
_Text = InArg;
return static_cast<WidgetArgsTepe*>(this)->Me();
}
/// end SLATE_ARGUMENT
/// start SLATE_END_ARGS()
};
/// end SLATE_END_ARGS
注意这个参数声明并不是声明在这个控件内,而是生成在嵌套类 FArguments
中,而在使用 SNew
宏声明布局时会获取到这个对象:
#define SNew( WidgetType, ... )
MakeTDecl<WidgetType>( #WidgetType, __FILE__, __LINE__, RequiredArgs::MakeRequiredArgs(__VA_ARGS__) ) <<= TYPENAME_OUTSIDE_TEMPLATE WidgetType::FArguments()
另外我们可以看到 SLATE_BEGIN_ARGS
其实添加了一个未实现的构造函数,了解了这一点后,我们就很容易理解为什么需要在 SLATE_BEGIN_ARGS(SExampleWidget)
加一对花括号 {}
了。显然,我们也可以像一般的构造函数一样在此处设置参数的默认值,例如:
SLATE_BEGIN_ARGS(NewWidget) : _Focusable(true) {}
SLATE_ARGUMENT(bool, Focusable)
SLATE_END_ARGS()
命令式语法 #
有时候声明式语法不足以描述所需控件,例如实现一个包含若干按钮的列表,此时就需要使用一般的命令式语法来添加子控件。
Slate 框架除了 SNew
之外还提供了一个 SAssignNew
宏用于在新建控件的同时获取其引用。例如这里在 SExampleListWidget
中声明了 TSharedPtr<SVerticalBox>
类型的成员变量,同时按照前面提到的方法,增加一个 ButtonCount
参数用于设定按钮的数量:
class SExampleListWidget : public SCompoundWidget {
public:
SLATE_BEGIN_ARGS(SExampleListWidget) {}
SLATE_ARGUMENT(int32, ButtonCount)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
void RebuildFromData();
void SetButtonCount(int32 ButtonCount);
protected:
TSharedPtr<SVerticalBox> ListBox;
int32 ButtonCount = 0;
};
在 Construct
函数的实现中,新建一个 SVerticalBox
并获取其引用:
void SExampleListWidget::Construct(const FArguments& InArgs) {
// 记录参数
ButtonCount = InArgs._ButtonCount;
ChildSlot
[
// 新建 SVerticalBox 并获取引用,赋值给 ListBox
SAssignNew(ListBox, SVerticalBox)
];
// 刷新 UI
RebuildFromData();
}
在 RebuildFromData
函数中则基于 ButtonCount
,使用 AddSlot
接口动态添加子控件容器。在添加 slot 后,我们依然在 []
中填入需要的子控件,这和前面使用声明式语法是一样的:
void SExampleListWidget::RebuildFromData() {
// 清除当前列表数据
ListBox->ClearChildren();
for (int32 i = 0; i < ButtonCount; i) {
// 添加 slot 并设置子控件
ListBox->AddSlot()
[
SNew(SButton)
.Text(FText::FromString(FString::FromInt(i)))
];
}
}
在控件中嵌入 Details 面板 #
在实现一个工具插件的时候,经常需要让用户填入一些设置数据,此时我们对 UI 的布局没有太高的要求。那么手动布局就没有太大必要了,它不仅麻烦,还要人工处理变量和显示的绑定关系。此时一个常用的套路是利用 UE 的反射机制来替我们进行简单布局。我们可以用 UObject
类型持有一些变量,然后使用 UE 自带的 details 面板生成对应的字段设置 UI,然后将这个 UI 嵌入到我们的控件中。
我们先定义一个 UObject
在其中放置所需的成员变量,注意这些成员用 UPROPERTY
修饰:
UCLASS()
class UTestUserInput: public UObject {
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, category = "Test")
float TestFloat;
UPROPERTY(EditAnywhere, category = "Test")
UTexture2D* TestTexture;
};
在我们的控件中需要持有一个 TSharedPtr<IDetailsView>
用来指向对应对象的 details view:
class SExampleWidget : public SCompoundWidget {
public:
SLATE_BEGIN_ARGS(SMyWidget) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
// 这里保存 details view 的指针
TSharedPtr<IDetailsView> InputPanel;
};
这里需要再次提醒的一点是 SExampleWidget
并不直接或间接继承自 UObject
,因此它并不受 UE 的 GC 管理,因此这里不要去保存 UObject
的指针,而应该用其他方式保存,例如使用可以将该对象直接挂到全局或者用 TStrongObjectPtr
来保存指针。另外一个方法是由于这个对象全局一般只有一份,我们也可以直接使用对象的 default object:
void SExampleWidget::Construct(const FArguments& InArgs) {
// 获取 PropertyEditor 模块
auto& PropertyModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
// 创建 details view
FDetailsViewArgs DetailsViewArgs(false, false, true, FDetailsViewArgs::HideNameArea, true);
InputPanel = PropertyModule.CreateDetailView(DetailsViewArgs);
// 注意,这里使用了 default object,这个对象不会被释放
InputPanel->SetObject(UTestUserInput::StaticClass()->GetDefaultObject(true), true);
ChildSlot
[
// 嵌入自定义的 detail panel
InputPanel.ToSharedRef()
];
}
如果我们使用的是 defult object,后续需要获取用户输入的时候,直接从这个 default object 里拿数据即可:
代码语言:javascript复制float TestFloat = Cast<UTestUserInput>(UTestUserInput::StaticClass()->GetDefaultObject(true))->TestFloat;
References #
- Making UIs with C in Unreal Engine
- Slate Overview - UE4 官方文档
- Slate Widget Examples - UE4 官方文档