How to best achieve nested panels in the blueprint editor

Hello everyone again!

I’ve been tinkering with a completely different part of the engine than last week and I became a bit curious of how to best design my classes to let them be easily edited from blueprint as this is something I really liked with Unity. More specifically, the ability to edit and view objects in the details pane in real time.

The specific case I’m fiddling around with now is a port of my inventory system from Unity. In my redesign to make it fit UE4’s design philosophy, I made my base inventory component and equipment components ActorComponents that I’ve attached to my actor root in the ctor. The equipment class then contains several ItemSlot objects that derive from UObject directly, and these itemslots can then host UObject derived Items that are blueprintable to make it easy to create lots of different instances that can be placed in any slot.

This seems to be working fine from hard code, but I found it a bit more tricky to make it look nice, and be easily editable from blueprint. Since the itemslots are the objects on the inventory, it is not possible to attached item blueprints directly in the editor. It would be nice if the header tool would recognize that the ItemSlot objects have UProperties themselves and create a nice nested pane in the BP editor. Instead, it seems like you have to create detail specializations for the ActorComponents and manually create these views.
My problem then becomes that the items that would need the specializing class (my itemslots) cannot be components since it doesn’t want to seem to compile if you try to nest ActorComponents and the examples I’ve found of IDetailCustomization all extend ActorComponents.

My next step I think will be to try to create IDetailCustomization specializations for both my itemslots and item base class but this solution does not feel very elegant.
Has someone else found a better way to design this?

I’ll post an excerpt of my code in the next post so you guys can have a look :slight_smile:

Best regards,
Temaran

EquipmentComponent.h:



UCLASS()
class UEquipmentComponent : public UActorComponent
{
	GENERATED_UCLASS_BODY()

public:
	//Items
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = EquipmentSlots)
		TSubobjectPtr<class UItemSlot> LeftHand;
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = EquipmentSlots)
		TSubobjectPtr<class UItemSlot> RightHand;
};


EquipmentComponent.cpp:



UEquipmentComponent::UEquipmentComponent(const class FPostConstructInitializeProperties& PCIP)
: Super(PCIP)
{
	LeftHand = PCIP.CreateDefaultSubobject<UItemSlot>(this, TEXT("LeftHand"));
	RightHand = PCIP.CreateDefaultSubobject<UItemSlot>(this, TEXT("RightHand"));
}


ItemSlot.h:



UCLASS()
class UItemSlot : public UObject
{
	GENERATED_UCLASS_BODY()

public:	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = SlotProperties)
		UItem* Item;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = SlotProperties)
		TEnumAsByte<EItemType::Type> ItemTypesRestriction;

	void DestroyItem();

	bool TryMovingItem(UItemSlot& TargetSlot);
	bool TrySplittingItem(UItemSlot& TargetSlot, int Amount);
	void Use(UCharacterStatus& UsingCharacter);
};


ItemSlot.cpp:



UItemSlot::UItemSlot(const class FPostConstructInitializeProperties& PCIP)
: Super(PCIP)
{
}

void UItemSlot::DestroyItem()
{
	Item = NULL;
}

bool UItemSlot::TryMovingItem(UItemSlot& TargetSlot)
{
	if (NULL == Item)
		return false;

	UItem* const TargetItem = TargetSlot.Item;
	if ((TargetSlot.ItemTypesRestriction != EItemType::None && Item->ItemType != TargetSlot.ItemTypesRestriction) ||
		(ItemTypesRestriction != EItemType::None && NULL != TargetItem && TargetItem->ItemType != ItemTypesRestriction))
		return false;

	if (NULL == TargetItem) //Just move the item
		TargetSlot.Item = Item;
	else if (TargetItem->MaximumStackSize > 1 && Item->MaximumStackSize > 1 && Item->Name == TargetItem->Name)
	{
		int PossibleIncrease = TargetItem->MaximumStackSize - TargetItem->StackSize;
		int ActualIncrease = FMath::Min(PossibleIncrease, Item->StackSize);
		TargetItem->StackSize += ActualIncrease;
		Item->StackSize -= ActualIncrease;

		if (Item->StackSize <= 0)
			DestroyItem();
	}
	else //Swap items
	{
		TargetSlot.Item = Item;
		Item = TargetItem;
	}

	return true;
}

bool UItemSlot::TrySplittingItem(UItemSlot& TargetSlot, int Amount)
{
	if (NULL != TargetSlot.Item)
		return false;

	if (Amount >= Item->StackSize)
		UE_LOG(LogNeuroSpace, Error, TEXT("Cannot split to equal or more than the existing stack size on item: %s"), *Item->Name.ToString());

	UItem* NewItem = NewNamedObject<UItem>(Item->GetOuter(), Item->Name);

	Item->StackSize -= Amount;
	NewItem->StackSize = Amount;

	return TryMovingItem(TargetSlot);
}

void UItemSlot::Use(UCharacterStatus& UsingCharacter)
{
	if (NULL != Item)
	{
		Item->Use(UsingCharacter);
		if (Item->StackSize <= 0)
			DestroyItem();
	}
}


Item.h:



UCLASS(Abstract)
class UItem : public UObject
{
	GENERATED_UCLASS_BODY()

public:
	EItemType::Type ItemType;
    int StackSize;
    int MaximumStackSize;
    FName Name;
    
	virtual void Use(UCharacterStatus& UsingCharacter);
};


