4. Slate高级控件:列表控件、树形控件、表格控件、自定义绘制

好,咱们今天聊点硬核的。Slate 里那些基础控件,比如按钮、文本框,说白了就是搭积木。但真正让界面「活」起来的,是列表、树形、表格这些高级控件。我个人习惯把这类控件叫做「数据容器」——它们不负责展示静态内容,而是跟数据源绑定,动态生成界面。

我记得刚接触 Slate 那会儿,被 SListView 的 Item 刷新机制坑过好几次。嗯,咱们一步步来,先讲最常用的列表控件。

4.1 SListView:高性能列表控件

SListView 是 Slate 里最核心的虚拟化列表控件。什么叫虚拟化?说白了,就是列表有 10 万条数据,它只渲染屏幕上能看见的那几十个 Item。你想想看,如果不做虚拟化,光创建 10 万个 SWidget 就能把内存撑爆。

使用 SListView 需要三个要素:数据源、Item 生成器、以及 Item 的 Widget 类型。我一般这样写:

// 定义 Item 数据类型
struct FMyListItem
{
    FString Name;
    int32 Value;
};

// 创建列表控件
SNew(SListView<TSharedPtr<FMyListItem>>)
    .ListItemsSource(&MyItems)  // 数据源
    .OnGenerateRow_Lambda([](TSharedPtr<FMyListItem> Item, 
                             const TSharedRef<STableViewBase>& OwnerTable)
    {
        return SNew(STableRow<TSharedPtr<FMyListItem>>, OwnerTable)
            [
                SNew(STextBlock).Text(FText::FromString(Item->Name))
            ];
    })
我的经验:数据源一定要用 TArray<TSharedPtr<...>>,别用原始指针。为什么?因为 Slate 的垃圾回收机制依赖共享引用计数,用原始指针很容易造成野指针崩溃。我曾经在项目里偷懒用了原始指针,结果 QA 测试时列表滚动几下就闪退,查了两天才找到原因。

还有一个容易被忽略的点:ListItemsSource 绑定的数组地址不能变。如果你在运行时重新分配了数组(比如 MyItems = NewArray),列表不会自动刷新。正确的做法是修改数组内容后调用 RequestListRefresh()

4.2 STreeView:树形控件与节点展开

树形控件 STreeView 比列表复杂一个维度——它需要处理父子层级关系。我建议你先想清楚数据模型,再动手写代码。

树形控件的核心是「子节点获取函数」。Slate 不会自动知道某个节点有没有子节点,你需要告诉它:

// 树节点数据结构
struct FTreeItem
{
    FString Label;
    TArray<TSharedPtr<FTreeItem>> Children;
};

// 创建树形控件
SNew(STreeView<TSharedPtr<FTreeItem>>)
    .TreeItemsSource(&RootItems)
    .OnGetChildren_Lambda([](TSharedPtr<FTreeItem> Item, 
                              TArray<TSharedPtr<FTreeItem>>& OutChildren)
    {
        OutChildren = Item->Children;
    })
    .OnGenerateRow_Lambda([](...) { ... })

这里有个坑:OnGetChildren 回调会被频繁调用。如果你的树很深,或者子节点计算逻辑很重,性能会急剧下降。我曾经接手过一个项目,树形控件展开一个节点要卡 3 秒——原因就是 OnGetChildren 里做了数据库查询。

避坑指南:千万不要在 OnGetChildren 里做耗时操作。如果需要懒加载,先把子节点设为一个「占位符」对象,然后在后台异步加载完成后,再调用 RequestTreeRefresh() 刷新整个树。

节点展开状态的管理也很重要。默认情况下,STreeView 不会记住哪些节点是展开的。如果你需要持久化展开状态(比如用户关闭面板再打开,树还是原来的展开样子),可以用 SetItemExpansion()GetItemExpansion() 手动管理。

4.3 SHeaderRow + STableRow:表格控件

表格控件本质上是一个带表头的列表。Slate 没有单独的「表格控件」,而是用 SListView 配合 SHeaderRow 来实现。我个人习惯把这种组合叫做「伪表格」,但用起来效果一点不差。

实现思路是这样的:每一行是一个 STableRow,里面用 SHorizontalBox 排列多个列。表头用 SHeaderRow 单独定义:

SNew(SVerticalBox)
+ SVerticalBox::Slot()
[
    SNew(SHeaderRow)
    + SHeaderRow::Column("Name")
        .DefaultLabel(FText::FromString("名称"))
        .FillWidth(0.5f)
    + SHeaderRow::Column("Value")
        .DefaultLabel(FText::FromString("数值"))
        .FillWidth(0.5f)
]
+ SVerticalBox::Slot()
[
    SNew(SListView<...>)
        .OnGenerateRow_Lambda([](...)
        {
            return SNew(STableRow<...>, OwnerTable)
                [
                    SNew(SHorizontalBox)
                    + SHorizontalBox::Slot()
                        .FillWidth(0.5f)
                        [ SNew(STextBlock).Text(Item->Name) ]
                    + SHorizontalBox::Slot()
                        .FillWidth(0.5f)
                        [ SNew(STextBlock).Text(Item->Value) ]
                ];
        })
]
关键点:列宽分配用 FillWidth 而不是固定像素值。这样表格在窗口缩放时能自适应。如果你需要固定列宽,用 FixedWidth。我一般把操作列(比如删除按钮)设成固定宽度,其他列用 FillWidth。

