Introduction

Still, as of UE 5.5, multi-line editable text boxes lack proper keyboard and gamepad navigation support. Let’s fix this without modifying the engine’s source code. I’ll experiment with a simple 2x2 grid of text boxes to demonstrate how we can implement smooth, unified navigation across both keyboard and gamepad input devices, which you can also recreate for testing purposes.

Current Limitations

Keyboard

Arrow keys only allow navigation within the content of a single text box, and there’s no way to navigate between text boxes themselves.

Gamepad

The left stick moves between the text boxes, but neither the right stick nor the D-pad can be used to navigate between the content of a text box or between boxes themselves.

Solution

To fix this, we will create custom widgets extending both UMultiLineEditableTextBox (for UMG) and SMultiLineEditableTextBox (for Slate). The UMG widget will override the RebuildWidget() method to instantiate our custom Slate widget, which will handle both keyboard and gamepad navigation in a unified way.

Here’s the implementation:

Click to see the code

Header:

class RUNTIMETTSDEMO_API SCustomMultiLineEditableTextBox : public SMultiLineEditableTextBox
{
protected:
	/** Whether we should ignore the next OnKeyDown event */
	bool bIgnoreOnKeyDown = false;

public:
	//~ Begin SWidget Interface
	virtual bool SupportsKeyboardFocus() const override { return true; }
	virtual FReply OnKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent) override;
	//~ End SWidget Interface

	virtual FReply HandleNavigation(const FGeometry& MyGeometry, EUINavigation Navigation);
};

UCLASS(ClassGroup = "UI", meta = (Category = "Mod.io Common UI"))
class RUNTIMETTSDEMO_API UCustomMultiLineEditableTextBox : public UMultiLineEditableTextBox
{
	GENERATED_BODY()

protected:
	virtual TSharedRef<SWidget> RebuildWidget() override;
};

Source:

FReply SCustomMultiLineEditableTextBox::OnKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent)
{
	if (bIgnoreOnKeyDown)
	{
		return FReply::Unhandled();
	}

	const FKey Key = InKeyEvent.GetKey();

	if (Key == EKeys::Up || Key == EKeys::Gamepad_RightStick_Up || Key == EKeys::Gamepad_LeftStick_Up || Key == EKeys::Gamepad_DPad_Up)
	{
		return HandleNavigation(MyGeometry, EUINavigation::Up);
	}
	if (Key == EKeys::Down || Key == EKeys::Gamepad_RightStick_Down || Key == EKeys::Gamepad_LeftStick_Down || Key == EKeys::Gamepad_DPad_Down)
	{
		return HandleNavigation(MyGeometry, EUINavigation::Down);
	}
	if (Key == EKeys::Left || Key == EKeys::Gamepad_RightStick_Left || Key == EKeys::Gamepad_LeftStick_Left || Key == EKeys::Gamepad_DPad_Left)
	{
		return HandleNavigation(MyGeometry, EUINavigation::Left);
	}
	if (Key == EKeys::Right || Key == EKeys::Gamepad_RightStick_Right || Key == EKeys::Gamepad_LeftStick_Right || Key == EKeys::Gamepad_DPad_Right)
	{
		return HandleNavigation(MyGeometry, EUINavigation::Right);
	}

	return FReply::Unhandled();
}

