3. Slate UI基础:Slate架构、SWidget、SCompoundWidget、布局控件、文本控件

好,咱们今天来聊聊Slate。说实话,很多Unreal开发者一听到Slate就头大,觉得这东西又复杂又绕。我刚开始接触的时候也差不多,看着那一堆SNewSWidget,心里直犯嘀咕——这跟UMG到底啥关系?

嗯,咱们先把这个问题说清楚。UMG其实就是在Slate上面包了一层可视化编辑器。你拖拽的那些按钮、文本框,底层全是Slate控件。说白了,Slate才是Unreal UI的亲爹,UMG只是它儿子。你要是只会用UMG拖拖拽拽,那遇到复杂UI需求时就会很被动。我当年在做一个编辑器工具时,就因为这个吃了不少苦头。

Slate架构:从底层看UI

Slate的架构,我习惯把它理解成三个层次:

  • 底层:渲染层,负责把控件画到屏幕上。这部分你基本不用碰。
  • 中间层:控件层,也就是SWidget体系。这是咱们今天的主角。
  • 上层:布局与交互层,处理控件怎么排、怎么响应事件。

你想想看,Slate本质上就是一个控件树。每个控件都是一个SWidget,它们组合在一起,形成一棵树。引擎从根节点开始遍历,逐个绘制。这个思路跟HTML的DOM树很像,但Slate更轻量、更直接。

核心要点:Slate控件树是只读的,你不能像操作UMG那样在运行时动态增删控件。要改变UI结构,你得重建控件树。这一点很多新手会踩坑。

SWidget:一切控件的基类

SWidget是所有Slate控件的基类。它定义了控件的生命周期、绘制流程、事件处理等基础行为。我建议你把它想象成一个「纯虚接口」,虽然它并不是纯虚的。

每个SWidget都有几个关键方法:

方法 作用
ComputeDesiredSize() 告诉父控件「我想占多大地方」
Paint() 把自己画到屏幕上
OnMouseButtonDown() 处理鼠标点击事件
OnKeyDown() 处理键盘事件

你不需要每次都重写这些方法。大多数时候,你只需要组合现有的控件就行。只有当你需要自定义绘制行为时,才需要继承SWidget并重写Paint()。我在项目中遇到过需要绘制自定义图表的情况,那时候才真正深入了Paint()的实现。

小技巧:如果你只是想改改外观,别碰Paint()。用SetRenderTransform()SetColorAndOpacity()这些现成的方法就够了。重写Paint()意味着你要自己处理裁剪、透明度、抗锯齿,工作量不小。

SCompoundWidget:组合控件的利器

SCompoundWidgetSWidget的一个子类,专门用来组合多个子控件。说白了,它就是给你搭积木用的。你定义一个SCompoundWidget,然后在它的Construct()方法里用SNew把子控件拼起来。

来看个例子:

class SMyCustomPanel : public SCompoundWidget
{
public:
    SLATE_BEGIN_ARGS(SMyCustomPanel) {}
    SLATE_END_ARGS()

    void Construct(const FArguments& InArgs)
    {
        ChildSlot
        [
            SNew(SVerticalBox)
            + SVerticalBox::Slot()
            .AutoHeight()
            [
                SNew(STextBlock)
                .Text(FText::FromString("Hello, Slate!"))
            ]
            + SVerticalBox::Slot()
            .FillHeight(1.0f)
            [
                SNew(SButton)
                .Text(FText::FromString("Click Me"))
                .OnClicked(this, &SMyCustomPanel::OnButtonClicked)
            ]
        ];
    }

    FReply OnButtonClicked()
    {
        UE_LOG(LogTemp, Log, TEXT("Button clicked!"));
        return FReply::Handled();
    }
};

看到没?ChildSlot就是你的画布,你在里面用SNew创建控件,用+操作符添加子项。这种声明式语法一开始可能不太习惯,但用久了你会发现它非常清晰——控件的层级结构一目了然。

注意SLATE_BEGIN_ARGSSLATE_END_ARGS这两个宏必须成对出现,缺一不可。我曾经因为漏写了SLATE_END_ARGS,编译报错找了半天,最后发现是这种低级错误。嗯,写代码时一定要细心。

布局控件:让UI排布更灵活

布局控件是Slate里最常用的东西。它们不负责显示内容,只负责安排子控件的位置和大小。常用的布局控件有这几个:

  • SVerticalBox:垂直排列子控件。每个子控件占一行。
  • SHorizontalBox:水平排列子控件。每个子控件占一列。
  • SOverlay:子控件叠在一起,像图层一样。适合做浮动提示、遮罩。
  • SScrollBox:可滚动的容器。内容超出范围时自动出现滚动条。
  • SWrapBox:自动换行的容器。适合做标签、按钮组。

每个布局控件的Slot()方法都有一些参数,用来控制子控件的行为:

参数 说明
AutoHeight() / AutoWidth() 子控件占自己需要的大小,不拉伸
FillHeight() / FillWidth() 子控件占剩余空间的比例。比如两个控件都设FillHeight(1.0f),它们就各占一半
HAlign() / VAlign() 子控件在Slot内的对齐方式。左对齐、右对齐、居中等等
Padding() 子控件周围的间距。相当于CSS的padding

