UE 自定义细节(Details)面板

发布于 2023-03-01  609 次阅读


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

如堕五里雾中
最后更新于 2023-08-03