Customizaing detail panel is used to display UObjects or structs that cannot use general schema of displaying on the detail panel. In unreal engine, custom property layout and custom class layout are not a same thing. Custom property layout means how a property displayed within an object detail panel, while custom class layout means how the detail panel of an object should be.

1. Introduction

While displaying properties of an object on the detail panel, we are familiar with the general schema of Unreal Engine, UE provides us the widget to display the value of the property and give us a method to change it:

How a float property is displayed in Unreal Engine generally

But some property are special, they do not follow the general schema, but use their own schema to fit their requirement and provide uses better experience, like the editor of FTransform, no matter when and where you declare a FTransform struct, it always displayed as the follow:

So why is this transform special? How can we implement our own? This is the topic of our post today, we ignore the class layout for now: How can we customizing our own property editor panel. In fact, there are too many examples of customizing property layout in Unreal Engine, the FBoneReference, CameraLensSettings etc.

2. Customizing Property Detail Panel

Imagine our mission: there is a struct FMyStruct, represents damage that game character causes to another game character, sometimes it is fixed like 20, 30 but sometimes it is a random number from a given range like 20~30, we create this struct to abstract it:

MyStruct.h
UENUM(Blueprintable, BlueprintType)
enum class EValueType : uint8
{
	Amount,
	Range
};

USTRUCT(Blueprintable, BlueprintType)
struct CUSTOMLAYOUT_API FMyStruct
{
	GENERATED_BODY()
public:

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	EValueType Type = EValueType::Amount;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float Amount;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float RangeMax;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float RangeMin;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, meta=(DisplayThumbnail))
	TObjectPtr<UTexture> Texture;
};
C++

The property texture here is only used for our tutorial here.

If there is nothing we do, we will get the result like:

To modify this, we need a class inherits from IPropertyTypeCustomization in our editor module:

MyStructCustomization.h
class CUSTOMLAYOUTEDITOR_API FMyStructCustomization : public IPropertyTypeCustomization
{
public:

	// This method is a short-cut method, it is not necessary.
	static TSharedRef<IPropertyTypeCustomization> MakeInstance();

	/** Begin IPropertyTypeCustomization */
	virtual void CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& CustomizationUtils) override;
	virtual void CustomizeChildren(TSharedRef<IPropertyHandle> PropertyHandle, IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils) override;
	/** End IPropertyTypeCustomization */
};
C++

The most important methods here are CustomizeHead and CustomizeChildren:

The simplest implmenetation is just print a log, if you do as followed you will see that there is nothing on the detail panel soon:

MyStructCustomization.cpp
TSharedRef<IPropertyTypeCustomization> FMyStructCustomization::MakeInstance()
{
  // This method is a short-cut method, it is not necessary.
	return MakeShareable(new FMyStructCustomization);
}

void FMyStructCustomization::CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow,
	IPropertyTypeCustomizationUtils& CustomizationUtils)
{
	UE_LOG(LogTemp, Log, TEXT("%hs - The header customization is called"), ANSI_TO_TCHAR(__FUNCTION__));
	// Should customize header here soon.
}

void FMyStructCustomization::CustomizeChildren(TSharedRef<IPropertyHandle> PropertyHandle,
	IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils)
{
	// Should customize body here soon.
	UE_LOG(LogTemp, Log, TEXT("%hs - The children customization if called"), ANSI_TO_TCHAR(__FUNCTION__))
}
C++

However, the costomization doesn’t work yet, we must register our customization for the class when startup the module:

CustomlayoutEditor.h
class FCustomLayoutEditorModule : public IModuleInterface
{
public:
    virtual void StartupModule() override;
    virtual void ShutdownModule() override;
};
C++
CustomLayoutEditor.cpp
void FCustomLayoutEditorModule::StartupModule()
{
    FPropertyEditorModule& PropertyEditorModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
    PropertyEditorModule.RegisterCustomPropertyTypeLayout(
    	FMyStruct::StaticStruct()->GetFName(),
    	FOnGetPropertyTypeCustomizationInstance::CreateStatic(&FMyStructCustomization::MakeInstance)
    );
	PropertyEditorModule.NotifyCustomizationModuleChanged();
}

void FCustomLayoutEditorModule::ShutdownModule()
{
    FPropertyEditorModule& PropertyEditorModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
	PropertyEditorModule.UnregisterCustomPropertyTypeLayout("MyStruct");
	PropertyEditorModule.NotifyCustomizationModuleChanged();
}

#undef LOCTEXT_NAMESPACE
C++

Restart the engine and you will get the result:

There is a type error, but it indicates that our methods worked!

