Details Panel Customization in Unreal Engine

虚幻引擎中的属性面板定制化

In UE, when developing editor plugins, automatically creating property panels through the reflection information of USTRUCT provides a very convenient way to offer a configurable solution. However, there are often some specific customization needs for property panels, such as providing special panel options or displaying different types of values based on parameters. Property panels in UE are applied in HotPatcher and ResScannerUE, allowing for straightforward configuration and customization of plugins. UE’s official documentation: Details Panel Customization .

This article starts with creating an independent Details panel and offers implementation methods for customizing property panels and property entries through the specific case in ResScannerUE .

Property Panel StructureDetailsView

Taking ResScannerUE as an example, launching the plugin creates a Dock that includes some basic operation buttons and a property panel:

The elements displayed in the property panel are reflection properties of a certain USTRUCT structure. In the case mentioned above, it is a FScannerConfig structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
USTRUCT(BlueprintType)
struct RESSCANNER_API FScannerConfig
{
GENERATED_USTRUCT_BODY()
public:
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Configuration Name",Category="Base")
FString ConfigName;
// if true,only scan the GlobalScanFilters assets
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Enable Global Resource",Category="Global")
bool bByGlobalScanFilters = false;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Block Resources Configured in Each Rule",Category="Global",meta=(EditCondition="bByGlobalScanFilters"))
bool bBlockRuleFilter = false;
// if bByGlobalFilters is true,all rules use the filter assets
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Global Scan Configuration",Category="Global",meta=(EditCondition="bByGlobalScanFilters"))
FAssetFilters GlobalScanFilters;
// force ignore assets,don't match any rule
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Global Ignore Scan Configuration",Category="Global",meta=(EditCondition="bByGlobalScanFilters"))
TArray<FAssetFilters> GlobalIgnoreFilters;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Git Repository Scan Configuration",Category="Global",meta=(EditCondition="bByGlobalScanFilters"))
FGitChecker GitChecker;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Enable Rules Data Table",Category="RulesTable")
bool bUseRulesTable = false;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Rules Data Table",Category="RulesTable", meta=(RequiredAssetDataTags = "RowStructure=ScannerMatchRule",EditCondition="bUseRulesTable"))
TSoftObjectPtr<class UDataTable> ImportRulesTable;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Rules List",Category="Rules")
TArray<FScannerMatchRule> ScannerRules;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Save Configuration File",Category="Save")
bool bSaveConfig = true;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Save Scan Result",Category="Save")
bool bSaveResult = true;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Storage Path",Category="Save")
FDirectoryPath SavePath;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Standalone Mode",Category="Advanced")
bool bStandaloneMode = true;
UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Advanced")
FString AdditionalExecCommand;
};

Defining a structure like this allows UE’s property panel to automatically create corresponding types and substructure members, completely avoiding the need to write creation code for each property. The creation process is also very simple, requiring only the use of the PropertyEditor module to create an SStructureDetailsView in a certain Slate class’s Construct method.

First, you need to include the PropertyEditor module in build.cs, and then you can create it using PropertyEditor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// .h
TSharedPtr<FScannerConfig> ScannerConfig;
TSharedPtr<IStructureDetailsView> SettingsView;
// .cpp
void SResScannerConfigPage::CreateScannerStructureDetailView()
{
// Create a property view
FPropertyEditorModule& EditModule = FModuleManager::Get().GetModuleChecked<FPropertyEditorModule>("PropertyEditor");

FDetailsViewArgs DetailsViewArgs;
{
DetailsViewArgs.bAllowSearch = true;
DetailsViewArgs.bHideSelectionTip = true;
DetailsViewArgs.bLockable = false;
DetailsViewArgs.bSearchInitialKeyFocus = true;
DetailsViewArgs.bUpdatesFromSelection = false;
DetailsViewArgs.NotifyHook = nullptr;
DetailsViewArgs.bShowOptions = true;
DetailsViewArgs.bShowModifiedPropertiesOption = false;
DetailsViewArgs.bShowScrollBar = false;
DetailsViewArgs.bShowOptions = true;
}

FStructureDetailsViewArgs StructureViewArgs;
{
StructureViewArgs.bShowObjects = true;
StructureViewArgs.bShowAssets = true;
StructureViewArgs.bShowClasses = true;
StructureViewArgs.bShowInterfaces = true;
}
// create details
SettingsView = EditModule.CreateStructureDetailView(DetailsViewArgs, StructureViewArgs, nullptr);
FStructOnScope* Struct = new FStructOnScope(FScannerConfig::StaticStruct(), (uint8*)ScannerConfig.Get());
SettingsView->GetDetailsView()->RegisterInstancedCustomPropertyLayout(FScannerConfig::StaticStruct(),FOnGetDetailCustomizationInstance::CreateStatic(&FScannerSettingsDetails::MakeInstance));
SettingsView->SetStructureData(MakeShareable(Struct));
}

