Exposing templated properties to UPROPERTY
Hello everyone!
While working as a contractor for Stray Kite Studios I was tasked with exposing TOptional<PropertyType> to UPROPERTY for use in the details panel in Unreal Engine.
I learned a lot from exposing this and I wanted to share my knowledge with the community so that maybe you can also expose a templated property for your needs in your project.
This isn't possible without a custom engine, so if you're hoping to do this solely in a plugin you wouldn't be able to do this.
Before I continue, it's worth mentioning that we only supported the details panel, we never added blueprint-graph support, or allowing the property to be created in the blueprints variables panel. We only needed this to work in CPP classes and show up in details panels and therefore never ventured further. If you want to create a custom property and expose it and have it be blueprint supported you will need to add additional support.
The last version we supported was 5.0 so the code used may be slightly outdated, however the general concept of how to add your own (templated) properties to UPROPERTY still remains the same.
Epic Games commit is here: https://github.com/EpicGames/UnrealEngine/commit/cc9a77a3996ad42a33c65ca3742f0584c29aacdb
Why exposing TOptional?
When working with a lot data, like we did in the project we worked on for Stray Kite Studios, we had a lot of optional data that had no good "invalid" value as a lot of properties had any possible value of that property type still as a "valid" value, such as -1 still being valid.
This is where TOptional comes in, however we made our own custom TScriptOptional and i'll go into detail later as to why.
Now, you could do this with the following, already-supported, code for UPROPERTY:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Overrides, meta=(PinHiddenByDefault, InlineEditConditionToggle))
uint8 bOverride_FilmGrainIntensity:1;
UPROPERTY(interp, BlueprintReadWrite, Category = "Film Grain", meta = (UIMin = "0.0", UIMax = "1.0", editcondition = "bOverride_FilmGrainIntensity"))
float FilmGrainIntensity;
The above code works great, you can set a InlineEditConditionToggle, which will conditionally enable or disable editing the bottom property. However, this means two single unconnected properties, which makes it prone to forgetting to check that edit condition as well as double the size of your header if you are using a lot of those.
TScriptOptional allows you to, in one property, have the edit condition and value without having to add two UPROPERTIES, and without being able to forget checking if the condition property was set.
Primary concepts
There are 3 concepts, or "things", that you need to do in order to support exposing a property.
- Create the underlying FProperty for your property type
- Update UBT to generate generated code for your FProperty
- Add support in the engine
Creating the Property
To create the property I closely followed how FArrayProperty, FMapProperty and FFieldPathProperty implemented their properties.
class COREUOBJECT_API FOptionalProperty : public FProperty
{
DECLARE_FIELD(FOptionalProperty, FProperty, CASTCLASS_FOptionalProperty)
protected:
FProperty* Inner;
The FOptionalProperty holds a pointer to the inner value property, which is used for functions such as:
void ExportTextItem(....);
void ImportText_Internal(...);
These functions export the property value to and from text for purposes such copy and pasting property values in the details panel.
To make my life easier (for some part) and getting the alignment and size of the property correct I created a templated child of my FOptionalProperty, TOptionalProperty<PropertyValueType>.
This templated class allowed me to use template code to more easily handle the data of the underlying property without having to add additional code to ensure the data I copy/move/clear has the correct alignment and sizes.
Using a templated FProperty however was going to prove a bit annoying when it comes to UBT/UHT, however this ended up being possible.
You will also need to add a new cast flag in ObjectMacros.h, just make sure it doesn't override another cast class flag:CASTCLASS_FOptionalProperty   = 0x0100000000000000,
TScriptOptional
Above I mentioned I created TScriptOptional, rather than using TOptional for exposing to UPROPERTY. Â This allows more designer-friendly experiences without potential data loss.
TOptional clears the value when you toggle off the condition. This means that if a designer would toggle off the optional property, the underlying data would be wiped. So if they later wanted to turn it on again, they would have to set that data up again.
TScriptOptional is mostly the implementation of TOptional but without the data value being reset.
Worth noting that Unreal Engine uses TTypeCompatibleBytes<Type> for their value data while I directly use Type as value type, this was in part possible due to the fact I generate a templated FProperty at UBT/UHT generation time.
UBT
During my first implementation in UE4.27 the generated header code was still generated by the now-deprecated standalone UHT (Unreal Header Tool). This involved a lot more custom edits in engine code to support a new property. However, with UBT (Unreal Build Tool), adding a new supported UPROPERTY was relatively straight forward with no custom edits to UBT needed.
Going forward I refer to UBT/UHT as "UBT" for simplicity.
I created a new file called UhtOptionalProperty.cs inside Engine\Source\Programs\Shared\EpicGames.UHT\Types\Properties
Here again I closely followed how TArray, TMap and TFieldPath implemented their templated code.
The main difference here is that I had a templated FProperty, which means I had to add the creation of this optional FProperty inside the code generation functions:
public override StringBuilder AppendMemberDef(StringBuilder builder, IUhtPropertyMemberContext context, string name, string nameSuffix, string? offset, int tabs)
{
builder.AppendMemberDef(InnerProperty, context, name, GetNameSuffix(nameSuffix, "_Inner"), "0", tabs);
AppendMemberDefStart(builder, context, name, nameSuffix, offset, tabs, "FOptionalPropertyParams", "UECodeGen_Private::EPropertyGenFlags::OptionalProperty");
builder.Append("new TOptionalProperty<");
InnerProperty.AppendText(builder, UhtPropertyTextType.Generic);
builder.Append(">(nullptr, TEXT(\"")
.Append(name)
.Append("\"), ")
.Append(ObjectFlags)
.Append(", ");
builder.Append("STRUCT_OFFSET(").Append(context.OuterStructSourceName).Append(", ").Append(SourceName).Append(")), ");
AppendMemberDefEnd(builder, context, name, nameSuffix);
return builder;
}
I adjusted the member definition to construct the TOptionalProperty inside the generated header code, rather than constructing the property later in the engine-side of this blog post.
Note the "FOptionalPropertyParams" in the member definition, this is a struct that is used to pass information from generated header code to the FProperty and you will need to make your own later in the engine-side of the blog post.
To allow UBT to generate your new property you will need to tell UBT what to expect below UPROPERTY:
[UhtPropertyType(Keyword = "TScriptOptional")]
[SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Attribute accessed method")]
[SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Attribute accessed method")]
private static UhtProperty? OptionalProperty(UhtPropertyResolvePhase resolvePhase, UhtPropertySettings propertySettings, IUhtTokenReader tokenReader, UhtToken matchedToken)
{
using UhtMessageContext tokenContext = new("TScriptOptional");
if (!tokenReader.SkipExpectedType(matchedToken.Value, propertySettings.PropertyCategory == UhtPropertyCategory.Member))
{
return null;
}
tokenReader.Require('<');
UhtProperty? value = UhtPropertyParser.ParseTemplateParam(resolvePhase, propertySettings, propertySettings.SourceName, tokenReader);
if (value == null)
{
return null;
}
tokenReader.Require('>');
return CreateOptionalProperty(propertySettings, tokenReader, value);
}
The above code checks for TScriptOptional and requires a "<" to follow, then it tries to parse a template parameter, aftewards it requires a ">" to close the property definition.
Engine-side implementation
The engine requires a few custom edits to allow using the TScriptOptional, simply creating the property and telling UBT how to parse and generate the property isn't enough to support it showing up in details panels.
Core
Inside UObjectGlobals.h you can add your FOptionalPropertyParams
:
struct FOptionalPropertyParams // : FPropertyParamsBaseWithOffset
{
const char* NameUTF8;
const char* RepNotifyFuncUTF8;
EPropertyFlags PropertyFlags;
EPropertyGenFlags Flags;
EObjectFlags ObjectFlags;
int32 ArrayDim;
int32 Offset;
FProperty* GeneratedProperty;
#if WITH_METADATA
const FMetaDataPairParam* MetaDataArray;
int32 NumMetaData;
#endif
};
This struct is used to pass information from generated header code down to the property so the created property has the required information to handle the value of the property. If you need additional information for your property to be passed down from the UPROPERTY generation you can do that here.
On the .cpp side of this file you need to add code to handle this struct/property type.
case EPropertyGenFlags::OptionalProperty:
{
const FOptionalPropertyParams* Prop = (const FOptionalPropertyParams*)PropBase;
NewProp = Prop->GeneratedProperty;
NewProp->PropertyFlags = Prop->PropertyFlags;
NewProp->Owner = Outer;
NewProp->Init();
// Next property is the optional inner
ReadMore = 1;
#if WITH_METADATA
MetaDataArray = Prop->MetaDataArray;
NumMetaData = Prop->NumMetaData;
#endif
}
break;
This code is closer related to UE4 implementation than UE5 implemenation as the initial implementation was done in UE4 and I opted to stick to the UE4 implementation to keep my ability to use a templated FProperty.
The above code uses the GeneratedProperty from the struct created in the UBT section of this blog post, rather than constructing the new FProperty in this function directly.
You also see a EPropertyGenFlags::OptionalProperty in the above code, you will also need to add your property type to that enum and use that type here as well as in the UBT section. OptionalProperty  = 0x21
Details panel
You will need to create a new FPropertyNode for optional property, as well as add a new FPropertyHandle for the optional property.
Property node
First make sure the FOptionalProperty is expandable in ItemPropertyNode.cpp:
bool bExpandableType = CastField<FStructProperty>(MyProperty)
|| CastField<FArrayProperty>(MyProperty) || CastField<FSetProperty>(MyProperty) || CastField<FMapProperty>(MyProperty)
// Add this v
|| CastField<FOptionalProperty>(MyProperty)
// Add this ^
;
This will mark the property as needing expansion so we can add our inner property node in InitChildNodes in ItemPropertyNode.cpp:
Add your property CastField at the bottom of all the casts:
FOptionalProperty* OptionalProperty = CastField<FOptionalProperty>(MyProperty);
Then add your if statement alongside the if statements below:
else if( OptionalProperty )
{
void* Optional = NULL;
FReadAddressList Addresses;
if ( GetReadAddress(!!HasNodeFlags(EPropertyNodeFlags::SingleSelectOnly), Addresses ) )
{
Optional = Addresses.GetAddress(0);
}
if( Optional )
{
TSharedPtr<FItemPropertyNode> NewItemNode( new FItemPropertyNode );
FPropertyNodeInitParams InitParams;
InitParams.ParentNode = SharedThis(this);
InitParams.Property = OptionalProperty->GetInner();
InitParams.ArrayOffset = 0;
InitParams.ArrayIndex = 0;
InitParams.bAllowChildren = true;
InitParams.bForceHiddenPropertyVisibility = bShouldShowHiddenProperties;
InitParams.bCreateDisableEditOnInstanceNodes = bShouldShowDisableEditOnInstance;
NewItemNode->InitNode( InitParams );
NewItemNode->SetDisplayNameOverride(FText::FromString("Value")); // Value name, otherwise it'll show 0 from "index 0", but we're not an array so that's counterintuitive
AddChildNode(NewItemNode);
}
}
Once again the implementation closely follows that of FArrayProperty, etc.
Property handle
Next you will need to make a property handle, this class sets and gets the property values from and to the details panel from the FProperty:
class FPropertyHandleOptionalProperty : public FPropertyHandleBase
{
public:
FPropertyHandleOptionalProperty(TSharedRef<FPropertyNode> PropertyNode, FNotifyHook* NotifyHook, TSharedPtr<IPropertyUtilities> PropertyUtilities);
static bool Supports(TSharedRef<FPropertyNode> PropertyNode);
virtual FPropertyAccess::Result GetValue(bool& OutValue) const override;
virtual FPropertyAccess::Result GetValueAsDisplayText(FText& OutValue) const override;
virtual FPropertyAccess::Result GetValueAsFormattedText(FText& OutValue) const override;
virtual FPropertyAccess::Result SetValue(bool const& InValue, EPropertyValueSetFlags::Type Flags = EPropertyValueSetFlags::DefaultFlags) override;
};
To make the details panel use this property handle, add support for it in PropertyEditorHelpers.cpp in GetPropertyHandle(...)
else if (FPropertyHandleOptionalProperty::Supports(PropertyNode))
{
PropertyHandle = MakeShareable(new FPropertyHandleOptionalProperty(PropertyNode, NotifyHook, PropertyUtilities));
}
Just make sure to implement the Supports(PropertyNode) function of the handle you made.
bool FPropertyHandleOptionalProperty::Supports(TSharedRef<FPropertyNode> PropertyNode)
{
FProperty* Property = PropertyNode->GetProperty();
if (Property == nullptr)
{
return false;
}
return Property->IsA<FOptionalProperty>();
}
Here is how I get the value of the optional property handle:
FPropertyAccess::Result FPropertyHandleOptionalProperty::GetValue(bool& OutValue) const
{
void* PropValue = nullptr;
FPropertyAccess::Result Res = Implementation->GetValueData(PropValue);
if (Res == FPropertyAccess::Success)
{
FProperty* Property = GetProperty();
check(Property->IsA(FOptionalProperty::StaticClass()));
const FScriptOptional* Optional = (const FScriptOptional*)(PropValue);
OutValue = Optional->bIsSet;
return FPropertyAccess::Success;
}
return FPropertyAccess::Fail;
}
It's worth noting that this handle only takes care of the outer optional property, it does not handle the inner property value, so getting the value as a bool here only returns the value of the condition.
Slate
In order to visually allow the designers to tick and untick the condition, and set the value, you need to add a custom property slate editor widget. In my case this is a simple SCompoundWidget with a SCheckBox
class SPropertyEditorOptionalProperty : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SPropertyEditorOptionalProperty) {}
SLATE_END_ARGS()
static bool Supports( const TSharedRef< class FPropertyEditor >& InPropertyEditor );
void Construct( const FArguments& InArgs, const TSharedRef< class FPropertyEditor >& InPropertyEditor );
protected:
void OnCheckStateChanged( ECheckBoxState InNewState );
ECheckBoxState OnGetCheckState() const;
private:
TSharedPtr< class FPropertyEditor > PropertyEditor;
TSharedPtr< class SWidget > PrimaryWidget;
TSharedPtr< class SCheckBox > CheckBox;
};
Constructing the widget is then:
void SPropertyEditorOptionalProperty::Construct(const FArguments& InArgs, const TSharedRef<FPropertyEditor>& InPropertyEditor)
{
PropertyEditor = InPropertyEditor;
static const FName DefaultForegroundName("DefaultForeground");
CheckBox = SNew(SCheckBox).OnCheckStateChanged( this, &SPropertyEditorOptionalProperty::OnCheckStateChanged )
.IsChecked( this, &SPropertyEditorOptionalProperty::OnGetCheckState )
.ForegroundColor( FEditorStyle::GetSlateColor(DefaultForegroundName) )
.Padding(0.0f);
const TSharedRef< IPropertyHandle > PropertyHandle = PropertyEditor->GetPropertyHandle();
ChildSlot
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
[
CheckBox.ToSharedRef()
]
];
if( InPropertyEditor->PropertyIsA( FObjectPropertyBase::StaticClass() ) )
{
// Object properties should display their entire text in a tooltip
PrimaryWidget->SetToolTipText( TAttribute<FText>( InPropertyEditor, &FPropertyEditor::GetValueAsText ) );
}
}
And using the property handle I get the checked/unchecked state:
ECheckBoxState SPropertyEditorOptionalProperty::OnGetCheckState() const
{
ECheckBoxState ReturnState = ECheckBoxState::Undetermined;
bool Value;
const TSharedRef< IPropertyHandle > PropertyHandle = PropertyEditor->GetPropertyHandle();
if( PropertyHandle->GetValue( Value ) == FPropertyAccess::Success )
{
if( Value == true )
{
ReturnState = ECheckBoxState::Checked;
}
else
{
ReturnState = ECheckBoxState::Unchecked;
}
}
return ReturnState;
}
Just like before with the property node and handle you need to tell the details panel to use this widget in PropertyEditorHelpers.cpp in ConstructPropertyEditorWidget(...):
else if ( SPropertyEditorOptionalProperty::Supports(PropertyEditorRef) )
{
PropertyWidget = SNew( SPropertyEditorOptionalProperty, PropertyEditorRef );
}
If you also intend to use this property in a TableRow then I suggest you also add it to SPropertyEditorTableRow.cpp as this class handles its own property widgets itself.
In short
In short, there's quite a few things you need to add to the engine to support adding a new property, however, once you know where to look and what all to add it's quite possible.
A short list of all the things you need to do:
- Create the custom FProperty for your property
- Tell UBT to look for your property type
- Tell UBT how to generate your FProperty
- Create a
FOptionalPropertyParams
(named to your property type) - Check for
FOptionalPropertyParams
inside UObjectGlobals.h - Add
OptionalProperty  = 0x21
(0x21 may change in the future if Epic Games adds new properties, just add your last) - Add a new cast flag for your property
- Make sure your property type is expandable in ItemPropertyNode.cpp
- Use your property type to expand your inner property in InitChildNodes
- Create your own FPropertyHandle for your property
- Tell Unreal Engine to use your property handle
- Create your widget representation of your custom property
- Tell Unreal Engine to use your custom widget for your custom property
There's more small steps that would be required, however these are the most important parts you have to do.
I also want to mention that nothing I did is the way to add custom property support. I am sure that some things I did are not fully correct or can be done better or more efficiently.
I am also closely looking at Unreal Engine's native implementation of their TOptional support in order to see what I could do different in the future should I ever have to expose another new property to UPROPERTY.