Customizing Details & Property Type panel - tutorial

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!

Updated over 2 years ago Edit Page Revisions

Overview

The "Details panel" in the editor for an Actor

The intent of this page is to gives you the maximum basics details about "How to customizing your own objects" and gives you abilities to start customizing and dig in UE4 source code. Keep in mind that is not a full API documentation. Hope we can write it later...

Inspired from

Prerequisites

You need to master these elements first (or at least well understand their key principles):

Source codes

Every lines of codes has been written and testing on this repo: https://github.com/NansPellicari/UE4-HowTo-DetailsPanelCustomization so you can test all these next features in a glimpse.

Look at each commit! I separate each commit trying to get a step by step guide following the instructions bellow. This way you can check what lines of codes has been added at each step.

If you want to contribute on the repo you are very welcome!

Introduction

There are 2 kinds of cutomizations:

  1. Property Type Customization: it gives the ability of changing the layout and validate data of the UPROPERTY(ies) of an USTRUCT .

    >>> This works exclusively with an USTRUCT.

  2. Detail Customization: gives full control over the rendering of all parts of the pane (customizing the category box, creating new categories, do whatever you like in the categories etc.).

    >>> This works with any UObject or UStruct.

Setup

First we need to create a dedicated Editor Module.

Because it is a bit out of scope, I will try to go straight here, so if you need more details, please referer to this Wiki Link.

In our *.uproject files, add these lines:

It is exaclty the same process when you work with plugins, use *.uplugin instead.

{
	"FileVersion": 3,
	"EngineAssociation": "YourEngineVersion",
	"Category": "",
	"Description": "",
	"Modules": [
		{
			"Name": "MyGame",
			"Type": "Runtime",
			"LoadingPhase": "Default"
		},
		{
			"Name": "MyGameEditor",
			"Type": "Editor",
			"LoadingPhase": "PostEngineInit"
		}
	]
}

Create necessary folder Source\MyGameEditor\Public and Source\MyGameEditor\Private then create in Source\MyGameEditor\ the file MyGameEditor.build.cs with this content:

using UnrealBuildTool;

public class MyGameEditor : ModuleRules
{
	public MyGameEditor(ReadOnlyTargetRules Target) : base(Target)
	{
		PublicDependencyModuleNames.AddRange(
			new string[] {
				"Core",
				"CoreUObject",
				"Engine",
				"InputCore",
				"Slate",
				"SlateCore",
				"UnrealEd",
				"PropertyEditor",
				"MyGame",
			});

		PublicIncludePaths.AddRange(
			new string[]
			{
				"MyGameEditor/Public"
			});

		PrivateIncludePaths.AddRange(
			new string[]
			{
				"MyGameEditor/Private"
			});
	}
}

Note the specific requirements for customization: "Slate", "SlateCore", "UnrealEd" and "PropertyEditor"

Add our new module on our MyGameEditor.Target.cs file:

// ...
ExtraModuleNames.AddRange(
			new string[] {
				// ...
				"MyGameEditor"
			});
	}
// ...

Then create the Module definition files:

// Source\MyGameEditor\Public\MyGameEditor.h
#pragma once

#include "Engine.h"
#include "Modules/ModuleInterface.h"
#include "Modules/ModuleManager.h"
#include "UnrealEd.h"

class FMyGameEditorModule : public IModuleInterface
{
public:
	virtual void StartupModule() override;
	virtual void ShutdownModule() override;
};
// Source\MyGameEditor\Private\MyGameEditor.cpp
#include "MyGameEditor.h"

#include "Modules/ModuleInterface.h"
#include "Modules/ModuleManager.h"

IMPLEMENT_GAME_MODULE(FMyGameEditorModule, MyGameEditor);

#define LOCTEXT_NAMESPACE "MyGameEditor"

void FMyGameEditorModule::StartupModule() {}

void FMyGameEditorModule::ShutdownModule() {}

#undef LOCTEXT_NAMESPACE

Now we can regenerate our project files then build.

Create a basic USTRUCT, UENUM and a BP Actor

> USTRUCT

To create a Property Type Customization we need a USTRUCT to work with (as we've talking about above):

In case you are confused: this struct is located in MyGame source folder. The struct is used in the game, this is only its appearence in the editor we need to customize in our new Editor Module.

// Source\MyGame\MyStruct.h
USTRUCT(BlueprintType)
struct FMyStruct
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Amount")
	float Amount;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Range")
	float RangeMin;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Range")
	float RangeMax;
};