Item.cpp:



UItem::UItem(const class FPostConstructInitializeProperties& PCIP)
: Super(PCIP)
{
	ItemType = EItemType::Misc;
	StackSize = 1;
	MaximumStackSize = 1;
	Name = "ItemToSplit";
}

void UItem::Use(UCharacterStatus& UsingCharacter)
{

}


Hehe, this is pretty cool, I still haven’t found any better way to do this, but it wasn’t very hard to move forward as there are a lot of good examples to draw inspiration from.

For anyone wondering the same thing as I did, I would recommend looking in particular at:
StreamingLevelCustomization.h / cpp as it contains just the right amount of code to get a good overview of what’s happening.

The IDetailCustomization system is just a slate driven system without all of the magical macros used by slate, that is, it uses slate syntax to define how you want the panes to look like but the header is very clean compared to a normal slate class.

The most important parts seem to be getting references to properties you want to modify, in this case



	TSharedPtr<IPropertyHandle> LevelTransformProperty = DetailLayoutBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(ULevelStreaming, LevelTransform));

	LevelPositionProperty = LevelTransformProperty->GetChildHandle("Translation");
	LevelRotationProperty = LevelTransformProperty->GetChildHandle("Rotation");


gets the LevelTransform property from the ULevelStreaming class this specializes and stores some child properties for use in slate later, and then after getting your property references it’s just plain old slate after that.

I can’t help but think that a default layout should be possible to generate though since my leaf properties are all just vanilla. Looking at the namespace where most of the customizations are though, it seems like all of the views in the details panel are manually crafted like this.

I’m guessing there must either have been a time constraint at work here or that there is some technical detail I have yet to understand why this isn’t automated yet. But in either case, I can only hope they add automatic generation of this in some future version of the engine.

Hmm, it seems a bit more complicated than I initially thought…

Simply having a details view is not enough to make your own details panel editor it seems.
I’m having trouble getting my custom detail view to get loaded. Stepping through SDetailvsViewBase.cpp:QueryCustomDetailLayout I find that my class is listed in the ClassesWithProperties set, but is apparently not in the InstancedClassToDetailLayoutMap which makes me always get the useless default layout.

I’m guessing I’m missing some registration somewhere, but looking at the other detailcustomizations, there doesn’t seem to be an obvious place to do this. There are registrations done in other places (for example the heavy-duty editor classes like Source/Editor/StaticMeshEditor etc.) but afaik those are for the open-up-a-new-window editors so it doesn’t seem right.

If I ever figure this out I’ll post a tutorial on the wiki, seems like one is kind of needed >_>

My current custom detailscustomization currently looks like this:

ItemSlotCustomization.h:



#pragma once

class FItemSlotCustomization : public IDetailCustomization
{
public:
	/** Makes a new instance of this detail layout class for a specific detail view requesting it */
	static TSharedRef<IDetailCustomization> MakeInstance();

	/** IDetailCustomization interface */
	virtual void CustomizeDetails( IDetailLayoutBuilder& DetailBuilder ) override;

private:
	FItemSlotCustomization();
};



ItemSlotCustomization.cpp:



#include "MyProj.h"

#include "Editor/DetailCustomizations/Private/DetailCustomizationsPrivatePCH.h"

#include "DetailCustomizations/ItemSlotCustomization.h"
#include "Items/Item.h"

#define LOCTEXT_NAMESPACE "ItemSlotCustomization"

FItemSlotCustomization::FItemSlotCustomization()
{
}

TSharedRef<IDetailCustomization> FItemSlotCustomization::MakeInstance()
{
	return MakeShareable( new FItemSlotCustomization );
}

void FItemSlotCustomization::CustomizeDetails( IDetailLayoutBuilder& DetailBuilder )
{
	// Create a category so this is displayed early in the properties
	IDetailCategoryBuilder& LulzCategory = DetailBuilder.EditCategory("LULZ", TEXT(""), ECategoryPriority::Important);

	LulzCategory.AddCustomRow(LOCTEXT("Item", "Item").ToString())
		.NameContent()
		
			SNew(STextBlock)
			.Text(LOCTEXT("Item", "Item"))
			.Font(IDetailLayoutBuilder::GetDetailFont())
		]
		.ValueContent().MinDesiredWidth(500)
		
			SNew(STextBlock)
			.Text(LOCTEXT("LOL", "LOL"))
			.Font(IDetailLayoutBuilder::GetDetailFont())
		];
}

#undef LOCTEXT_NAMESPACE


After debugging for another 30min or so, I found a class that looks promising;
DetailCustomizations.cpp

It’s basically a class with a lot of registrations to the class I was talking about.
I’m guessing these are the “base library” of registrations, since there appears to be some instances of other classes also using the registration method by requesting the propertyeditormodule that hosts it:

FPropertyEditorModule& ParentPlugin = FModuleManager::GetModuleChecked<FPropertyEditorModule>(“PropertyEditor”);
PropertyModule.RegisterCustomClassLayout( “BTDecorator_Blackboard”, FOnGetDetailCustomizationInstance::CreateStatic( &FBlackboardDecoratorDetails::MakeInstance ) );

I’m guessing this is the way to go. I’ll get back when I’ve played around a bit with it.

/Temaran

Yeah, that worked.

I’ll make a tutorial of this when I get home methinks… but now it’s time for work, baibai!

/Temaran

@Temaran any chance for tutorial?