Details Panel Customization in Unreal Engine

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

When developing editor plugins in UE, it is very convenient to automatically create property panels using the reflection information of USTRUCT, providing a configurable way, but there are often special customization requirements for the property panels, such as providing special panel options, displaying different types of values based on parameters, etc. The property panels in UE are applied in HotPatcher and ResScannerUE, which can conveniently configure and customize plugins. The official documentation from UE: Details Panel Customization.

This article starts with creating an independent Details panel, and through specific cases in ResScannerUE, provides implementation methods for customizing property panels and property items.

Property Panel StructureDetailsView

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

The elements displayed in the property panel are all reflection properties of a USTRUCT structure. As an example of the above panel, it is a structure of FScannerConfig:

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 Resources",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 rule using 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 Results",Category="Save")
bool bSaveResult = true;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Save Path",Category="Save")
FDirectoryPath SavePath;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Standalone Mode",Category="Advanced")
bool bStandaloneMode = true;
UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Advanced")
FString AdditionalExecCommand;
};

Just define a structure like this, and the UE property panel will create the corresponding types and sub-structure members, completely avoiding the need to write creation code for each property. The creation method is also very simple; just create an SStructureDetailsView in the Construct of a certain Slate class through the PropertyEditor module.

First, include the PropertyEditor module in build.cs, and then you will be able to create it through 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
// .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.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 be bound to a UStruct to obtain the reflection information of the structure, and also bind a structure instance to get display and storage values. At the same time, the functionality of the property panel can be controlled by setting the parameters of FDetailsViewArgs and FStructureDetailsViewArgs, such as whether to search for property names, show scroll bars, etc.

After creating SettingsView, the method to display it in the Slate control is:

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
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()
]
]
];
}

It’s simply putting the created SettingsView widget into a container for display, which includes all editable reflection properties bound to the UStruct, just like the panel display of ResScannerUE mentioned earlier.

Property Panel Customization

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

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

This specifies an instance of a DetailCustomization class for the current SettingsView, 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, the Import button in 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 obtain the structure instance bound to the current SettingView, allowing you to retrieve data from the configuration and perform operations.

The above code retrieves the property’s Category, creating a CustomRow element under RulesTable, and within ValueContent() you can directly write Slate code to create your custom control. Because you can directly access the bound structure instance, you can also directly call the structure’s functions in the control’s implementation, enabling the effect of clicking the Import button to import from the DataTable into the configuration.

Property Customization in the Panel

In the previous section, we mentioned that we can create custom Slate controls in the property panel to achieve customized behavior. However, some requirements involve modifying the display of properties in the panel. For instance, in the property matching requirement for ResScannerUE:

  1. You can select the UClass type.
  2. Based on the selected UClass type, list the selectable properties, and dynamically create the value types of the selected properties to display in the panel.
  3. The property name-value is an array, with each element allowing a different property name and creating different value types.

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

Below is an introduction to the implementation. If it is merely about obtaining reflection data, it’s actually not difficult. However, displaying it on the property panel becomes more complex.

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

As mentioned earlier, the property panel binds a USTRUCT and creates properties. My initial thought was to dynamically modify the UStruct, but I found that is not very reasonable since the UStruct is also fixed and cannot change the element types in an array.

So, what other methods are there? After reviewing the engine’s code, I discovered that UE can also perform custom operations in the property panel for certain types of properties. The core of this is IPropertyTypeCustomization.

Its usage is similar to the IDetailCustomization interface, both providing an interface for custom property panel controls. However, there are slight differences in their creation forms:

  1. DetailCustomization needs to be specified when creating SettingView, while PropertyTypeCustomization does not require this.
  2. DetailCustomization only takes effect in its own created SettingView, while PropertyTypeCustomization can take effect wherever that property is created.

PropertyTypeCustomization is more flexible. Once the custom event is completed, it can correctly display in both SettingView and the structure instance created from a 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="Match Pattern")
EPropertyMatchRule MatchRule;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Property Name")
FString PropertyName;
UPROPERTY(EditAnywhere,BlueprintReadWrite,DisplayName="Value")
FString MatchValue;
};

Both PropertyName and MatchValue are strings, and the reason for using strings is that they can store values of any type. Subsequent property customization operations are targeted at the FPropertyMatchMapping structure type.

Thus, the implementation ideas for property customization are:

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

First, create the PropertyTypeCustomization class for the FPropertyMatchMapping type:

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, 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 the FPropertyMatchMapping type in the property panel, it will create an instance of FCustomPropertyMatchMappingDetails to execute the creation operation, thus shifting the control creation process of the specified class to our code.

Implementation of FCustomPropertyMatchMappingDetails:
In the CustomizeHeader function, you can directly create the property-value, but I did not find a method in the CustomizeHeader parameters to access the entire parent structure instance, thus unable to retrieve the selected UClass. If creation does not depend on contextual property-value, it can be created directly here.

However, since what I want to implement depends on the selected UClass’s value, I only created the property name in CustomizeHeader, while the value control creation was done in CustomizeChildren:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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() // Style of the property's Name
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Property Rule")))
];
}

In CustomizeHeader, I retrieved and stored the actual values of FPropertyMatchMapping instances (both PropertyName and Value are FString). The elements of this structure are placed into CustomizeChildren for creation because the IDetailChildrenBuilder passed into CustomizeChildren allows access to the parent structure to retrieve 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));
}

After obtaining the UClass, traverse 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
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();
}

This is how an SWidget is created through Slate and placed into the ValueContent container created by StructBuilder.AddCustomRow.

With the created SComboBox, you can select the property name in the editor, and through the UClass and property name, obtain the corresponding FProperty, which is the reflection information of the property. Therefore, when the name selected in the ComboBox is different, the obtained FProperty is also different, and the types it represents are also different. The next critical step is to dynamically create the corresponding types in the property panel based on the obtained FProperty:

First, a container to store value controls needs to be created 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>, and the controls created afterwards will be displayed using it.

Returning to the earlier discussion about creating value controls based on FProperty, the IDetailChildrenBuilder interface provides a function AddExternalStructureProperty, which allows adding a certain property of an external structure to the current Details.

For example, the CompressionSettings property of UTexture2D:

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

It is an enum, and I want to create it in a property panel that does not contain it and place it in the PropertyContent created earlier:

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);
}

mStructBuilder can be retrieved and stored in CustomizeChildren parameters.

The code above is crucial in implementing the creation of corresponding value type controls based on FProperty. By applying the UClass and property names, you can create the corresponding SWidget and place it in a specific container for display!

Using the selected property name from the ComboBox, you can retrieve its FProperty, and it can create the proper widget without including more code here due to space limit. If interested, you can check out the complete implementation in CustomPropertyMatchMappingDetails.cpp from ResScannerUE.

Generic Data Storage

Besides being able to display different properties in the panel, there also needs to be a general method to store them. Strings can be used to store all value information. UE also provides Value Widget methods to read string values and set values from strings:

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

With these two functions, all types of values can be stored and retrieved through 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.4k 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!