> UENUM

Why an enum? This enum will be used to change the customization's display in accordance with its value.

// Source\MyGame\MyStruct.h

UENUM(BlueprintType)
enum class EValueType : uint8
{
	AMOUNT UMETA(DisplayName = "Amount"),
	RANGE UMETA(DisplayName = "Range")
};

// Then add a field in the struct
struct FMyStruct
{
//...
public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Choice")
	EValueType Type = EValueType::AMOUNT;
//...
}

> Create a basic BP Actor

Create a Basic Blueprint Actor and add a field SwitchingValue...

of type FMyStruct...

then compile and we'll see this new setting in our details panels of the Actor:

>>> End of step 1 in the repo

Customization

Now everything will be done in our Editor module.

1. Property Type

> What can we customize?

(1) The Header

For now it is represented by our property's name (defined in the actor). It should be great to add a bit more details here to distinguished quickly what type of value has been selected.

(2) The Children

This part is dedicated to the list of properties of our Struct, this is where the most of our customization fit.

For the both parts (Children & Header), we can:

  • add events when values changes to customize behaviors

  • change or add slate widget to customize the style

> Create the customization class

Our class name shoulds represent the struct's name the customization was made for, add Customization appendix to the FMyStruct = FMyStructCustomization

Header file:

// Source\MyGameEditor\Public\Customization\MyStructCustomization.h

#pragma once

#include "PropertyEditor/Public/IPropertyTypeCustomization.h"

class FMyStructCustomization : public IPropertyTypeCustomization
{
public:
	/**
	 * It is just a convenient helpers which will be used
	 * to register our customization. When the propertyEditor module
	 * find our FMyStruct property, it will use this static method
	 * to instanciate our customization object.
	 */
	static TSharedRef<IPropertyTypeCustomization> MakeInstance();

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

Cpp file:

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

#include "Customization/MyStructCustomization.h"

TSharedRef<IPropertyTypeCustomization> FMyStructCustomization::MakeInstance()
{
	// Create the instance and returned a SharedRef
	return MakeShareable(new FMyStructCustomization());
}

void FMyStructCustomization::CustomizeHeader(TSharedRef<IPropertyHandle> StructPropertyHandle,
	class FDetailWidgetRow& HeaderRow,
	IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
	UE_LOG(LogTemp, Warning, TEXT("%s - The header customization is called"), ANSI_TO_TCHAR(__FUNCTION__));
	// Should customize here soon
}

void FMyStructCustomization::CustomizeChildren(TSharedRef<IPropertyHandle> StructPropertyHandle,
	class IDetailChildrenBuilder& StructBuilder,
	IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
	// Should customize here soon
}

> Register the customization for the editor

Now the customization exists, we can register (and unregister) it thanks to our module definition:

// Source\MyGameEditor\Private\MyGameEditor.cpp

// ...
// Add these lines in the header
#include "MyGame/MyStruct.h"
#include "Customization/MyStructCustomization.h"
#include "PropertyEditor/Public/PropertyEditorModule.h"

// ...
void FMyGameEditorModule::StartupModule()
{
	// import the PropertyEditor module...
	FPropertyEditorModule& PropertyModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
	// to register our custom property
	PropertyModule.RegisterCustomPropertyTypeLayout(
		// This is the name of the Struct (we can also use "MyStruct" instead)
		// this tells the property editor which is the struct property our customization will applied on.
		FMyStruct::StaticStruct()->GetFName(),
		// this is where our MakeInstance() method is usefull
		FOnGetPropertyTypeCustomizationInstance::CreateStatic(&FMyStructCustomization::MakeInstance));

	PropertyModule.NotifyCustomizationModuleChanged();
}

void FMyGameEditorModule::ShutdownModule()
{
	if (FModuleManager::Get().IsModuleLoaded("PropertyEditor"))
	{
		// unregister properties when the module is shutdown
		FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
		PropertyModule.UnregisterCustomPropertyTypeLayout("MyStruct");

		PropertyModule.NotifyCustomizationModuleChanged();
	}
}

Now we can regenerated our project files then build and restart the project

Unfortunatly, everytime you change your customizations you should close and reopen the unreal editor...😦 BTW Unreal Doc says:

"If this is an engine class, you should add your customization class (if it does not already exist) to the DetailCustomizations module. This module can be recompiled and reloaded without restarting the editor, making it useful for fast tweaking of properties."

We will not do that here (it is not an engine class), but keep it in mind to ease your further developments if you need so.

Now in your Actor BP, there is no more details shown for our SwitchingValue property. This because the customization is applied but we didn't implement anything yet:

But you can see in the logs panel:

>>> End of step 2 in the repo

> Implements the header customizations

We can be tempted to make all our changes here, but it can bring some troubles as:

