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:
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:
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:
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:
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:
class FCustomLayoutEditorModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};
C++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:
Now we can implement it:
// 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.
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++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:
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:
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++