The created panel needs to bind a UStruct to obtain the reflection information of that structure, as well as bind a structure instance to obtain and store values. Additionally, the properties of FDetailsViewArgs and FStructureDetailsViewArgs can be configured to control the functionality of the property panel, such as whether to enable property name search, scroll bars, etc.

Once SettingsView is created, the method to display it in a Slate control is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void SResScannerConfigPage::Construct(const FArguments& InArgs)
{
ScannerConfig = MakeShareable(new FScannerConfig);
CreateScannerStructureDetailView();

ChildSlot
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
.HAlign(HAlign_Right)
.Padding(4, 4, 10, 4)
[
// ...
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(FEditorStyle::GetMargin("StandardDialog.ContentPadding"))
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.VAlign(VAlign_Center)
[
SettingsView->GetWidget()->AsShared()
]
]
];

In fact, this is just putting the created SettingsView Widget into a container for display; this Widget contains all editable reflection properties bound to the UStruct, as shown in the previous ResScannerUE panel display.

Property Panel Customization

In the previous code for creating SettingView, there is a line:

1
SettingsView->GetDetailsView()->RegisterInstancedCustomPropertyLayout(FScannerConfig::StaticStruct(),FOnGetDetailCustomizationInstance::CreateStatic(&FScannerSettingsDetails::MakeInstance));

Here, a DetailCustomization class instance for the current SettingsView is specified, which is used for customization operations when creating the SettingView control. The declaration of this class is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ScannerConfigDetails.h
#include "IDetailCustomization.h"

class FScannerSettingsDetails : public IDetailCustomization
{
public:
/** Makes a new instance of this detail layout class for a specific detail view requesting it */
static TSharedRef<IDetailCustomization> MakeInstance()
{
return MakeShareable(new FScannerSettingsDetails());
}

/** IDetailCustomization interface */
virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override;
};

In the CustomizeDetails interface, you can perform some customization operations, such as creating a new Slate control in the property panel. For example, in the Import button from ResScannerUE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void FScannerSettingsDetails::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder)
{
TArray< TSharedPtr<FStructOnScope> > StructBeingCustomized;
DetailBuilder.GetStructsBeingCustomized(StructBeingCustomized);
check(StructBeingCustomized.Num() == 1);
FScannerConfig* ScannerSettingsIns = (FScannerConfig*)StructBeingCustomized[0].Get()->GetStructMemory();
IDetailCategoryBuilder* RulesCategory = DetailBuilder.EditCategory(TEXT("RulesTable"),FText::GetEmpty(),ECategoryPriority::Default);

if(RulesCategory)
{
RulesCategory->SetShowAdvanced(true);
RulesCategory->AddCustomRow(LOCTEXT("ImportRulesTable", "Import Rules Table"),true)
.ValueContent()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.Padding(0)
.AutoWidth()
[
SNew(SButton)
.Text(LOCTEXT("Import", "Import"))
.ToolTipText(LOCTEXT("ImportRulesTable_Tooltip", "Import Rules Table to the Config"))
.IsEnabled_Lambda([this,ScannerSettingsIns]()->bool
{
return ScannerSettingsIns->bUseRulesTable;
})
.OnClicked_Lambda([this, ScannerSettingsIns]()
{
if (ScannerSettingsIns)
{
ScannerSettingsIns->HandleImportRulesTable();
}
return(FReply::Handled());
})
]
];
}
}

