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 面板。它需要:
- 表格形式展示属性名和属性值
- 属性值支持文本编辑、下拉选择、颜色拾取
- 支持按属性名排序
- 支持展开/折叠分组
我会这样设计架构:
- 数据层:
FPropertyItem结构体,包含属性名、类型、值、子属性列表 - 控件层:
SPropertyTable继承SListView,内部用SHeaderRow做表头 - 绘制层:根据属性类型动态生成不同的值控件(
SEditableTextBox、SComboBox、SColorBlock)
代码结构大致如下:
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 的样式系统,那个才是真正让界面「好看」的关键。