Okay, so here is my final solution:
Big thanks @Everynone for the suggestion and pointing me in the right direction.
To give some context, I have an inventory system where:
- Inventory components contain an Inventory UObject
- Inventory UObjects contain an Inventory FFastArraySerializer
- Inventory FFastArraySerializers contain a TArray of Inventory FFastArraySerializerItems
- Inventory FFastArraySerializerItems contain an item instance and a TArray of Inventory IDs, however I only use 1 currently
- Item instances contain Item data assets which do contain UI data like what image and brush to use
- Inventory IDs contain an int32, the int32 is the location of the item inside the inventory
With this inventory system, it is possible to locate and grab an inventory item from the inventory component using an int32.
Part 1, UI CREATION:
Firstly, I created the main inventory UserWidget with a Uniform grid panel, see here:
Then I created a C++ base class for an Item slot user widget that contained an ID member variable and “InitIDFromObjectName” that sets the ID based off of the name of the object. See here: (Using common UI here)
UCLASS()
class ORGANISEMYBAGS_API UInventorySlotWidget : public UCommonActivatableWidget
{
public:
GENERATED_BODY()
// Implies that the the number is after an "_"
UFUNCTION(BlueprintCallable)
void InitIDFromObjectName();
protected:
UPROPERTY(EditAnywhere, BlueprintGetter = GetID, BlueprintSetter = SetID)
int32 ID = -1;
public:
UFUNCTION(BlueprintGetter)
FORCEINLINE int32 GetID() const { return ID; }
UFUNCTION(BlueprintSetter)
FORCEINLINE void SetID(int32 InID) { ID = InID; }
};
I used sub string functions from the Kismet string library to find an underscore in the name of the object, then, starting from the index after the underscore I try to convert the string, that is up to 6 characters, into a number, see here:
#include "Kismet/KismetStringLibrary.h"
void UInventorySlotWidget::InitIDFromObjectName()
{
int32 StartIndex = UKismetStringLibrary::FindSubstring(GetName(), TEXT("_")) + 1;
SetID(FCString::Atoi(*UKismetStringLibrary::GetSubstring(GetName(), StartIndex, 6)));
}
I then created a widget blueprint based of that C++ class, see here:
I populated the main inventory UserWidget’s Uniform grid panel with these item slots, see here:
Part 2, GRID WIDGET COMPONENT:
After all the UI was created I made a new C++ class based off of the Widget component, see here:
On this component I store a pointer to a panel widget, which in the case of the UI created earlier, means it will point to a Uniform Grid panel, see here:
UPROPERTY()
TObjectPtr<UPanelWidget> GridWidget = nullptr;
I also have a flag that is stored on the component that is set to true by a public function called “TriggerUpdateToInventory”, see here:
UPROPERTY()
uint8 bUpdateInventory : 1;
public:
UFUNCTION(BlueprintCallable)
void TriggerUpdateToInventory();
void UGridWidgetComponent::TriggerUpdateToInventory()
{
bUpdateInventory = true;
}
Then In order to update the UI I have an “UpdateToInventory” function that takes in an inventory component, see here:
UFUNCTION(BlueprintCallable)
void UpdateToInventory(UBaseInventoryComponent* InInventoryComponent);
The way this function works is that it gets the world location of item slots inside the inventory UI and either creates or moves widget components to those locations.
It then also adds box components to those locations so that later they can be traced against to grab the widget at that location. After there has been a valid attempt to update the UI, the update inventory flag is set to false.
Massive help from @Caracole_1 with this post in getting the locations of elements inside a widget being drawn by a widget component. The only note is do not use relative scale, world scale is the way to go because like in my case the widget component may be a child of another component.
Here are the member variables used in the function and the function definition itself:
UPROPERTY(EditAnywhere)
TSubclassOf<UUserWidget> GridItemClass = nullptr;
UPROPERTY(EditAnywhere)
FVector2D GridItemDrawSize = FVector2D(220.f, 220.f);
UPROPERTY(EditAnywhere)
FVector GridItemBoxExtents = FVector(20.f, 120.f, 120.f);
UPROPERTY(EditAnywhere)
uint8 bDebugGridItemBoxComponents : 1;
UPROPERTY()
TMap<int32, UWidgetComponent*> AddedWidgetComponents;
UPROPERTY()
TMap<int32, FTransform> IDTransforms;
UPROPERTY()
TMap<UPrimitiveComponent*, int32> GridItemSlotColliders;
void UGridWidgetComponent::UpdateToInventory(UBaseInventoryComponent* InInventoryComponent)
{
// Check if slate application is initialised
if (FSlateApplication::IsInitialized())
{
// Check that both the inventory component and grid widget are valid
if (IsValid(InInventoryComponent) && IsValid(GridWidget))
{
// If they are then we want to get the Grid widget's geometry
const FGeometry& GridGeometry = GridWidget->GetTickSpaceGeometry();
// and a default geometry to compare it to
FGeometry DefaultGeometry = FGeometry();
// If the grid widget geometry's local size is the same as geometry that doesn't have data then we are properly rendering
// the grid widget yet and therefore shouldn't enter this code
if (USlateBlueprintLibrary::GetLocalSize(GridGeometry) != USlateBlueprintLibrary::GetLocalSize(DefaultGeometry))
{
// If we pass that check get the widget component's draw size
const FVector2D& CachedDrawSize = GetDrawSize();
// Move it into an FVector instead of an FVector2D
FVector DrawSize3D = FVector(0.f, CachedDrawSize.X, CachedDrawSize.Y);
// Multiply it by this component's world scale
FVector ScaledDrawSize3D = DrawSize3D * GetComponentScale();
// Then shift it to half (not actually sure why this is done but it works)
FVector ShiftedDrawSize3D = ScaledDrawSize3D * 0.5f;
// Finally, find the top left location of the widget this component is drawing from the current location shifted by the shifted draw size
FVector TopLeftWidgetWorldLocation = GetComponentLocation() + (GetUpVector() * ShiftedDrawSize3D.Z) + (GetRightVector() * ShiftedDrawSize3D.Y);
// Loop through all the slots inside of the grid widget
for (const UPanelSlot* Slot : GridWidget->GetSlots())
{
// If the slot is valid
if (IsValid(Slot))
{
// Check that it is an inventory slot
UInventorySlotWidget* InventorySlot = Cast<UInventorySlotWidget>(Slot->Content);
if (IsValid(InventorySlot))
{
// If it is make sure it's ID is set by calling the Init ID
InventorySlot->InitIDFromObjectName();
// Then get this ID
const int32 CurrentID = InventorySlot->GetID();
// and use it to find an item inside of the inventory component
FInventoryItem FoundItem;
InInventoryComponent->GetItemFromID(FoundItem, CurrentID);
// Declare a widget and box component
UWidgetComponent* CurrentWidgetComponent = nullptr;
UBoxComponent* CurrentBoxComponent = nullptr;
// and try to find if a widget component already exists at the current ID
UWidgetComponent** WidgetPtr = AddedWidgetComponents.Find(CurrentID);
if (WidgetPtr != nullptr)
{
// If it does set current widget component to it
CurrentWidgetComponent = *WidgetPtr;
}
else
{
// but if it doesn't then create a new widget component with the ID number in the name
FName CurrentWidgetComponentName = FName(*FString::Printf(TEXT("GridItemWidgetComponent_%i"), CurrentID));
CurrentWidgetComponent = NewObject<UWidgetComponent>(this, CurrentWidgetComponentName);
if (IsValid(CurrentWidgetComponent))
{
// Then register and set it up if it was successfully created
CurrentWidgetComponent->RegisterComponent();
CurrentWidgetComponent->AttachToComponent(this, FAttachmentTransformRules::KeepRelativeTransform);
CurrentWidgetComponent->CreationMethod = EComponentCreationMethod::Instance;
CurrentWidgetComponent->SetMaterial(0, GetMaterial(0));
CurrentWidgetComponent->SetDrawSize(GridItemDrawSize);
CurrentWidgetComponent->SetCastShadow(CastShadow);
CurrentWidgetComponent->SetAffectDistanceFieldLighting(bAffectDistanceFieldLighting);
CurrentWidgetComponent->SetAffectDynamicIndirectLighting(bAffectDynamicIndirectLighting);
}
// and add it to the added widget components map
AddedWidgetComponents.Add(CurrentID, CurrentWidgetComponent);
// Then because there wasn't a widget here a box component must also be created
FName CurrentBoxComponentName = FName(*FString::Printf(TEXT("BoxComponent_%i"), CurrentID));
CurrentBoxComponent = NewObject<UBoxComponent>(this, CurrentBoxComponentName);
if (IsValid(CurrentBoxComponent))
{
// regiter and set it up if created successfully
CurrentBoxComponent->RegisterComponent();
CurrentBoxComponent->AttachToComponent(this, FAttachmentTransformRules::KeepRelativeTransform);
CurrentBoxComponent->CreationMethod = EComponentCreationMethod::Instance;
CurrentBoxComponent->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);
CurrentBoxComponent->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block);
CurrentBoxComponent->SetBoxExtent(GridItemBoxExtents);
CurrentBoxComponent->SetHiddenInGame(!bDebugGridItemBoxComponents);
}
// and add it to the grid item colliders
GridItemSlotColliders.Add(CurrentBoxComponent, CurrentID);
}
// Now there should be a widget component
if (IsValid(CurrentWidgetComponent))
{
// and the inventory slot should have a cached widget
TSharedPtr<SWidget> SafeWidgetSlot = InventorySlot->GetCachedWidget();
if (SafeWidgetSlot.IsValid())
{
// which if both are true, get the inventory slot's geometry
const FGeometry& Geometry = InventorySlot->GetTickSpaceGeometry();
// then get the local size
FVector2D LocalSize = USlateBlueprintLibrary::GetLocalSize(Geometry);
// half it
FVector2D HalfLocalSize = LocalSize / 2.f;
// and turn it to an absolute size
FVector2D AbsoluteSize = USlateBlueprintLibrary::LocalToAbsolute(Geometry, HalfLocalSize);
// scale the absolute size by the component world scale and multiply that by negative 1
FVector ScaledAbsoluate = FVector(0.f, AbsoluteSize.X, AbsoluteSize.Y) * GetComponentScale() * -1;
// Finally, get the world location of the item slot widget from this widget component's widget top left location and the scaled size of
// the item slot widget
FVector WidgetWorldLocation = TopLeftWidgetWorldLocation + (GetRightVector() * ScaledAbsoluate.Y) + (GetUpVector() * ScaledAbsoluate.Z);
// Then set the current widget component's location to the item slot widget's location but shift it forward a little bit
CurrentWidgetComponent->SetWorldLocation(WidgetWorldLocation + (GetForwardVector() * 1.f));
// Try and get the Transform at this ID
FTransform& IDTransform = IDTransforms.FindOrAdd(CurrentID);
// and set it to the current widget component's relative transform
IDTransform = CurrentWidgetComponent->GetRelativeTransform();
// Then just set the current box component's location to the item slot's locaton
if (IsValid(CurrentBoxComponent))
{
CurrentBoxComponent->SetWorldLocation(WidgetWorldLocation);
}
}
// If there was an item at the ID
if (IsValid(FoundItem.ItemInstance))
{
// Check whether there is a widget to use and if there isn't make one
if (!IsValid(CurrentWidgetComponent->GetWidget()))
{
CurrentWidgetComponent->SetWidgetClass(GridItemClass);
}
// Check that the item at the ID has a data asset
if (IsValid(FoundItem.ItemInstance->GetDataAsset()))
{
// Check the widget again and that it has a widget tree
if (IsValid(CurrentWidgetComponent->GetWidget()) && IsValid(CurrentWidgetComponent->GetWidget()->WidgetTree))
{
// declare an array of found widgets
TArray<UWidget*> FoundWidgets;
// fill out the array with the results from getting all widgets within the free
CurrentWidgetComponent->GetWidget()->WidgetTree->GetAllWidgets(FoundWidgets);
for (UWidget* FoundWidget : FoundWidgets)
{
// Then try to find an image in the tree and update the image with data from the data asset
if (UImage* Image = Cast<UImage>(FoundWidget))
{
Image->SetBrushFromAsset(FoundItem.ItemInstance->GetDataAsset()->BrushAsset);
Image->SetBrushFromTexture(FoundItem.ItemInstance->GetDataAsset()->ItemImage);
}
}
}
}
}
else
{
// If there wasn't an item remove the widget
CurrentWidgetComponent->SetWidgetClass(nullptr);
}
}
}
}
}
// Then as long there was geometry with size the inventory update can
// be considered complete and set the flag to false
bUpdateInventory = false;
}
}
}
}
Then I override the “UpdateWidget” function so that the Grid widget gets set and triggers an update to inventory if the flag has been set to true. See here:
virtual void UpdateWidget() override;
void UGridWidgetComponent::UpdateWidget()
{
Super::UpdateWidget();
if (IsValid(this) && IsValid(GetWidget()))
{
GridWidget = GetWidget()->WidgetTree->FindWidget<UPanelWidget>(GridName);
}
if (bUpdateInventory)
{
UpdateToInventory(GetOwner()->FindComponentByClass<UBaseInventoryComponent>());
}
}
Then to tie this all together, inside the bag actor I trigger an inventory update on begin play and bind to my inventory component’s “InventoryUpdateDelegate” so that every time it updates it triggers an update to the UI, see here:
This displays the items as so:
But it doesn’t have any interaction. In order to do that I created a “GrabGridItemWidgetComponent” function that takes in a primitive component, finds that primitive inside of the “GridItemSlotColliders” TMap and uses the associated ID on the “AddedWidgetComponents” TMap to find a widget component to return. See here:
// The in component is the one hit which is used to find associated widget component
UFUNCTION(BlueprintCallable)
UWidgetComponent* GrabGridItemWidgetComponent(UPrimitiveComponent* InCollisionComponent);
UWidgetComponent* UGridWidgetComponent::GrabGridItemWidgetComponent(UPrimitiveComponent* InCollisionComponent)
{
int32* IDPtr = GridItemSlotColliders.Find(InCollisionComponent);
if (IDPtr == nullptr) return nullptr;
int32 FoundID = *IDPtr;
UWidgetComponent** WidgetComponentPtr = AddedWidgetComponents.Find(FoundID);
if (WidgetComponentPtr == nullptr) return nullptr;
return *WidgetComponentPtr;
}
This widget component can then be moved around and manipulated but in order to place it back onto another grid widget component I created the “PlaceGridItemWidgetComponent” function.
It takes in a context actor used by the inventory system, a primitive that was hit and was created as part of the grid widget component and the widget component that is currently being held and should have all the information about where it came from.
It uses all the inputs to move the item represented by the widget component to the location represented by the primitive and then returns the widget component to it’s original location. This is done because it’s easier then swapping the location of 2 widget components. See here:
UFUNCTION(BlueprintCallable)
bool PlaceGridItemWidgetComponent(AActor* ContextActor, UPrimitiveComponent* InCollisionComponent, UWidgetComponent* InWidgetComponent);
bool UGridWidgetComponent::PlaceGridItemWidgetComponent(AActor* ContextActor, UPrimitiveComponent* InCollisionComponent, UWidgetComponent* InWidgetComponent)
{
// Check that both are valid
if (!IsValid(InCollisionComponent) || !IsValid(InWidgetComponent)) return false;
// If they are get a pointer to ID of the collider that was hit
int32* InventoryIDPtr1 = GridItemSlotColliders.Find(InCollisionComponent);
// If we didn't find one, the return
if (InventoryIDPtr1 == nullptr) return false;
// Check that the widget component's parent is a grid
UGridWidgetComponent* OtherGridWidgetParent = Cast<UGridWidgetComponent>(InWidgetComponent->GetAttachParent());
// If it is not then return
if (!IsValid(OtherGridWidgetParent)) return false;
// return the widget component back to it's original location
OtherGridWidgetParent->ResetGridItemWidgetTransform(InWidgetComponent);
// Get the owning actor of the widget
AActor* WidgetOwner = InWidgetComponent->GetOwner();
// If it is not valid then return
if (!IsValid(WidgetOwner)) return false;
// Use that actor and get it's inventory component
UBaseInventoryComponent* OtherInventoryComponent = WidgetOwner->FindComponentByClass<UBaseInventoryComponent>();
// if it is not valid then return
if (!IsValid(OtherInventoryComponent)) return false;
// Get this owner
AActor* ThisOwner = GetOwner();
// Check if it's valid
if (!IsValid(ThisOwner)) return false;
// Then get this owner's inventory component
UBaseInventoryComponent* InventoryComponent = ThisOwner->FindComponentByClass<UBaseInventoryComponent>();
// return false if not valid
if (!IsValid(InventoryComponent)) return false;
// Get a ptr to the id in the other grid widget associated with the widget component
const int32* InventoryIDPtr2 = OtherGridWidgetParent->AddedWidgetComponents.FindKey(InWidgetComponent);
// Check if its a nullptr
if (InventoryIDPtr2 == nullptr) return false;
FInventoryItem OtherItem;
// Then get the item from the other inventory using the ID gotten from the widget component
OtherInventoryComponent->GetItemFromID(OtherItem, *InventoryIDPtr2);
// Then move the item from the other inventory into this one
bool bSuccess = InventoryComponent->MoveItem(OtherInventoryComponent, OtherItem.ItemInstance, *InventoryIDPtr1, UOrganiseMyBagsStatics::GetNetworkContextFromActor(ContextActor));
return bSuccess;
}
Because the UI is asking the inventory to make a change it will trigger an inventory update and automatically update this widget component and represent accurate information.
This function also uses another function called “ResetGridItemWidgetTransform” which simply resets the location of the widget component to where it came from, see here:
UFUNCTION(BlueprintCallable)
void ResetGridItemWidgetTransform(UWidgetComponent* InGridItemWidgetComponent);
void UGridWidgetComponent::ResetGridItemWidgetTransform(UWidgetComponent* InGridItemWidgetComponent)
{
if (!IsValid(InGridItemWidgetComponent)) return;
const int32* IDPtr = AddedWidgetComponents.FindKey(InGridItemWidgetComponent);
if (IDPtr != nullptr)
{
int32 FoundID = *IDPtr;
InGridItemWidgetComponent->SetRelativeTransform(IDTransforms[FoundID]);
}
}
Part 3, PLAYER CONTROLLER:
In the C++ base class for the player controller there is a member variable pointer to a scene component called “HeldComponent.” It is used as part of this drag and drop system to maintain a reference to what is being dragged and casted to a primitive so that it can be used to specify what component to ignore when tracing from the mouse. See here:
// Component currently being held as part of the drag and drop
UPROPERTY(BlueprintGetter = GetHeldComponent, BlueprintSetter = SetHeldComponent)
TObjectPtr<USceneComponent> HeldComponent = nullptr;
UPROPERTY()
TObjectPtr<UPrimitiveComponent> HeldComponentPrimitive = nullptr;
public:
UFUNCTION(BlueprintGetter)
FORCEINLINE USceneComponent* GetHeldComponent() const { return HeldComponent; }
UFUNCTION(BlueprintSetter)
FORCEINLINE void SetHeldComponent(USceneComponent* InHeldComponent) { HeldComponent = InHeldComponent; HeldComponentPrimitive = Cast<UPrimitiveComponent>(InHeldComponent); }
UWorld* World = GetWorld();
if (IsValid(World))
{
FCollisionQueryParams Params;
Params.AddIgnoredComponent(HeldComponentPrimitive);
World->LineTraceSingleByChannel(
InputLocationData.CurrentHitResult,
InputLocationData.CurrentWorldMouseLocation,
InputLocationData.CurrentWorldMouseLocation + InputLocationData.CurrentWorldMouseDirection * InputTraceLength,
ECollisionChannel::ECC_Visibility, Params);
}
I have an event that gets called when the player left clicks which sends through a struct of data that can contain information about a line trace when the input starts, is triggered and ends as well as mouse position on screen and in world space. See here:
// Position data collected from input
USTRUCT(BlueprintType, Blueprintable)
struct ORGANISEMYBAGS_API FInputPositionData
{
public:
GENERATED_BODY()
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector StartWorldMouseLocation = FVector::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector StartWorldMouseDirection = FVector::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector2D StartScreenLocation = FVector2D::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FHitResult StartHitResult = FHitResult();
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector CurrentWorldMouseLocation = FVector::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector CurrentWorldMouseDirection = FVector::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector2D CurrentScreenLocation = FVector2D::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FHitResult CurrentHitResult = FHitResult();
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector EndWorldMouseLocation = FVector::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector EndWorldMouseDirection = FVector::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector2D EndScreenLocation = FVector2D::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FHitResult EndHitResult = FHitResult();
// Screen Data
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
float ViewportScale = 0.f;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector2D ScreenLocation = FVector2D::ZeroVector;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
FVector2D ScreenSize = FVector2D::ZeroVector;
void Reset()
{
StartWorldMouseLocation = FVector::ZeroVector;
StartWorldMouseDirection = FVector::ZeroVector;
StartScreenLocation = FVector2D::ZeroVector;
StartHitResult = FHitResult();
CurrentWorldMouseLocation = FVector::ZeroVector;
CurrentWorldMouseDirection = FVector::ZeroVector;
CurrentScreenLocation = FVector2D::ZeroVector;
CurrentHitResult = FHitResult();
EndWorldMouseLocation = FVector::ZeroVector;
EndWorldMouseDirection = FVector::ZeroVector;
EndScreenLocation = FVector2D::ZeroVector;
EndHitResult = FHitResult();
ViewportScale = 0.f;
ScreenLocation = FVector2D::ZeroVector;
ScreenSize = FVector2D::ZeroVector;
}
};
When the first event gets called it only contains starting line trace data which is used to try and grab the widget component from the grid widget and store it in held component pointer as seen here:
When the trigger event is called I just update the held component’s location to the mouse’s world location shifted by some arbitrary value, as seen here:
Finally, when the last event is triggered, just see if the mouse ended on an appropriate grid widget location.
Here is the final result:
Before implementing this solution I would like to recommend a critical analysis of it because there are certain components of the solution I’m not sure are best practice. If there are any mistakes or clarifications needed please let me know!