  • weird style results in an array

  • can't display a nested customized struct UPROPERTY.

First, lets retrieve the default display of our header:

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

// Now we used them, we needs their headers
#include "PropertyEditor/Public/DetailLayoutBuilder.h"
#include "PropertyEditor/Public/DetailWidgetRow.h"
#include "PropertyEditor/Public/PropertyHandle.h"

// ...

void FMyStructCustomization::CustomizeHeader(TSharedRef<IPropertyHandle> StructPropertyHandle,
	class FDetailWidgetRow& HeaderRow,
	IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
	HeaderRow.NameContent()[StructPropertyHandle->CreatePropertyNameWidget()];
}

// ...

Ok great, the name comes back.

Now we'll need to add more usefull details.

First we need to get some configured style for the editor, so we have to add a dependency on the EditorStyle module:

// Source\MyGameEditor\Public\MyGameEditor.build.cs

PublicDependencyModuleNames.AddRange(new string[] {
	// ...
	"EditorStyle",
	"MyGame",
});

then we get the Type of the FMyStruct to display it :

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

// We need our struct and basics slate widgets now
#include "SlateBasics.h"
#include "MyGame/MyStruct.h"

// 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
}

// ...

#undef LOCTEXT_NAMESPACE

Tadaaam!!

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.

First upgrade our Type to a protected property and give it a more explicit name: ChosenType. At the same time we can write the definition of the bound function we want to attached to our event.

// Source\MyGameEditor\Public\Customization\MyStructCustomization.h

protected:
	FText ChosenTypeText;

	/**
	 * This method is bind to the SetOnPropertyValueChanged on the "Type" property.
	 * It retrieves the Type's value and store it to the "ChosenTypeText" property here.
	 */
	void OnTypeChanged(TSharedPtr<IPropertyHandle> TypePropertyHandle);

Next, add these changes on the cpp file:

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

// in CustomizeHeader()
{
	// ...
	// retrieve its value as a text to display
	OnTypeChanged(TypePropertyHandle);

	// attached an event when the property value changed
	TypePropertyHandle->SetOnPropertyValueChanged(
		FSimpleDelegate::CreateSP(this, &FMyStructCustomization::OnTypeChanged, TypePropertyHandle));
}

void FMyStructCustomization::OnTypeChanged(TSharedPtr<IPropertyHandle> TypePropertyHandle)
{
	if (TypePropertyHandle.IsValid() && TypePropertyHandle->IsValidHandle())
	{
		TypePropertyHandle->GetValueAsDisplayText(ChosenTypeText);
	}
}
// ...

Ok that seems good but something missing. The value is passed to the widget but we have to bind a function to the desired attributes (as we need to do in the editor for a blueprint Widget to have a refreshed data to display)...

In the editor: in the details panel of a widget.

with the Slate API we do:

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

// replace our Textblock with:
SNew(STextBlock)
.Font(FEditorStyle::GetFontStyle("PropertyWindow.NormalFont"))
// The MakeAttributeLambda do the trick. 
.Text(MakeAttributeLambda([=] { return FText::Format(LOCTEXT("ValueType", "The value type is \"{0}\""), ChosenTypeText); }))

Ok compile and restart the editor, header customizations are done!

>>> End of step 3 in the repo

> Implements the children customizations

Ok the most interesting parts are coming here. First we need to test a changing value of the Type to see if our header customization is working:

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

// add this line a the top
#include "IDetailChildrenBuilder.h"

// ...

void FMyStructCustomization::CustomizeChildren(TSharedRef<IPropertyHandle> StructPropertyHandle,
	class IDetailChildrenBuilder& StructBuilder,
	IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
	TSharedPtr<IPropertyHandle> TypePropertyHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, Type));

	StructBuilder.AddCustomRow(LOCTEXT("MyStructRow", "MyStruct"))
	[
		TypePropertyHandle->CreatePropertyValueWidget()
	];
}

// ...

After compiling we can change the Type and see the text changing:

A. Style

Now add some style for our children properties:

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

// ...