You can use the DetailBuilder in the interface to access the currently bound structure instance, allowing you to obtain data from the configuration and execute operations.

The above code obtains the Category of the property and creates a CustomRow element under RulesTable. In ValueContent(), you can directly write Slate code to create the custom controls. Since you can directly access the bound structure instance, the implementation in the controls can also directly call functions of the structure, allowing the Import button to import data from the DataTable into the configuration.

Customization of Properties in the Panel

The previous section mentioned that you can create custom Slate controls in the property panel to achieve customized behaviors. However, some requirements need to modify the way properties are displayed in the panel. For example, in the requirement for property matching in ResScannerUE:

  1. You can select UClass type.
  2. Based on the selected UClass type, list options for the properties, and dynamically create the value types of the selected properties to display in the panel.
  3. Property names and values form an array, where each element can specify different property names and create corresponding value types.

I have already implemented this, and the effect is as follows:

Here’s a description of the implementation. If it’s only about obtaining reflection data, it’s actually not difficult. However, displaying it in the property panel can be a bit more complex.

  1. Traverse all the properties based on UClass.
  2. Obtain FProperty to get the current property type.
  3. Create different FProperty in the property panel.

Since the property panel binds a UStruct and creates properties, my initial thought was to dynamically modify the UStruct. However, that’s not very reasonable because UStruct is fixed and does not allow for different types of elements in an array.

So, what other ways are there? After reviewing the engine code, I found that UE can also perform custom operations on certain types of properties in the property panel, primarily through the IPropertyTypeCustomization.

Its usage is similar to the IDetailCustomization interface, which also provides an interface for custom property panel controls, but the creation approach is slightly different:

  1. DetailCustomization must be specified when creating SettingView, while PropertyTypeCustomization does not require this.
  2. DetailCustomization only takes effect in the created SettingView, while PropertyTypeCustomization can apply to all places where that property is created.

PropertyTypeCustomization is more flexible. After completing the custom events, it can be correctly displayed in both SettingView and the structures created from DataTable.

In ResScannerUE, I created a structure FPropertyMatchMapping to store property names and values:

1
2
3
4
5
6
7
8
9
10
11
12
USTRUCT(BlueprintType)
struct FPropertyMatchMapping
{
GENERATED_USTRUCT_BODY()
public:
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Matching Mode")
EPropertyMatchRule MatchRule;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Property Name")
FString PropertyName;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Value")
FString MatchValue;
};

PropertyName and MatchValue are both strings. The reason for using strings is that they can store values of any type. The subsequent operations for property customization will target the FPropertyMatchMapping structure type.

Thus, the implementation logic for property customization is as follows:

  1. Create a custom PropertyTypeCustomization.
  2. When the property panel is created, replace the original property creation logic with PropertyTypeCustomization.

First, create the PropertyTypeCustomization class for FPropertyMatchMapping:

1
2
3
4
5
6
7
8
9
10
11
class FCustomPropertyMatchMappingDetails : public IPropertyTypeCustomization
{
public:
static TSharedRef<IPropertyTypeCustomization> MakeInstance()
{
return MakeShareable(new FCustomPropertyMatchMappingDetails);
}

virtual void CustomizeHeader(TSharedRef<class IPropertyHandle> StructPropertyHandle, class FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& StructCustomizationUtils) override;
virtual void CustomizeChildren(TSharedRef<class IPropertyHandle> StructPropertyHandle, class IDetailChildrenBuilder& StructBuilder, IPropertyTypeCustomizationUtils& StructCustomizationUtils) override;
}

Then, during module startup, you need to register this class:

1
2
3
4
5
void FResScannerEditorModule::StartupModule()
{
FPropertyEditorModule& PropertyEditorModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>(TEXT("PropertyEditor"));
PropertyEditorModule.RegisterCustomPropertyTypeLayout(TEXT("PropertyMatchMapping"), FOnGetPropertyTypeCustomizationInstance::CreateStatic(&FCustomPropertyMatchMappingDetails::MakeInstance));
}

This step informs the PropertyEditor module that when creating a property panel for FPropertyMatchMapping, an instance of FCustomPropertyMatchMappingDetails will be created to perform the creation operation, transferring the control creation process for the specified class to your code.

