UE 细节面板自定义(Details Customization),添加按钮与属性混排
基于 UE 的反射,我们可以基本不必关心细节面板的开发,使用反射自动生成即可。UE 支持的很多说明符也提供了很强大的自定义能力 UPROPERTY 说明符示例
然而,在开发中或多或少可能也会遇到一些特殊的,难以使用反射生成的、或系统说明符无法满足生成的细节面板,这种时候,就可以使用 Details Customization 来进行完全的自定义,以完成我们的功能。笔者开发过程中,有一个按钮的需求,需要在几种属性之间生成,因此本文也主要针对细节面板中生成一个按钮并控制顺序进行描述,其他控件并未涉及,不过细节面板自定义整体逻辑类似也可参考,另外,引擎中有很多自定义的源码,如果有特殊的需求,或许可以先看看系统中有无类似的实例,源码大多位于:Engine\Source\Editor\DetailCustomizations\Private (如果是简单功能, UFunction 的 CallInEditor 说明符也可以完成简单功能,但其样式、名称等均无法自定义)
官方文档链接:Details 面板自定义
一、创建自定义样式类
当我们需要对某个数据进行自定义细节面板样式时,需要创建一个样式类,继承 IDetailCustomization
#pragma once
#include "PropertyEditor/Public/IDetailCustomization.h"
class IDetailCategoryBuilder;
class FTestDataDetails : public IDetailCustomization
{
public:
static TSharedRef<IDetailCustomization> MakeInstance();
virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override;
};
#include "TestDataDetails.h"
#include "DetailLayoutBuilder.h"
#define LOCTEXT_NAMESPACE "TestDataDetails"
TSharedRef<IDetailCustomization> FTestDataDetails::MakeInstance()
{
return MakeShareable( new FTestDataDetails);
}
void FTestDataDetails::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder)
{
}
#undef LOCTEXT_NAMESPACE
其中,需要实现两个方法,MakeInstance 是一个辅助函数,用于快速实例化我们的类,CustomizeDetails 是真正完成细节面板自定义的位置,在这里需要完成我们的自定义逻辑。
二、注册数据样式类
在定义好我们自定义的样式类后,我们还需要在合适的位置进行注册,将数据关联到其要使用的样式类,这一步一般在 StartupModule 处进行即可,ShutdownModule 时记得也要反注册。
// 注册
FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
PropertyEditorModule.RegisterCustomClassLayout(FName("TestData"), FOnGetDetailCustomizationInstance::CreateStatic(&TestDataDetails::MakeInstance));
// 反注册
FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
PropertyEditorModule.UnregisterCustomClassLayout(FName("TestData"));
注意这里注册时使用的类目没有包含 UE 的前缀,FName 还是有写错的风险,我们也可以使用 DetailView 来注册
TSharedPtr<IDetailsView> DetailsView = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor").CreateDetailView({false, false, false, FDetailsViewArgs::HideNameArea, true});
DetailsView->RegisterInstancedCustomPropertyLayout(UTestData::StaticClass(), FOnGetDetailCustomizationInstance::CreateStatic(&FTestDataDetails::MakeInstance));
完成后,当指定的数据类生成细节面板时,就会使用我们定义的 CustomizeDetails 方法里的逻辑来生成细节面板了。
三、细节面板添加按钮并控制顺序
CustomizeDetails 方法的入参 DetailBuilder 提供了一系列的接口,我们可以通过其来进行我们需要的操作。
首先,假定我们的需求是在中间位置的某个 Category 中的属性之间添加一个按钮,这里临时创建几个属性用于测试:
(此处的 MyMetaTag 是笔者过滤的条件,读者可以不必关心,了解更多可查看UPROPERTY 说明符示例)
UPROPERTY(EditAnywhere, Category = "Category 1", meta = (MyMetaTag))
FString Category1_Var1;
UPROPERTY(EditAnywhere, Category = "Category 1", meta = (MyMetaTag))
FString Category1_Var2;
UPROPERTY(EditAnywhere, Category = "Category 2", meta = (MyMetaTag))
FString Category2_Var1;
UPROPERTY(EditAnywhere, Category = "Category 2", meta = (MyMetaTag))
FString Category2_Var2;
UPROPERTY(EditAnywhere, Category = "Category 2", meta = (MyMetaTag))
FString Category2_Var3;
UPROPERTY(EditAnywhere, Category = "Category 3", meta = (MyMetaTag))
FString Category3_Var1;
UPROPERTY(EditAnywhere, Category = "Category 3", meta = (MyMetaTag))
FString Category3_Var2;
UFUNCTION(CallInEditor)
void CallInEditorFunc(){};
不妨假定我们需要在第二个 Category 的 第二个与第三个变量之间插入一个按钮(即 Category2_Var2 与 Category2_Var3 之间)。
1. Category 顺序问题
DetailBuilder 的 EditCategory 方法会返回一个 IDetailCategoryBuilder&,使用其我们就可以进行对应 Category 内细节面板的自定义:
void FTestDataDetails::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder)
{
IDetailCategoryBuilder& Category2 = DetailBuilder.EditCategory(FName("Category 2"));
FDetailWidgetRow& ButtonRow = Category2.AddCustomRow(LOCTEXT("Test Button", "Test Button"), false);
ButtonRow.ValueWidget
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.FillWidth(1.0f)
[
SNew(SButton)
.Text(LOCTEXT("TestButton", "测试按钮"))
]
];
}
不难看出, 使用 EditCategory 获取 IDetailCategoryBuilder 虽然使得我们具有了操作对应 Category 内细节面板的能力,但这一 Edit 的过程使得各个 Category 的顺序发生了混乱,不再与头文件中定义的属性顺序一致,我们使用过的 Category 会默认生成在最上方。
当我们指定 EditCategory 的入参 ECategoryPriority 为 ECategoryPriority::Variable 时,虽然看枚举名称理解为应该是变量顺序,但同样也会出现这一问题
namespace ECategoryPriority
{
enum Type
{
// Highest sort priority
Variable = 0,
Transform,
Important,
TypeSpecific,
Default,
// Lowest sort priority
Uncommon,
};
}
因此,目前为了解决这一问题,可能我们需要将所有涉及到的 Category 在此处重新定义一下(以后再仔细研究下,感觉不该如此),如下即可解决:
IDetailCategoryBuilder& Category1 = DetailBuilder.EditCategory(FName("Category 1"));// , FText::FromString(TEXT("Category_1")), ECategoryPriority::Variable);
IDetailCategoryBuilder& Category2 = DetailBuilder.EditCategory(FName("Category 2"));// , FText::FromString(TEXT("Category_2")), ECategoryPriority::Variable);
IDetailCategoryBuilder& Category3 = DetailBuilder.EditCategory(FName("Category 3"));// , FText::FromString(TEXT("Category_3")), ECategoryPriority::Variable);
2. 与 UPROPERTY 属性混排顺序
经过上述操作,按钮已经正确创建了,并且各个 Category 的位置也已经正确排布,此时应该继续修改其与 UPROPERTY 属性混排的顺序了。
这里我们按照上述 Category 的思路,将所需要的属性重新以对应的位置顺序生成一下,就可以完成这一目的。
这感觉起来不是一个太过合理的方案,按照这样的逻辑,如果属性再有更新,我们也需要再在此处添加对应的生成逻辑。
然而,系统中就是这么解决的这一问题,参考 Engine\Source\Editor\Persona\Private\PersonaMeshDetails.cpp
虽然感觉不太合理,但确实可以完成自定义控件与 UPROPERTY 属性混排的问题。
系统此处的代码还调用了 DetailBuilder.HideProperty() 方法,笔者测试并观察不到什么区别,但印象里大象无形里提到过可能会多次渲染并覆盖,因此这里考虑拿 RenderDoc 截下帧看下:命令行 renderdoc.CaptureAllActivity 1
,renderdoc.CaptureFrame
截取一下。
笔者这里一部分属性执行了 Hide,一部分未执行,但并未观察到区别,可能测试方式不太正确,读者有兴趣可以尝试,此处不再深入研究,暂时先按系统逻辑保留此句 Hide。
代码如下:
void FTestDataDetails::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder)
{
IDetailCategoryBuilder& Category1 = DetailBuilder.EditCategory(FName("Category 1"));// , FText::FromString(TEXT("Category_1")), ECategoryPriority::Variable);
IDetailCategoryBuilder& Category2 = DetailBuilder.EditCategory(FName("Category 2"));// , FText::FromString(TEXT("Category_2")), ECategoryPriority::Variable);
IDetailCategoryBuilder& Category3 = DetailBuilder.EditCategory(FName("Category 3"));// , FText::FromString(TEXT("Category_3")), ECategoryPriority::Variable);
TSharedPtr<IPropertyHandle> Category2_Var1PropertyHandle = DetailBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(UTestData, Category2_Var1));
Category2.AddProperty(Category2_Var1PropertyHandle);
DetailBuilder.HideProperty(Category2_Var1PropertyHandle);
TSharedPtr<IPropertyHandle> Category2_Var2PropertyHandle = DetailBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(UTestData, Category2_Var2));
Category2.AddProperty(Category2_Var2PropertyHandle);
DetailBuilder.HideProperty(Category2_Var2PropertyHandle);
FDetailWidgetRow& ButtonRow = Category2.AddCustomRow(LOCTEXT("Test Button", "Test Button"), false);
ButtonRow.ValueWidget
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.FillWidth(1.0f)
[
SNew(SButton)
.Text(LOCTEXT("TestButton", "测试按钮"))
]
];
TSharedPtr<IPropertyHandle> Category2_Var3PropertyHandle = DetailBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(UTestData, Category2_Var3));
Category2.AddProperty(Category2_Var3PropertyHandle);
DetailBuilder.HideProperty(Category2_Var3PropertyHandle);
}
四、按钮回调
通过以下方式,我们就能获取到使用此样式类实例化细节面板的 Object,也就是数据本身,这样我们就可以使用 Slate 语法的按钮回调,来处理细节面板的按钮回调事件。
const TArray< TWeakObjectPtr<UObject> >& SelectedObjects = DetailBuilder.GetSelectedObjects();
if (SelectedObjects.Num() != 1)
{
return;
}
// 属性 UTestData* Data;
const TWeakObjectPtr<UObject>& CurrentObject = SelectedObjects[0];
if ( CurrentObject.IsValid() )
{
this->Data = Cast<UTestData>(CurrentObject.Get());
}
还有另一种获取数据的方式,系统中两种都有许多,但控制显隐时多应用了此种形式
this->IsHidePropertyHandle = DetailBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(UTestData, IsHide));
EVisibility FTestDataDetails::ShouldShowXXX() const
{
bool IsHide = false;
this->IsHidePropertyHandle->GetValue(IsHide);
return IsHide ? EVisibility::Hidden : EVisibility::Visible;
}
能够获取到数据, UI 上的回调也就可以正常处理了
FDetailWidgetRow& ButtonRow = Category2.AddCustomRow(LOCTEXT("Test Button", "Test Button"), false);
ButtonRow.ValueWidget
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.FillWidth(1.0f)
[
SNew(SButton)
.Text(LOCTEXT("TestButton", "测试按钮"))
.OnClicked(this, &FTestDataDetails::ButtonClicked)
]
];
在 ButtonClicked 中处理相关操作即可
FReply FTestDataDetails::ButtonClickedEvent()
{
// 属性 UTestData* Data; 即为数据
return FReply::Handled();
}
五、控制显示时机
有些按钮,我们可能不需要立刻显示,而是在触发了某些条件之后才可以点击或显示,此时就可以使用 TAttribute 方式来控制显隐, TAttribute 简单来说就是一个保存了值和获取值的委托,当值发生了变化时,其会返回的结果也会发生改变,这对于做 Slate UI 来说很有便利,不必想要动态更新 UI 时还需要显示的刷新一下。
- [ ] 关于 TAttribute 的原理后续有时间会再单独分析下。
FDetailWidgetRow& ButtonRow = Category2.AddCustomRow(LOCTEXT("Test Button", "Test Button"), false); ButtonRow.Visibility(TAttribute<EVisibility>( this, &FTestDataDetails::ShouldShowXXX )) .ValueWidget [ SNew(SHorizontalBox) +SHorizontalBox::Slot() .FillWidth(1.0f) [ SNew(SButton) .Text(LOCTEXT("TestButton", "测试按钮")) .OnClicked(this, &FTestDataDetails::ButtonClickedEvent) ] ];
ShouldShowXXX 方法中,使用 四 中获取数据的方法,使用数据判断处理即可,例如,我们按下图逻辑实现这一方法
EVisibility FTestDataDetails::ShouldShowXXX() const { return this->Data->Category2_Var1 == "Test" ? EVisibility::Visible : EVisibility::Collapsed; }
最终表现为当我们在 Category2_Var1 中输入 Test 时,才会将这一按钮显示出来。一些说明符 EditCondition 无法实现的条件控制,也可以通过此方式来完成相应逻辑
参考
源码多位于:Engine\Source\Editor\DetailCustomizations
本文几种写法参考:
Engine\Source\Editor\DetailCustomizations\Private\StaticMeshActorDetails.cpp
Engine\Source\Editor\DetailCustomizations\Private\AnimSequenceDetails.cpp
Engine\Source\Editor\Persona\Private\PersonaMeshDetails.cpp
Comments 1 条评论
博主 Santa Wang
(=・ω・=)
("▔□▔)/
(● ̄(エ) ̄●)