我个人习惯把布局控件嵌套使用。比如一个SVerticalBox里放几个SHorizontalBox,每个SHorizontalBox里再放文本和按钮。这种嵌套方式很灵活,能实现几乎任何布局。

避坑指南:我曾经在做一个复杂面板时,把所有控件都塞进一个SVerticalBox里,结果滚动起来卡得要命。后来发现是AutoHeight()用太多,导致每次布局都要重新计算所有子控件的大小。解决办法是尽量用FillHeight(),或者把不需要动态改变大小的部分用SScrollBox包起来。

文本控件:STextBlock与更多选择

文本控件是UI里最基础的元素。Slate里最常用的文本控件是STextBlock。它的用法很简单:

SNew(STextBlock)
.Text(FText::FromString("Hello, World!"))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 14))
.ColorAndOpacity(FLinearColor::White)
.Justification(ETextJustify::Center)

这里有几个关键点:

  • Text参数必须用FText,不能用FString。因为FText支持本地化,而FString不支持。这是Unreal的硬性要求。
  • Font参数指定字体。你可以用FCoreStyle::GetDefaultFontStyle()获取默认字体,也可以加载自定义字体。
  • ColorAndOpacity控制文本颜色。支持透明度,比如FLinearColor(1.0f, 0.0f, 0.0f, 0.5f)就是半透明的红色。
  • Justification控制对齐方式。左对齐、居中、右对齐都行。

除了STextBlock,还有几个文本相关的控件:

  • SEditableText:可编辑的单行文本。适合输入框。
  • SEditableTextBox:带边框和样式的可编辑文本。比SEditableText更常用。
  • SRichTextBlock:支持富文本格式。可以嵌入图片、超链接、不同样式。

我建议你优先用STextBlock做静态文本,用SEditableTextBox做输入框。至于SRichTextBlock,它功能强大但配置复杂,适合做聊天窗口、文档编辑器这类场景。

重要提醒:文本控件的性能问题容易被忽视。如果你在列表里用了大量STextBlock,每个都设置不同的字体或颜色,那绘制开销会很大。解决办法是尽量复用字体和颜色,或者用FText::Format()合并多个文本。

实战:组合一个简单的信息面板

咱们把今天学的知识串起来,写一个简单的信息面板。这个面板包含一个标题、一段描述文本和一个关闭按钮。

class SInfoPanel : public SCompoundWidget
{
public:
    SLATE_BEGIN_ARGS(SInfoPanel) {}
        SLATE_ARGUMENT(FText, Title)
        SLATE_ARGUMENT(FText, Description)
        SLATE_EVENT(FOnClicked, OnCloseClicked)
    SLATE_END_ARGS()

    void Construct(const FArguments& InArgs)
    {
        ChildSlot
        [
            SNew(SBorder)
            .BorderImage(FCoreStyle::Get().GetBrush("GenericWhiteBox"))
            .BorderBackgroundColor(FLinearColor(0.1f, 0.1f, 0.1f, 0.9f))
            .Padding(10.0f)
            [
                SNew(SVerticalBox)
                + SVerticalBox::Slot()
                .AutoHeight()
                .Padding(0, 0, 0, 5)
                [
                    SNew(STextBlock)
                    .Text(InArgs._Title)
                    .Font(FCoreStyle::GetDefaultFontStyle("Bold", 16))
                    .ColorAndOpacity(FLinearColor::White)
                ]
                + SVerticalBox::Slot()
                .AutoHeight()
                .Padding(0, 0, 0, 10)
                [
                    SNew(STextBlock)
                    .Text(InArgs._Description)
                    .Font(FCoreStyle::GetDefaultFontStyle("Regular", 12))
                    .ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
                    .AutoWrapText(true)
                ]
                + SVerticalBox::Slot()
                .AutoHeight()
                .HAlign(HAlign_Right)
                [
                    SNew(SButton)
                    .Text(FText::FromString("关闭"))
                    .OnClicked(InArgs._OnCloseClicked)
                ]
            ]
        ];
    }
};

这个例子展示了:

  • SLATE_ARGUMENT定义外部传入的参数
  • SLATE_EVENT定义回调事件
  • SBorder给面板加背景和边框
  • SVerticalBox做垂直布局
  • STextBlock显示标题和描述
  • SButton做关闭按钮

你可以在任何需要的地方用SNew(SInfoPanel)来创建这个面板,传入标题、描述和关闭回调。是不是很灵活?

个人经验:我习惯在Construct()方法里把控件树写得尽量清晰,每个Slot都加上注释。这样半年后回来看代码,还能一眼看出每个控件是干嘛的。别偷懒,代码是写给人看的。

好了,Slate的基础就讲到这里。下一章咱们会深入事件处理和自定义绘制,到时候你会看到Slate更强大的一面。记住,Slate的学习曲线虽然陡,但一旦掌握了,你就能做出任何你想要的UI——不管是编辑器工具还是游戏内HUD,都不在话下。