FReply SCustomMultiLineEditableTextBox::HandleNavigation(const FGeometry& MyGeometry, EUINavigation Navigation)
{
	TSharedPtr<SWidget> EditableTextWidget = StaticCastSharedPtr<SWidget>(EditableText);
	if (!EditableTextWidget)
	{
		UE_LOG(LogTemp, Error, TEXT("Unable to handle '%s' navigation for the multi line text box: EditableText widget is null"), *UEnum::GetValueAsString(Navigation));
		return FReply::Unhandled();
	}

	UE_LOG(LogTemp, Log, TEXT("Sending '%s' navigation to the multiline editable text"), *UEnum::GetValueAsString(Navigation));
	
	FKey NavigationKey = [Navigation]()
	{
		switch (Navigation)
		{
		case EUINavigation::Previous:
		case EUINavigation::Left: return EKeys::Left;
		case EUINavigation::Next:
		case EUINavigation::Right: return EKeys::Right;
		case EUINavigation::Up: return EKeys::Up;
		case EUINavigation::Down: return EKeys::Down;
		default:
			{
				UE_LOG(LogTemp, Error, TEXT("Invalid navigation direction '%s'"), *UEnum::GetValueAsString(Navigation));
				return EKeys::Invalid;
			}
		}
	}();
	const FTextLocation PriorCursorPosition = EditableText->GetCursorLocation();
	FKeyEvent NavigationEvent(NavigationKey, FSlateApplication::Get().GetModifierKeys(), 0, false, 0, 0);
	bIgnoreOnKeyDown = true;
	FReply HandledEvent = EditableTextWidget->OnKeyDown(MyGeometry, NavigationEvent);
	bIgnoreOnKeyDown = false;
	const FTextLocation NewCursorPosition = EditableText->GetCursorLocation();

	// If the cursor changed position, the editable text handled the event by moving the cursor
	if (PriorCursorPosition != NewCursorPosition)
	{
		return HandledEvent;
	}

	// Otherwise, simulate navigation using the gamepad to go beyond the widget's bounds
	const float AnalogValue = [Navigation]()
	{
		switch (Navigation)
		{
		case EUINavigation::Previous:
		case EUINavigation::Left: return -1.0f;
		case EUINavigation::Next:
		case EUINavigation::Right: return 1.0f;
		case EUINavigation::Up: return 1.0f;
		case EUINavigation::Down: return -1.0f;
		default:
			{
				UE_LOG(LogTemp, Error, TEXT("Invalid navigation direction '%s'"), *UEnum::GetValueAsString(Navigation));
				return 0.0f;
			}
		}
	}();

	const FKey GamepadKey = [Navigation]()
	{
		switch (Navigation)
		{
		case EUINavigation::Previous:
		case EUINavigation::Left: return EKeys::Gamepad_LeftX;
		case EUINavigation::Next:
		case EUINavigation::Right: return EKeys::Gamepad_LeftX;
		case EUINavigation::Up: return EKeys::Gamepad_LeftY;
		case EUINavigation::Down: return EKeys::Gamepad_LeftY;
		default:
			{
				UE_LOG(LogTemp, Error, TEXT("Invalid navigation direction '%s'"), *UEnum::GetValueAsString(Navigation));
				return EKeys::Invalid;
			}
		}
	}();
	FAnalogInputEvent GamepadEvent(GamepadKey, FSlateApplication::Get().GetModifierKeys(), 0, false, 0, 0, AnalogValue);
	FSlateApplication::Get().ProcessAnalogInputEvent(GamepadEvent);
	return FReply::Handled();
}

TSharedRef<SWidget> UCustomMultiLineEditableTextBox::RebuildWidget()
{
	MyEditableTextBlock = SNew(SCustomMultiLineEditableTextBox)
		.Style(&WidgetStyle)
		.AllowContextMenu(AllowContextMenu)
		.IsReadOnly(bIsReadOnly)
		.VirtualKeyboardOptions(VirtualKeyboardOptions)
		.VirtualKeyboardDismissAction(VirtualKeyboardDismissAction)
		.OnTextChanged(BIND_UOBJECT_DELEGATE(FOnTextChanged, HandleOnTextChanged))
		.OnTextCommitted(BIND_UOBJECT_DELEGATE(FOnTextCommitted, HandleOnTextCommitted));

	MyEditableTextBlock->SetOnKeyDownHandler(FOnKeyDown::CreateWeakLambda(this, [this](const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent) -> FReply
	{
		if (TSharedPtr<SWidget> WidgetToHandle = StaticCastSharedPtr<SWidget>(MyEditableTextBlock))
		{
			return WidgetToHandle->OnKeyDown(MyGeometry, InKeyEvent);
		}
		return FReply::Unhandled();
	}));

	return MyEditableTextBlock.ToSharedRef();
}

Result

The result is smooth, consistent navigation:

Keyboard: Keyboard navigation

Gamepad: Gamepad navigation