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_OPTIMIZATIONEND_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) 重载了 SHorizontalBoxSHorizontalBox::FSlot 之间的 + 运算符。宏展开后大致为:

TArray<SHorizontalBox::FSlot*> Slots;
WidgetArgsType& operator+(SHorizontalBox::FSlot& SlotToAdd) {
    Slots.Add(&SlotToAdd);
    return *this;
}

我们前面实现的 SExampleWidget 继承自 SCompoudWidget,这是 Slate 框架内置的基础控件类型之一。Slate 框架中最基础的类是 SWidget ,基于 SWidget 的子类主要有三种,分别是 SLeafWidget SPanel SCompoudWidget,我们主要基于这三个类来构建我们的控件。他们三个最主要的区别在于附加子控件的能力:

父类型子控件例子
SLeafWidgetSImage, 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_SLOTSLATE_DEFAULT_SLOT 等。

例如:

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(...));

展开参数声明这几个宏,我们就能更清楚地看到这些变量和函数是如何被声明的了:

/// 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 里拿数据即可:

float TestFloat = Cast<UTestUserInput>(UTestUserInput::StaticClass()->GetDefaultObject(true))->TestFloat;

References