表格排序怎么实现?你需要监听表头的点击事件,然后对数据源重新排序,再调用 RequestListRefresh()。嗯,这里要注意:排序后要保留当前选中项和滚动位置,否则用户体验会很差。

4.4 自定义绘制:FPaintGeometry 与 FSlateDrawElement

有时候,内置控件满足不了需求。比如你想画一个自定义的进度条、波形图、或者节点连线。这时候就需要用到 Slate 的自定义绘制能力。

自定义绘制的入口是控件的 OnPaint 虚函数。你可以在里面拿到 FPaintGeometry(绘制区域)和 FSlateDrawElement(绘制元素),然后像 Canvas 一样画东西:

virtual int32 OnPaint(
    const FPaintArgs& Args,
    const FGeometry& AllottedGeometry,
    const FSlateRect& MyCullingRect,
    FSlateWindowElementList& OutDrawElements,
    int32 LayerId,
    const FWidgetStyle& InWidgetStyle,
    bool bParentEnabled) const override
{
    // 画一个矩形
    FSlateDrawElement::MakeBox(
        OutDrawElements,
        LayerId,
        AllottedGeometry.ToPaintGeometry(),
        &Brush,
        ESlateDrawEffect::None,
        FLinearColor::White
    );

    // 画一条线
    TArray<FVector2D> Points;
    Points.Add(FVector2D(0, 0));
    Points.Add(FVector2D(100, 100));
    FSlateDrawElement::MakeLines(
        OutDrawElements,
        LayerId + 1,
        AllottedGeometry.ToPaintGeometry(),
        Points
    );

    return LayerId + 2;
}

这里有个容易搞混的概念:LayerId 控制绘制顺序。数值越大,越在上面。你返回的 LayerId 应该是你使用的最大 LayerId + 1,这样后续的控件能正确叠在你上面。

我的建议:自定义绘制尽量少用。能用组合控件实现的,就别手动画。为什么?因为手绘的控件不支持文本本地化、不支持无障碍访问、也不支持样式继承。我只有在做编辑器专用工具(比如蓝图节点图、行为树编辑器)时才会用自定义绘制。

还有一个性能要点:OnPaint 每帧都会调用。如果你在里边做了复杂的计算(比如贝塞尔曲线插值),帧率会直线下降。我的做法是把计算结果缓存起来,只在数据变化时才重新计算。

4.5 综合实战:一个可编辑的属性表格

咱们把前面学的串起来,做一个实际例子。假设你要做一个属性编辑器,类似 UE 的 Detail 面板。它需要:

  • 表格形式展示属性名和属性值
  • 属性值支持文本编辑、下拉选择、颜色拾取
  • 支持按属性名排序
  • 支持展开/折叠分组

我会这样设计架构:

  1. 数据层:FPropertyItem 结构体,包含属性名、类型、值、子属性列表
  2. 控件层:SPropertyTable 继承 SListView,内部用 SHeaderRow 做表头
  3. 绘制层:根据属性类型动态生成不同的值控件(SEditableTextBoxSComboBoxSColorBlock

代码结构大致如下:

class SPropertyTable : public SListView<TSharedPtr<FPropertyItem>>
{
    // 重写 OnGenerateRow,根据属性类型创建不同的值控件
    virtual TSharedRef<ITableRow> OnGenerateRow(
        TSharedPtr<FPropertyItem> Item,
        const TSharedRef<STableViewBase>& OwnerTable) override
    {
        TSharedRef<SWidget> ValueWidget = CreateValueWidget(Item);
        return SNew(STableRow<TSharedPtr<FPropertyItem>>, OwnerTable)
            [
                SNew(SHorizontalBox)
                + SHorizontalBox::Slot() [ SNew(STextBlock).Text(Item->Name) ]
                + SHorizontalBox::Slot() [ ValueWidget ]
            ];
    }

    TSharedRef<SWidget> CreateValueWidget(TSharedPtr<FPropertyItem> Item)
    {
        switch (Item->Type)
        {
        case EPropertyType::Text:
            return SNew(SEditableTextBox)
                .Text(Item->Value)
                .OnTextChanged_Lambda([Item](const FText& NewText) {
                    Item->Value = NewText;
                });
        case EPropertyType::Enum:
            return SNew(SComboBox<...>)...;
        case EPropertyType::Color:
            return SNew(SColorBlock)...;
        }
    }
};
注意:这个例子为了简洁省略了很多细节。实际项目中你还需要处理:选中状态高亮、拖拽排序、撤销/重做、多选编辑等。这些功能加起来,代码量轻松上千行。但核心思路不变——数据驱动视图,控件只负责展示和交互。

好了,这一章的内容就到这。列表、树形、表格、自定义绘制,这四个东西掌握了,Slate 里 80% 的复杂界面你都能搞定。下一章咱们聊聊 Slate 的样式系统,那个才是真正让界面「好看」的关键。