Implementation of FCustomPropertyMatchMappingDetails involves creating the property name and setting it up to capture the corresponding values. In CustomizeHeader, I can create the property name directly, but I did not find a way to access the entire parent structure instance in the parameters passed into CustomizeHeader, so I could not retrieve what the selected UClass is. If the property-value pair doesn’t rely on contextual properties, one could easily create it here.

However, what I wanted to implement depends on the selected UClass value, so I only created the property name in CustomizeHeader, while leaving the value control creation to CustomizeChildren:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void FCustomPropertyMatchMappingDetails::CustomizeHeader(TSharedRef<IPropertyHandle> StructPropertyHandle,
FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
MatchRuleHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FPropertyMatchMapping, MatchRule));
MatchValueHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FPropertyMatchMapping, MatchValue));
PropertyNameHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FPropertyMatchMapping, PropertyName));
CurrentPropertyRow = NULL;
OldPropertyRow = NULL;
HeaderRow.NameContent() // Property Name Style
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Property Rule")))
];

}

In CustomizeHeader, I retrieved and stored the real element values (PropertyName/Value are both FString) of the FPropertyMatchMapping instance, while placing these elements into CustomizeChildren for creation because the IDetailChildrenBuilder parameter passed into CustomizeChildren allows access to the parent structure, thus retrieving the selected UClass:

1
2
3
4
5
6
7
8
void FCustomPropertyMatchMappingDetails::CustomizeChildren(TSharedRef<IPropertyHandle> StructPropertyHandle,
IDetailChildrenBuilder& StructBuilder, IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
IDetailCategoryBuilder& ParentCategory = mStructBuilder->GetParentCategory();
IDetailLayoutBuilder& ParentLayout = ParentCategory.GetParentLayout();
auto ScanAssetType = ParentLayout.GetProperty(GET_MEMBER_NAME_CHECKED(FScannerMatchRule, ScanAssetType));
ScanAssetType->SetOnPropertyValueChanged(FSimpleDelegate::CreateSP(this, &FCustomPropertyMatchMappingDetails::OnClassValueChanged));
}

Having obtained the UClass, I can now get all reflection properties and create an SComboBox:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

void FCustomPropertyMatchMappingDetails::OnClassValueChanged()
{
UObject* ClassObject = NULL;
Class = NULL;
IDetailCategoryBuilder& ParentCategory = mStructBuilder->GetParentCategory();
IDetailLayoutBuilder& ParentLayout = ParentCategory.GetParentLayout();
auto ScanAssetType = ParentLayout.GetProperty(GET_MEMBER_NAME_CHECKED(FScannerMatchRule, ScanAssetType));

ScanAssetType->GetValue(ClassObject);
if(ClassObject)
{
Class = Cast<UClass>(ClassObject);
for(TFieldIterator<FProperty> PropertyIter(Class);PropertyIter;++PropertyIter)
{
FProperty* PropertyIns = *PropertyIter;
AllPropertyNames.Add(MakeShareable(new FString(PropertyIns->GetName())));
AllPropertyMap.Add(PropertyIns->GetName(),PropertyIns);
}
}
TSharedPtr<SWidget> Widget;
if(Class)
{
SAssignNew(AllPropertySelector,SComboBox<TSharedPtr<FString>>)
.OptionsSource(&AllPropertyNames)
.OnGenerateWidget(SComboBox<TSharedPtr<FString>>::FOnGenerateWidget::CreateRaw(this,&FCustomPropertyMatchMappingDetails::HandleGenerateWidget_ForPropertyNamesComboBox))
.OnSelectionChanged(SComboBox< TSharedPtr<FString> >::FOnSelectionChanged::CreateRaw(this,&FCustomPropertyMatchMappingDetails::HandleSelectionChanged_ForPropertyNamesComboBox))
[
SAssignNew(PropertyNameComboContent, SBox)
];
PropertyNameComboContent->SetContent(SNew(STextBlock).Text(FText::FromString(*AllPropertyNames[0].Get())));
Widget = AllPropertySelector;
}
else
{
Widget = PropertyNameHandle->CreatePropertyValueWidget();
}
AllPropertySelectorBox->SetContent(Widget.ToSharedRef());
}
void FCustomPropertyMatchMappingDetails::CustomizeChildren(TSharedRef<IPropertyHandle> StructPropertyHandle,
IDetailChildrenBuilder& StructBuilder, IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
// ...
StructBuilder.AddCustomRow(FText::FromString(TEXT("PropertyName")))
.NameContent()
[
PropertyNameHandle->CreatePropertyNameWidget()
]
.ValueContent()
.MinDesiredWidth(500)
[
SAssignNew(AllPropertySelectorBox, SBox)
];

OnClassValueChanged();
}