Now we can implement it:

MyStructCustomization.cpp
// We will add new text data here, prepare them for i18n
#define LOCTEXT_NAMESPACE "MyGameEditor"

// ...
// in CustomizeHeader()
{
	// Get the property handler of the type property:
	TSharedPtr<IPropertyHandle> TypePropertyHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, Type));
	check(TypePropertyHandle.IsValid());

	// retrieve its value as a text to display
	FText Type;
	TypePropertyHandle->GetValueAsDisplayText(Type);

	// then change the HeaderRow to add some Slate widget
	// clang-format off
	HeaderRow.NameContent()[StructPropertyHandle->CreatePropertyNameWidget()]
	.ValueContent()[
		SNew(SHorizontalBox)
		+ SHorizontalBox::Slot()
		.AutoWidth()
		[
			SNew(STextBlock)
			.Font(FEditorStyle::GetFontStyle("PropertyWindow.NormalFont"))
			.Text(FText::Format(LOCTEXT("ValueType", "The value type is \"{0}\""), Type))
		]
	];
	// clang-format on
}
C++

But it is not sufficient for now, if the property value change, this widget will not be notified (so the display not updated). Although we still don’t managed the display for the value, we can already attached an event to the property to prevent this.

MyStructCustomization.h
class CUSTOMLAYOUTEDITOR_API FMyStructCustomization : public IPropertyTypeCustomization
{
protected:

	FText OnChosenTypeText;

	void OnTypeChanged(TSharedPtr<IPropertyHandle> TypePropertyHandle);

public:

	static TSharedRef<IPropertyTypeCustomization> MakeInstance();

	/** Begin IPropertyTypeCustomization */
	virtual void CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& CustomizationUtils) override;
	virtual void CustomizeChildren(TSharedRef<IPropertyHandle> PropertyHandle, IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils) override;
	/** End IPropertyTypeCustomization */
};
C++
MyStructCustomization.cpp
void FMyStructCustomization::OnTypeChanged(TSharedPtr<IPropertyHandle> TypePropertyHandle)
{
	if (TypePropertyHandle.IsValid() && TypePropertyHandle->IsValidHandle())
	{
		TypePropertyHandle->GetValueAsDisplayText(OnChosenTypeText);
	}
}

void FMyStructCustomization::CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow,
	IPropertyTypeCustomizationUtils& CustomizationUtils)
{
	UE_LOG(LogTemp, Log, TEXT("%hs - The header customization is called"), ANSI_TO_TCHAR(__FUNCTION__));
	// Should customize header here soon.
	TSharedPtr<IPropertyHandle> TypePropertyHandle = PropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, Type));
	check(TypePropertyHandle.IsValid());
	
	TypePropertyHandle->GetValueAsDisplayText(OnChosenTypeText);
	OnTypeChanged(TypePropertyHandle);

	HeaderRow.NameContent()[PropertyHandle->CreatePropertyNameWidget()]
	.ValueContent()
	[
		SNew(SHorizontalBox) + SHorizontalBox::Slot()
		.AutoWidth()
		[
			SNew(STextBlock)
			.Font(FAppStyle::GetFontStyle("PropertyWindow.NormalFont"))
			.Text(MakeAttributeLambda([this]()
			{
				return FText::Format(NSLOCTEXT("CustomLayout", "TypeValue", "The value is {0}"), OnChosenTypeText);
			}))
		]
	];

	TypePropertyHandle->SetOnPropertyValueChanged(FSimpleDelegate::CreateSP(this, &FMyStructCustomization::OnTypeChanged, TypePropertyHandle));
}
C++

After learning this, we can implement a complex function by composite out knowledge about Slate in last few posts and this post:

