Community Tutorial: Common UI Plugin - Keyboard Navigation

This C++ solution worked for me and does not require additional styles.

It hides the mouse cursor when you start using the navigation keys on the keyboard and acts the same as a gamepad. So if you want the mouse cursor to remain visible while you navigate the menu with your keyboard, this is not for you.

In the video you first see mouse navigation, then keyboard navigation, then gamepad navigation.

MyCommonUIActionRouter.h

#pragma once

#include "CoreMinimal.h"
#include "Input/CommonUIActionRouterBase.h"
#include "MyCommonUIActionRouter.generated.h"

UCLASS(MinimalAPI)
class UMyCommonUIActionRouter : public UCommonUIActionRouterBase
{
	GENERATED_BODY()
	
protected:
	MYGAME_API virtual TSharedRef<FCommonAnalogCursor> MakeAnalogCursor() const override;
	
};

MyCommonUIActionRouter.cpp

#include "MyCommonUIActionRouter.h"

#include "Input/CommonAnalogCursor.h"
#include "CommonInputTypeEnum.h"

// Tested with Unreal 5.7.3
class FMyCommonAnalogCursor : public FCommonAnalogCursor
{
public:
	bool bKeyboardNavigationOn = false;
	bool bIgnoreNextMouseMoveEvent = false;

	FMyCommonAnalogCursor(const UCommonUIActionRouterBase& InActionRouter)
		: FCommonAnalogCursor(InActionRouter)
	{
	}

	virtual void Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor) override
	{
		// When FCommonAnalogCursor::Tick updates the cursor position, this also triggers a HandleMouseMoveEvent, which we want to ignore.
		// So we check if the Tick has changed the position.

		FVector2D PreviousPosition = Cursor->GetPosition();

		FCommonAnalogCursor::Tick(DeltaTime, SlateApp, Cursor);

		if (Cursor->GetPosition() != PreviousPosition)
		{
			bIgnoreNextMouseMoveEvent = true;
		}
	}

	virtual bool HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent) override
	{
		// So we don't need to hard-code all the navigation keys.
		if (InKeyEvent.GetKey().GetMenuCategory() == EKeys::NAME_KeyboardCategory)
		{
			EUINavigation NavDir = SlateApp.GetNavigationDirectionFromKey(InKeyEvent);
			if (NavDir == EUINavigation::Left || NavDir == EUINavigation::Right || NavDir == EUINavigation::Down || NavDir == EUINavigation::Up)
			{
				if (!bKeyboardNavigationOn)
				{
					bKeyboardNavigationOn = true;
					// We don't just set ActiveInputMethod directly, so the Super::HandleInputMethodChanged can still do its thing.
					HandleInputMethodChanged(ECommonInputType::Gamepad);
				}
			}
		}
		return FCommonAnalogCursor::HandleKeyDownEvent(SlateApp, InKeyEvent);
	}

	virtual bool HandleMouseMoveEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
	{
		// This is to ignore the MouseMoveEvent generated when the hidden cursor is moved.
		if (bIgnoreNextMouseMoveEvent)
		{
			bIgnoreNextMouseMoveEvent = false;
		}
		else
		{
			// When we detect a mouse move by the user, we switch keyboard navigation off, so the mouse cursor becomes visible again.
			if (bKeyboardNavigationOn)
			{
				bKeyboardNavigationOn = false;
				// We don't just set ActiveInputMethod directly, so the Super::HandleInputMethodChanged can still do its thing.
				HandleInputMethodChanged(ECommonInputType::MouseAndKeyboard);
			}
		}
		return FCommonAnalogCursor::HandleMouseMoveEvent(SlateApp, MouseEvent);
	}

	// We override this method for the use case where the player presses any other key on the keyboard besides the navigation keys.
	virtual void HandleInputMethodChanged(ECommonInputType NewInputMethod) override
	{
		// We still want to call the super method, because it does Reset() on LastCursorTarget, which is a private member we cannot access.
		FCommonAnalogCursor::HandleInputMethodChanged(NewInputMethod);

		// When keyboard navigation is on, we fake being a Gamepad, but only in the eyes of FCommonAnalogCursor.
		// This will cause the Tick function to still hide the mouse cursor and do the proper navigation.
		if (bKeyboardNavigationOn)
		{
			ActiveInputMethod = ECommonInputType::Gamepad;
		}
	}
};

TSharedRef<FCommonAnalogCursor> UMyCommonUIActionRouter::MakeAnalogCursor() const
{
	return FCommonAnalogCursor::CreateAnalogCursor<FMyCommonAnalogCursor>(*this);
}

This solution is like what they mention here in the official docs.

And if you to allow WASD as well, you can do this:

// Somewhere in your code, for example in the cpp of your custom GameInstance

#include "Framework/Application/NavigationConfig.h"

class FMyNavigationConfig : public FNavigationConfig
{
public:
	FMyNavigationConfig()
	{
		KeyEventRules.Emplace(EKeys::W, EUINavigation::Up);
		KeyEventRules.Emplace(EKeys::S, EUINavigation::Down);
		KeyEventRules.Emplace(EKeys::A, EUINavigation::Left);
		KeyEventRules.Emplace(EKeys::D, EUINavigation::Right);
	}
};

// Then, in the Init of your GameInstance for example:
void UMyGameInstance::Init()
{
	Super::Init();
	
	if (FSlateApplication::IsInitialized())
	{
		TSharedRef<FMyNavigationConfig> NavConfig = MakeShared<FMyNavigationConfig>();
		FSlateApplication::Get().SetNavigationConfig(NavConfig);
	}
}

I also had to add Slate to my PublicDependencyModuleNames in my .Build.cs file.