More Advanced Version of Radial Menu
(With Integrated GamePad / KBM Interactions and Common Interactions Scenarios Integrated)
1 - Created RadialMenu IA and Integrated them in mapping context. Triggers as Pressed and Released.
2- A - Created a UI material that acts like a slice from left to right with angles as custom node. It has some color variables and opacity but important part is to have a correct angle on it. Additionally same script is used to calculate a ProgressBar at the right edge of the material which can be controlled by parameters.
2- B - Custom HLSL node code is like below
// Constants
const float DEG2RAD = 0.017453292519943295;
// Fixed apex (left middle) and facing (right)
float2 apex = float2(0.0, 0.5);
float2 d = float2(1.0, 0.0);
// UV vector from apex
float2 p = UV - apex;
float r = length(p);
// Avoid NaNs
float2 v = (r > 1e-6) ? (p / r) : d;
// ----- Angular mask (hard) -----
float halfA = (AngleDeg * 0.5) * DEG2RAD;
float aDot = dot(v, d);
float angMask = step(cos(halfA), aDot); // inside wedge
// ----- Radial mask (hard circle, radius = 1 UV unit) -----
float radMask = 1.0 - step(1.0, r); // r < 1
// ----- Combine -----
return angMask * radMask;
3- A : Creating Custom Content Widget with Custom Hitbox : This stage is C++ however can be neglected in general radial menu interaction methods it’s recommended for flexibility and accuracy of the system for an device agnostic interaction. If a hitbox method should be used it can be done like below by adding a custom widget with custom hitbox.
MyQuadWidget.h
Summary
// MyQuadWidget.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ContentWidget.h"
#include "MyQuadWidget.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuadSimple);
UCLASS(meta=(DisplayName="My Quad Content Widget"))
class UMyQuadWidget : public UContentWidget
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Shape", meta=(ExposeOnSpawn=true, BlueprintSetter=SetCornerA))
FVector2D CornerA = FVector2D(0.0f, 0.5f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Shape", meta=(ExposeOnSpawn=true, BlueprintSetter=SetCornerB))
FVector2D CornerB = FVector2D(1.0f, 0.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Shape", meta=(ExposeOnSpawn=true, BlueprintSetter=SetCornerC))
FVector2D CornerC = FVector2D(1.0f, 1.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Shape", meta=(ExposeOnSpawn=true, BlueprintSetter=SetCornerD))
FVector2D CornerD = FVector2D(0.0f, 0.5f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance", meta=(ClampMin="0.0"))
float EditorOutlineThickness = 2.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
FLinearColor EditorOutlineColor = FLinearColor(0.15f, 0.85f, 0.25f, 1.0f);
UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnPressed;
UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnReleased;
UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnClicked;
UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnEntered;
UPROPERTY(BlueprintAssignable, Category="Quad|Events") FOnQuadSimple OnLeft;
UFUNCTION(BlueprintCallable, Category="Quad") void SetCorners(FVector2D A, FVector2D B, FVector2D C, FVector2D D);
UFUNCTION(BlueprintSetter) void SetCornerA(FVector2D InA);
UFUNCTION(BlueprintSetter) void SetCornerB(FVector2D InB);
UFUNCTION(BlueprintSetter) void SetCornerC(FVector2D InC);
UFUNCTION(BlueprintSetter) void SetCornerD(FVector2D InD);
virtual void SynchronizeProperties() override;
virtual void ReleaseSlateResources(bool bReleaseChildren) override;
protected:
virtual UClass* GetSlotClass() const override { return UPanelSlot::StaticClass(); }
virtual void OnSlotAdded(UPanelSlot*) override {}
virtual void OnSlotRemoved(UPanelSlot*) override {}
virtual TSharedRef<SWidget> RebuildWidget() override;
#if WITH_EDITOR
virtual void PostEditChangeProperty(FPropertyChangedEvent& E) override
{
Super::PostEditChangeProperty(E);
SynchronizeProperties();
InvalidateLayoutAndVolatility();
}
#endif
private:
void PushToSlate();
TArray<FVector2D> BuildNormQuad() const;
private:
TSharedPtr<class SMyQuadPanel> SlatePanel;
void SlatePressed() { OnPressed.Broadcast(); }
void SlateReleased() { OnReleased.Broadcast(); }
void SlateClicked() { OnClicked.Broadcast(); }
void SlateEntered() { OnEntered.Broadcast(); }
void SlateLeft() { OnLeft.Broadcast(); }
};
// ----- Slate -----
class SMyQuadPanel : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SMyQuadPanel) {}
SLATE_ARGUMENT(TArray<FVector2D>, NormQuad)
SLATE_ARGUMENT(float, EditorOutlineThickness)
SLATE_ARGUMENT(FLinearColor, EditorOutlineColor)
SLATE_EVENT(FSimpleDelegate, OnPressed)
SLATE_EVENT(FSimpleDelegate, OnReleased)
SLATE_EVENT(FSimpleDelegate, OnClicked)
SLATE_EVENT(FSimpleDelegate, OnEntered)
SLATE_EVENT(FSimpleDelegate, OnLeft)
SLATE_DEFAULT_SLOT(FArguments, Content)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
void SetNormQuad(const TArray<FVector2D>& In);
void SetEditorOutline(float Thickness, FLinearColor Color);
void SetChildRef(const TSharedRef<SWidget>& InChild);
virtual bool SupportsCustomHitTesting() const { return true; }
virtual TSharedPtr<FVirtualPointerPosition> TranslateMouseCoordinateForCustomHitTesting(
const FGeometry& Geo, const FVector2D& AbsolutePos) const;
virtual void OnMouseEnter(const FGeometry& Geo, const FPointerEvent& Ev) override;
virtual void OnMouseLeave(const FPointerEvent& Ev) override;
virtual FReply OnMouseMove(const FGeometry& Geo, const FPointerEvent& Ev) override;
virtual FReply OnMouseButtonDown(const FGeometry& Geo, const FPointerEvent& Ev) override;
virtual FReply OnMouseButtonUp(const FGeometry& Geo, const FPointerEvent& Ev) override;
virtual int32 OnPaint(const FPaintArgs& Args, const FGeometry& Geo, const FSlateRect& Clip,
FSlateWindowElementList& Out, int32 Layer, const FWidgetStyle& Style,
bool bParentEnabled) const override;
private:
TArray<FVector2D> ToLocal(const FGeometry& Geo) const;
static FVector2D Clamp01(const FVector2D& V)
{
return FVector2D(FMath::Clamp(V.X, 0.f, 1.f), FMath::Clamp(V.Y, 0.f, 1.f));
}
static bool PointInQuad(const FVector2D& P, const TArray<FVector2D>& Q);
static bool SameSide2D(const FVector2D& P, const FVector2D& A, const FVector2D& B, const FVector2D& Ref);
private:
TArray<FVector2D> Norm;
float Thick = 0.0f;
FLinearColor Col = FLinearColor::White;
FSimpleDelegate OnPressedDelegate;
FSimpleDelegate OnReleasedDelegate;
FSimpleDelegate OnClickedDelegate;
FSimpleDelegate OnEnteredDelegate;
FSimpleDelegate OnLeftDelegate;
bool bPressedInside = false;
bool bHoverInside = false;
};
MyQuadWidget.cpp
Summary
// MyQuadWidget.cpp
#include "MyQuadWidget.h"
#include "Widgets/SCompoundWidget.h"
#include "Rendering/DrawElements.h"
TSharedRef<SWidget> UMyQuadWidget::RebuildWidget()
{
SlatePanel = SNew(SMyQuadPanel)
.NormQuad(BuildNormQuad())
.EditorOutlineThickness(EditorOutlineThickness)
.EditorOutlineColor(EditorOutlineColor)
.OnPressed(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlatePressed))
.OnReleased(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlateReleased))
.OnClicked(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlateClicked))
.OnEntered(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlateEntered))
.OnLeft(FSimpleDelegate::CreateUObject(this, &UMyQuadWidget::SlateLeft))
[
GetContent() ? GetContent()->TakeWidget() : SNullWidget::NullWidget
];
return SlatePanel.ToSharedRef();
}
void UMyQuadWidget::SynchronizeProperties()
{
Super::SynchronizeProperties();
PushToSlate();
}
void UMyQuadWidget::ReleaseSlateResources(bool bReleaseChildren)
{
Super::ReleaseSlateResources(bReleaseChildren);
SlatePanel.Reset();
}
void UMyQuadWidget::SetCorners(FVector2D A, FVector2D B, FVector2D C, FVector2D D)
{
CornerA = A; CornerB = B; CornerC = C; CornerD = D;
PushToSlate();
}
void UMyQuadWidget::SetCornerA(FVector2D InA) { CornerA = InA; PushToSlate(); }
void UMyQuadWidget::SetCornerB(FVector2D InB) { CornerB = InB; PushToSlate(); }
void UMyQuadWidget::SetCornerC(FVector2D InC) { CornerC = InC; PushToSlate(); }
void UMyQuadWidget::SetCornerD(FVector2D InD) { CornerD = InD; PushToSlate(); }
void UMyQuadWidget::PushToSlate()
{
if (SlatePanel.IsValid())
{
SlatePanel->SetNormQuad(BuildNormQuad());
SlatePanel->SetEditorOutline(EditorOutlineThickness, EditorOutlineColor);
SlatePanel->SetChildRef(GetContent() ? GetContent()->TakeWidget() : SNullWidget::NullWidget);
InvalidateLayoutAndVolatility();
}
}
TArray<FVector2D> UMyQuadWidget::BuildNormQuad() const
{
TArray<FVector2D> Q; Q.Reserve(4);
Q.Add(CornerA); Q.Add(CornerB); Q.Add(CornerC); Q.Add(CornerD);
return Q;
}
// ----- Slate -----
void SMyQuadPanel::Construct(const FArguments& InArgs)
{
Norm = InArgs._NormQuad;
Thick = InArgs._EditorOutlineThickness;
Col = InArgs._EditorOutlineColor;
OnPressedDelegate = InArgs._OnPressed;
OnReleasedDelegate = InArgs._OnReleased;
OnClickedDelegate = InArgs._OnClicked;
OnEnteredDelegate = InArgs._OnEntered;
OnLeftDelegate = InArgs._OnLeft;
ChildSlot[ InArgs._Content.Widget ];
}
void SMyQuadPanel::SetNormQuad(const TArray<FVector2D>& In)
{
Norm = In;
Invalidate(EInvalidateWidget::LayoutAndVolatility);
}
void SMyQuadPanel::SetEditorOutline(float Thickness, FLinearColor Color)
{
Thick = Thickness; Col = Color;
Invalidate(EInvalidateWidget::PaintAndVolatility);
}
void SMyQuadPanel::SetChildRef(const TSharedRef<SWidget>& InChild)
{
ChildSlot.AttachWidget(InChild);
}
TSharedPtr<FVirtualPointerPosition> SMyQuadPanel::TranslateMouseCoordinateForCustomHitTesting(
const FGeometry& Geo, const FVector2D& AbsolutePos) const
{
const FVector2D Local = Geo.AbsoluteToLocal(AbsolutePos);
const bool bInside = PointInQuad(Local, ToLocal(Geo));
if (bInside)
{
return MakeShared<FVirtualPointerPosition>(AbsolutePos, Local);
}
else
{
return nullptr;
}
}
void SMyQuadPanel::OnMouseEnter(const FGeometry& Geo, const FPointerEvent& Ev)
{
const FVector2D L = Geo.AbsoluteToLocal(Ev.GetScreenSpacePosition());
const bool bInside = PointInQuad(L, ToLocal(Geo));
if (bInside && !bHoverInside)
{
bHoverInside = true;
if (OnEnteredDelegate.IsBound()) OnEnteredDelegate.Execute();
}
SCompoundWidget::OnMouseEnter(Geo, Ev);
}
void SMyQuadPanel::OnMouseLeave(const FPointerEvent& Ev)
{
if (bHoverInside)
{
bHoverInside = false;
if (OnLeftDelegate.IsBound()) OnLeftDelegate.Execute();
}
SCompoundWidget::OnMouseLeave(Ev);
}
FReply SMyQuadPanel::OnMouseMove(const FGeometry& Geo, const FPointerEvent& Ev)
{
const FVector2D L = Geo.AbsoluteToLocal(Ev.GetScreenSpacePosition());
const bool bInside = PointInQuad(L, ToLocal(Geo));
if (bInside && !bHoverInside)
{
bHoverInside = true;
if (OnEnteredDelegate.IsBound()) OnEnteredDelegate.Execute();
}
else if (!bInside && bHoverInside)
{
bHoverInside = false;
if (OnLeftDelegate.IsBound()) OnLeftDelegate.Execute();
}
return SCompoundWidget::OnMouseMove(Geo, Ev);
}
FReply SMyQuadPanel::OnMouseButtonDown(const FGeometry& Geo, const FPointerEvent& Ev)
{
if (Ev.GetEffectingButton() != EKeys::LeftMouseButton)
return SCompoundWidget::OnMouseButtonDown(Geo, Ev);
const FVector2D L = Geo.AbsoluteToLocal(Ev.GetScreenSpacePosition());
const bool bInside = PointInQuad(L, ToLocal(Geo));
bPressedInside = bInside;
if (bInside)
{
if (OnPressedDelegate.IsBound()) OnPressedDelegate.Execute();
return SCompoundWidget::OnMouseButtonDown(Geo, Ev);
}
return FReply::Unhandled();
}
FReply SMyQuadPanel::OnMouseButtonUp(const FGeometry& Geo, const FPointerEvent& Ev)
{
const FVector2D L = Geo.AbsoluteToLocal(Ev.GetScreenSpacePosition());
const bool bInside = PointInQuad(L, ToLocal(Geo));
if (bInside)
{
if (OnReleasedDelegate.IsBound()) OnReleasedDelegate.Execute();
if (bPressedInside && OnClickedDelegate.IsBound()) OnClickedDelegate.Execute();
}
bPressedInside = false;
return bInside ? SCompoundWidget::OnMouseButtonUp(Geo, Ev) : FReply::Unhandled();
}
int32 SMyQuadPanel::OnPaint(const FPaintArgs& Args, const FGeometry& Geo, const FSlateRect& Clip,
FSlateWindowElementList& Out, int32 Layer, const FWidgetStyle& Style,
bool bParentEnabled) const
{
#if WITH_EDITOR
if (Thick > 0.0f && Norm.Num() == 4)
{
TArray<FVector2D> L = ToLocal(Geo);
const FVector2D First = L.Num() ? L[0] : FVector2D::ZeroVector;
L.Add(First);
FSlateDrawElement::MakeLines(
Out, Layer, Geo.ToPaintGeometry(), L, ESlateDrawEffect::None,
(Col * Style.GetColorAndOpacityTint()).ToFColor(true), true, Thick);
Layer++;
}
#endif
return SCompoundWidget::OnPaint(Args, Geo, Clip, Out, Layer, Style, bParentEnabled);
}
TArray<FVector2D> SMyQuadPanel::ToLocal(const FGeometry& Geo) const
{
// map normalized corners to the ChildSlot content rect
const FMargin Pad = ChildSlot.GetPadding();
const FVector2D Full = Geo.GetLocalSize();
const FVector2D Size(
FMath::Max(0.f, Full.X - (Pad.Left + Pad.Right)),
FMath::Max(0.f, Full.Y - (Pad.Top + Pad.Bottom))
);
const FVector2D Origin(Pad.Left, Pad.Top);
TArray<FVector2D> L; L.Reserve(4);
for (const FVector2D& N : Norm)
{
const FVector2D Nc = Clamp01(N);
L.Add( Origin + FVector2D(Nc.X * Size.X, Nc.Y * Size.Y) );
}
return L;
}
bool SMyQuadPanel::SameSide2D(const FVector2D& P, const FVector2D& A, const FVector2D& B, const FVector2D& Ref)
{
const FVector2D AB = B - A;
const float z1 = AB.X*(P.Y - A.Y) - AB.Y*(P.X - A.X);
const float z2 = AB.X*(Ref.Y - A.Y) - AB.Y*(Ref.X - A.X);
return z1 * z2 >= 0.f;
}
bool SMyQuadPanel::PointInQuad(const FVector2D& P, const TArray<FVector2D>& Q)
{
const FVector2D A = Q[0], B = Q[1], C = Q[2], D = Q[3];
return SameSide2D(P, A, B, C)
&& SameSide2D(P, B, C, D)
&& SameSide2D(P, C, D, A)
&& SameSide2D(P, D, A, B);
}
3- B : Configuring Quad Widget : After this we should be able to access our custom quad widget from UMG
It’s edges can be defined from details or from bp function directly.
3-C : Building atomic Pie Buttons : Added a canvas panel and My Quad Widget. Set it’s size to 256x256, On It’s child lives an overlay as wrapper MyButtonContainer which simply holds our button elements: some backgrounds with our material from step 2 and one icon. If you don’t want to use custom QuadWrapperWidget you can simply add a size box or any other element to define sizings of your button.
3-D: Building atomic Pie Buttons Logic : We have couple of functions over here and 2 events.
SetButtonState(): Compare current state is different → Then create change state and create an event OnButtonStateChanged.
CreateDynamicButton() : Creates our material as instance and adds to background widget elements. Also holds material reference since we will use it as indicator later on.
PrepeareButton() : Simply we pass angle of slice value to our materials and set an Icon. Additionally we make the widget rotations for radial menu in this element with incoming angle. Icon is rotated negatively to other side to have a downward facing icon alignment at all times.
An important point over here is SetCorners() for QuadWidget. As seen A and D corners (Top Left and BottomLeft) are set to (0, 0.5), on the other hand B and C corners are X =1 and Y is set to incoming angles Tangent/2 subtracted from 0.5 (middle point). This will sit on the edges when angle variable is introduced from parent widget.
4-A: Creating Parent Widget as Radial_WGT :
Radial hierarcy is as follows.
4-B: Radial_WGT logic
We get our children in buttons container ,cast to its class and save them as array.
Calculate our angle desired for button count then prepeare buttons.
OnConstruction() We run function PrepeareMouse() which sets the mouse button to be center for sure. Additionally bind to our buttons Interacted Event.
CreateIndicator() can be anything for now it creates a niagara system in the world with the buttons icon passed to niagara system, nothing much.
SetHoverButton() → Deselect all buttons and set state hover current button index
DeselectAllButtons()->Set all buttons state normal.
RemoveRadialMenu()-> Calls event for exit on parent widget , try Interact() then remove destroy()
TryInteract()-> If there is a button currently we are howering as valid, calls event as OnButtonInteracted. This can be done in button level if wanted as well.
We have 2 major functions as
MakeCardinalSelection() : Which simply gets a vector of input position, compares with a deadzone value, if greater converts this to a 0-360 angle and derives index of the button which passed as incoming index.
Additionally runs a UpdateProgressBar() which simply controls the materials progress bar variables to indicate confirmation call to action. If distance radius from center reaches outer bounds of the radial menu we consider it %100
4-C Radial_WGT input and Interaction Design
Below two functions are simply listeners for our input button release for gamepad and mouse.
Override OnMouseButtonUp and check if the up button is our IA_RadialMenu compare and run function RemoveRadialMenu()
Similarly OnKeyUp check if the up button is our IA_RadialMenu compare and run function RemoveRadialMenu()
For controlling our cardinal selection through the widget we use functions below as override OnMouseMove() and OnAnalogValueChanged() override both and do as follows.
for getting radius use macro below, its a hypotenuse calculation nothing much.
OnMouseMove() we get mouse pos reletive to screen center and it’s radius and pass it to MakeCardinalSelection(). Additionally we compare if this radius reaches the edge of menu we RemoveRadialMenu() which before removal tries interact if there is a valid hover.
Similarly OnAnalogValueChanged(), we get X and Y values of thumbstick and globally save them at the end make MakeCardinalSelection(). We don’t remove it here since analog sticks are not very comfortable with edge control still can be extended for further interaction behaviour.
5- Binding all together.
We just need to spawn widget and set inputs to UI only, widgets should take care of the rest since we added input down listeners in widget which will remove the widget in the end running the OnRadialMenuExit event. Simply we bind to that in pawn to listen removal of widget so we can give back controls to game. Depending on the setup can be different for sure.
Results

Let me know your questions and feedback if any.