MyStructCustomization.cpp
void FMyStructCustomization::CustomizeChildren(TSharedRef<IPropertyHandle> PropertyHandle,
	IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils)
{
	// Should customize body here soon.
	UE_LOG(LogTemp, Log, TEXT("%hs - The children customization if called"), ANSI_TO_TCHAR(__FUNCTION__))
	const TSharedPtr<IPropertyHandle> TypePropertyHandle = PropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, Type));
	const TSharedPtr<IPropertyHandle> AmountPropertyHandle = PropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, Amount));
	const TSharedPtr<IPropertyHandle> RangeMinPropertyHandle = PropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, RangeMin));
	const TSharedPtr<IPropertyHandle> RangeMaxPropertyHandle = PropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, RangeMax));
	const TSharedPtr<IPropertyHandle> TexturePropertyHandle = PropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, Texture));

	if (TypePropertyHandle.IsValid())
	{
		ChildBuilder.AddCustomRow(NSLOCTEXT("CustomLayout", "MyStructRow", "MyStruct"))
		[
			SNew(SBorder)
			.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
			.BorderBackgroundColor(FLinearColor(255.0f, 125.0f, 10.0f, 0.2))
			.Content()
			[
				SNew(SWrapBox)
				.UseAllottedWidth(true)
				+SWrapBox::Slot()
				.Padding(5.0f, 5.0f)
				[
					SNew(SVerticalBox)
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						[
							TypePropertyHandle->CreatePropertyNameWidget()
						]
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						[
							TypePropertyHandle->CreatePropertyValueWidget()
						]
					]
				]
				+SWrapBox::Slot()
				.Padding(5.0f, 5.0f)
				[
					SNew(SVerticalBox)
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						[
							AmountPropertyHandle->CreatePropertyNameWidget()
						]
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						[
							AmountPropertyHandle->CreatePropertyValueWidget()
						]
					]
				]
				+SWrapBox::Slot()
				.Padding(5.0f, 5.0f)
				[
					SNew(SVerticalBox)
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						.IsEnabled(MakeAttributeLambda([this, TypePropertyHandle]()
						{
							uint8 EnumByte;
							TypePropertyHandle->GetValue(EnumByte);
							return static_cast<EValueType>(EnumByte) == EValueType::Range;
							
						}))
						[
							RangeMinPropertyHandle->CreatePropertyNameWidget()
						]
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						.IsEnabled(MakeAttributeLambda([this, TypePropertyHandle]()
						{
							uint8 EnumByte;
							TypePropertyHandle->GetValue(EnumByte);
							return static_cast<EValueType>(EnumByte) == EValueType::Range;
								
						}))
						[
							RangeMinPropertyHandle->CreatePropertyValueWidget()
						]
					]
				]
				+SWrapBox::Slot()
				.Padding(5.0f, 5.0f)
				[
					SNew(SVerticalBox)
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						.IsEnabled(MakeAttributeLambda([this, TypePropertyHandle]()
						{
							uint8 EnumByte;
							TypePropertyHandle->GetValue(EnumByte);
							return static_cast<EValueType>(EnumByte) == EValueType::Range;
							
						}))
						[
							RangeMaxPropertyHandle->CreatePropertyNameWidget()
						]
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						.IsEnabled(MakeAttributeLambda([this, TypePropertyHandle]()
						{
							uint8 EnumByte;
							TypePropertyHandle->GetValue(EnumByte);
							return static_cast<EValueType>(EnumByte) == EValueType::Range;
								
						}))
						[
							RangeMaxPropertyHandle->CreatePropertyValueWidget()
						]
					]
				]
			]
		];
		ChildBuilder.AddCustomRow(NSLOCTEXT("CustomLayout", "TestRow", "New Row"))
		[
			SNew(SBorder)
			.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
			.Content()
			[
				SNew(SWrapBox)
				.UseAllottedWidth(true)
				+SWrapBox::Slot()
				.Padding(5.0f, 5.0f)
				[
					SNew(SHorizontalBox)
					+ SHorizontalBox::Slot()
					.AutoWidth()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						[
							TexturePropertyHandle->CreatePropertyNameWidget()
						]
					]
					+ SHorizontalBox::Slot()
					.AutoWidth()
					[
						SNew(SBox)
						.MinDesiredWidth(70.0f)
						[
							TexturePropertyHandle->CreatePropertyValueWidget()
						]
					]
				]
			]
		];
	}
}
C++

Then we will get a beautiful widget on the detail panel, this even works nice for a TArray, the index name of the array change into your header:

And here is a little tip, by using a meta property that is not introduced in official documentation TitleProperty you can change to index name of the array in the editor, which allows you to modify your index name without implementing a complex editor module:

C++
USTRUCT(Blueprintable, BlueprintType)
struct FCoushu
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	FString Name{"Name"};
};

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Custom Layout", meta=(TitleProperty="Name"))
TArray<FCoushu> Array;
C++

References

Customizing Details & Property Type panel – tutorial | Unreal Engine Community Wiki
The details panel is used all over the editor. It is used to display property of UObject, Blueprint default, behavior tree node’s settings, project settings,… The editor gives default layouts for all of these, but sometimes you need to make things easier and more intuitive for game designer. It’s customizing time!
unrealcommunity.wiki

By JiahaoLi

Hypergryph - Game Programmer 2023 - Now Shandong University - Bachelor 2019-2023

Leave a Reply

Your email address will not be published. Required fields are marked *