// in CustomizeChildren()
{
	// First we need to retrieve every Property handles
	TSharedPtr<IPropertyHandle> AmountPropertyHandle =
		StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, Amount));
	TSharedPtr<IPropertyHandle> LhRangePropertyHandle =
		StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, RangeMin));
	TSharedPtr<IPropertyHandle> RhRangePropertyHandle =
		StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, RangeMax));
	TSharedPtr<IPropertyHandle> TypePropertyHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FMyStruct, Type));

	// and check them before using them
	check(AmountPropertyHandle.IsValid() && LhRangePropertyHandle.IsValid() && RhRangePropertyHandle.IsValid() &&
		  TypePropertyHandle.IsValid());

	StructBuilder.AddCustomRow(LOCTEXT("MyStructRow", "MyStruct"))
	[
		SNew(SBorder)
		.BorderImage(FEditorStyle::GetBrush("ToolPanel.GroupBorder"))
		.BorderBackgroundColor(FLinearColor(255.f, 152.f, 0))
		.Content()
		[
			SNew(SWrapBox)
			.UseAllottedWidth(true)
			+SWrapBox::Slot()
			.Padding(5.f, 0.f)
			[
				SNew(SVerticalBox)
				+ SVerticalBox::Slot()
				.AutoHeight()
				[
					TypePropertyHandle->CreatePropertyNameWidget()
				]
				+ SVerticalBox::Slot()
				.AutoHeight()
				[
					TypePropertyHandle->CreatePropertyValueWidget()
				]
			]
			+SWrapBox::Slot()
			.Padding(5.f, 0.f)
			[
				SNew(SBox)
				.MinDesiredWidth(70.f)
				[
					SNew(SVerticalBox)
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						AmountPropertyHandle->CreatePropertyNameWidget()
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						AmountPropertyHandle->CreatePropertyValueWidget()
					]
				]
			]
			+SWrapBox::Slot()
			.Padding(5.f, 0.f)
			[
				SNew(SBox)
				.MinDesiredWidth(70.f)
				[
					SNew(SVerticalBox)
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						LhRangePropertyHandle->CreatePropertyNameWidget()
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						LhRangePropertyHandle->CreatePropertyValueWidget()
					]
				]
			]
			+SWrapBox::Slot()
			.Padding(5.f, 0.f)
			[
				SNew(SBox)
				.MinDesiredWidth(70.f)
				[
					SNew(SVerticalBox)
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						RhRangePropertyHandle->CreatePropertyNameWidget()
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						RhRangePropertyHandle->CreatePropertyValueWidget()
					]
				]
			]
		]
	];
}

Ok ok style is still a subjective taste... But now you probably better understand how far you can go == Sky is the limit 😵!!

>>> End of step 4 in the repo

B. Behaviors

As we planned when we created the UENUM, we want the properties edition depending on the Type chosen.

Thanks to the previously header's customization, we already listen an event when Type's value changes. We just have to push a bit further the implementation:

// Source\MyGameEditor\Public\Customization\MyStructCustomization.h

// move MyStruct inclusion here to have the "EValueType" available for the compiler
#include "MyGame/MyStruct.h"

// ...
protected:
	EValueType ChosenType;
// ...

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

// ...
// in OnTypeChanged()
// ...
TypePropertyHandle->GetValueAsDisplayText(ChosenTypeText);
uint8 ValueAsByte;
TypePropertyHandle->GetValue(ValueAsByte);
ChosenType = (EValueType) ValueAsByte;
// ...

Then add attribute IsEnabled for SBoxes wrapping the desired field:

// Source\MyGameEditor\Private\Customization\MyStructCustomization.cpp

// in CustomizeChildren()

// ...

// For the SBox wrapping AmountPropertyHandle
SNew(SBox)
.IsEnabled(MakeAttributeLambda([=] { return ChosenType == EValueType::AMOUNT; }))
// ...

// For the SBoxes wrapping LhRangePropertyHandle and RhRangePropertyHandle
SNew(SBox)
.IsEnabled(MakeAttributeLambda([=] { return ChosenType == EValueType::RANGE; }))
// ...

Compile & restart, children customizations are done!

When "Range" is chosen

When "Amount" is chosen

>>> End of step 5 in the repo

> BONUS: Nested Customization

If you want to call a customization inside your new customization without changing its style and behavior, you should call StructBuilder.GenerateStructValueWidget(MyPropertyCustomizedHandle.ToSharedRef()) (which return a slate widget) in your CustomizeChildren method.

This can't be done in CustomizeHeader (For now I don't know the reason why...)

2. Detail