The idea is to create an SWidget through Slate and place it into the container created by StructBuilder.AddCustomRow in the ValueContent container.

Using the created SComboBox, you can retrieve the property name selected in the editor. With the selected property name and the UClass, you can access the FProperty, which represents the reflection information of the property. Therefore, when the name selected in the ComboBox changes, the FProperty obtained will differ, leading to corresponding types. The next key step is to dynamically create the expected value type in the property panel based on the obtained FProperty:

First, you need to create a container to store the value controls in CustomizeChildren:

1
2
3
4
5
6
7
8
9
10
StructBuilder.AddCustomRow(FText::FromString(TEXT("PropertyValue")))
.NameContent()
[
MatchValueHandle->CreatePropertyNameWidget()
]
.ValueContent()
.MinDesiredWidth(500)
[
SAssignNew(PropertyContent,SBox)
];

PropertyContent is a TSharedPtr<SBox> meant to display the controls created later.

Continuing from the previous discussion of dynamically creating the corresponding value controls based on FProperty, the IDetailChildrenBuilder contains a function AddExternalStructureProperty, allowing you to add a property of an external structure to the current Details.

For example, considering the CompressionSettings property of UTexture2D:

1
2
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Compression, AssetRegistrySearchable)
TEnumAsByte<enum TextureCompressionSettings> CompressionSettings;

If I want to create this enum in a property panel that doesn’t directly contain it and place it in the previously created PropertyContent, it would look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Class is UTexture2D
IDetailPropertyRow* PropertyRow = mStructBuilder->AddExternalStructureProperty(MakeShareable(new FStructOnScope(Cast<UStruct>(Class))), TEXT("CompressionSettings"));
if(PropertyRow)
{
PropertyRow->Visibility(EVisibility::Hidden);

FString ExistValue;
MatchValueHandle->GetValueAsFormattedString(ExistValue);
PropertyRow->GetPropertyHandle()->SetValueFromFormattedString(ExistValue);

TSharedRef<SWidget> Widget = PropertyRow->GetPropertyHandle()->CreatePropertyValueWidget();
PropertyContent->SetContent(Widget);
}

You can store mStructBuilder in the parameters of CustomizeChildren for future access.

In fact, the above code implements the key part of creating the corresponding value type controls based on FProperty. By simply obtaining the UClass and the property name, you can create the corresponding SWidget and place it into a designated container for display.

The code to retrieve the property name selected from the ComboBox, obtain the FProperty via the property name, and then create the Widget is extensive. Interested readers can refer to the ResScannerUE DetailCustomization/CustomPropertyMatchMappingDetails.cpp for the full implementation.

Generic Data Storage Method

Apart from needing to display different properties in the panel, a general method for storing these values is also necessary. Strings can be used to store information of all types of values, and UE offers Value Widget methods for reading string values and setting values from strings:

1
2
3
FString Value;
MatchValueHandle->GetValueAsFormattedString(Value);
MatchValueHandle->SetValueFromFormattedString(Value);

With these two functions, it is achievable that all types of values can be stored and retrieved with strings.

The article is finished. If you have any questions, please comment and communicate.

Scan the QR code on WeChat and follow me.

Title:Details Panel Customization in Unreal Engine
Author:LIPENGZHA
Publish Date:2021/10/25 09:34
Word Count:8.5k Words
Link:https://en.imzlp.com/posts/26919/
License: CC BY-NC-SA 4.0
Reprinting of the full article is prohibited.
Your donation will encourage me to keep creating!