ESC 界面
void UEqZeroHUDLayout::NativeOnInitialized()
{
Super::NativeOnInitialized();
// 注册 ESC 事件
// Plugins Common UI Input Settings 里面配置一下Tag和触发按钮,lyra是ESC和手柄按下遥感
RegisterUIActionBinding(FBindUIActionArgs(
FUIActionTag::ConvertChecked(TAG_UI_ACTION_ESCAPE),
false,
FSimpleDelegate::CreateUObject(this, &ThisClass::HandleEscapeAction)));
从这里出ESC界面,push到UI栈上。
再按一次ESC消失的逻辑在哪里
UCommonActivatableWidget::HandleBackAction 打了个断点猜是这里。确实
找一下注册的地方
bIsBackHandler,IsBackHandler 要打勾,
另外还有一个 IsBackActionDisplayedInActionBar 也要打勾,这个我们一会来看。
这两个bool都注册进CommomUI了,通过 RegisterUIActionBinding
void UCommonActivatableWidget::NativeConstruct()
{
Super::NativeConstruct();
if (bIsBackHandler)
{
if (CommonUI::IsEnhancedInputSupportEnabled() && ICommonInputModule::GetSettings().GetEnhancedInputBackAction())
{
FBindUIActionArgs BindArgs(ICommonInputModule::GetSettings().GetEnhancedInputBackAction(), FSimpleDelegate::CreateUObject(this, &UCommonActivatableWidget::HandleBackAction));
BindArgs.bDisplayInActionBar = bIsBackActionDisplayedInActionBar;
BindArgs.OverrideDisplayName = OverrideBackActionDisplayName;
DefaultBackActionHandle = RegisterUIActionBinding(BindArgs);
}
else
{
FBindUIActionArgs BindArgs(ICommonInputModule::GetSettings().GetDefaultBackAction(), FSimpleDelegate::CreateUObject(this, &UCommonActivatableWidget::HandleBackAction));
BindArgs.bDisplayInActionBar = bIsBackActionDisplayedInActionBar;
BindArgs.OverrideDisplayName = OverrideBackActionDisplayName;
DefaultBackActionHandle = RegisterUIActionBinding(BindArgs);
}
}
自己这个CommonUserwidget 会存一个,
然后通过 UCommonUIActionRouterBase 注册上去,这是一个LocalPlayerSubsystem。
注册到那里了,AllRegistrationsByHandle 这是一个static的 TMap。
回我们自己项目打个勾,然后是BackAction是什么
FDataTableRowHandle UCommonInputSettings::GetDefaultBackAction() const
{
ensure(bInputDataLoaded);
if (InputDataClass)
{
if (const UCommonUIInputData* InputDataPtr = InputDataClass.GetDefaultObject())
{
return InputDataPtr->DefaultBackAction;
}
}
return FDataTableRowHandle();
}
配置一下ESC打开和ESC关闭的逻辑就好了。
总结一下:
CommonUserWidget的接口 RegisterUIActionBinding,可以注册某个键的回调。我们注册了一个按ESC打开UI。
再按一次ESC关闭。UCommonActivatableWidget 也用 RegisterUIActionBinding 这个接口注册了BackAction,
具体是哪个TAG,通过暴露出配置走ComminUI的配置来做。读到BackAction
具体看 /Game/UI/B_CommonInputData.B_CommonInputData。
触发后做了什么,DeactivateWidget 把UI反激活了。
顶键侧键
ESC出界面,再ESC界面消失。界面右下角有一个Back按钮。点击也是消失。
界面的下面放一个子UI,作为侧边键的那一条。
/Game/UI/Foundation/Widgets/BottomBar/W_BottomActionBar.W_BottomActionBar
【如何知道要加什么按钮】
这里面是一个size box 套着一个 Common Bound Action Bar 这个控件见自动获得所有注册的 RegisterUIActionBinding 的回调,
如果界面设置了 IsBackActionDisplayedInActionBar = true 这个变量在,UCommonActivatableWidget 里面,
如果配置了 RegisterUIActionBinding 的 这个参数就是true。也有其他重载,例如
// UCommonActivatableWidget::NativeConstruct
FBindUIActionArgs BindArgs(ICommonInputModule::GetSettings().GetDefaultBackAction(), FSimpleDelegate::CreateUObject(this, &UCommonActivatableWidget::HandleBackAction));
BindArgs.bDisplayInActionBar = bIsBackActionDisplayedInActionBar;
BindArgs.OverrideDisplayName = OverrideBackActionDisplayName;
DefaultBackActionHandle = RegisterUIActionBinding(BindArgs);
【对于 Common Bound Action Bar 】
代码大概在这个位置,会获取对应的所有bind的action
HandleDeferredDisplayUpdate 这里面 (TArray
=》UCommonBoundActionBar::CreateActionButton 创建
输入模式的屏蔽
我们的每个激活的界面都实现这个接口
TOptional<FUIInputConfig> UEqZeroActivatableWidget::GetDesiredInputConfig() const
{
switch (InputConfig)
{
case EEqZeroWidgetInputMode::GameAndMenu:
return FUIInputConfig(ECommonInputMode::All, GameMouseCaptureMode);
case EEqZeroWidgetInputMode::Game:
return FUIInputConfig(ECommonInputMode::Game, GameMouseCaptureMode);
case EEqZeroWidgetInputMode::Menu:
return FUIInputConfig(ECommonInputMode::Menu, EMouseCaptureMode::NoCapture);
case EEqZeroWidgetInputMode::Default:
default:
return TOptional<FUIInputConfig>(); // 空 = 不关心,沿用父级
}
}
直接断这就行。
TOptional<FUIInputConfig> FActivatableTreeNode::FindDesiredInputConfig() const
{
// 1. 先问当前控件要不要输入配置
TOptional<FUIInputConfig> DesiredConfig = RepresentedWidget->GetDesiredInputConfig();
// 2. 查找 ActionDomain 配置(如果有)
TOptional<FUIInputConfig> ActionDomainDesiredConfig = FindDesiredActionDomainInputConfig();
// 3. ActionDomain 配置作为 fallback(控件自身没设,但 Domain 有设)
if (ActionDomainDesiredConfig.IsSet() && !DesiredConfig.IsSet())
{
return ActionDomainDesiredConfig;
}
// 4. 如果还是没有,向父节点递归
if (!DesiredConfig.IsSet() && Parent.IsValid())
{
DesiredConfig = Parent.Pin()->FindDesiredInputConfig();
}
return DesiredConfig;
}
emmmmmmmmmmm,有一棵树,他找一路找配置,如果找不到就往父节点爬。这颗树什么样呢?
UCommonUIActionRouterBase 好像是这个维护的。
树节点结构
class FActivatableTreeNode : public FActionRouterBindingCollection
{
TWeakObjectPtr<UCommonActivatableWidget> RepresentedWidget; // 本节点对应的控件
TWeakPtr<FActivatableTreeNode> Parent; // 弱引用父节点(避免循环引用)
TArray<FActivatableTreeNodeRef> Children; // 强引用子节点(负责子节点生命周期)
bool bCanReceiveInput; // 是否可接收输入
TWeakPtr<SWidget> FocusRestorationTarget; // 焦点恢复目标
};
class FActivatableTreeRoot : public FActivatableTreeNode
{
TWeakPtr<FActivatableTreeNode> LeafmostActiveNode; // 当前最深活跃节点(输入焦点所在)
FSimpleDelegate OnLeafmostActiveNodeChanged;
};
父→子是强引用(TArray<SharedRef>),子→父是弱引用(TWeakPtr)。根节点由 ActionRouter 的 RootNodes 数组持有
这棵树 不是 Widget 嵌套的完整复制品,只包含 UCommonActivatableWidget 类型的控件。普通 UWidget(如 Button、Text)不会在树中出现。
ActionRouter(每 LocalPlayer 一个)
├── RootNodes[] ← 普通根节点集合
│ ├── FActivatableTreeRoot (GameLayout)
│ │ ├── FActivatableTreeNode (HUD)
│ │ │ ├── FActivatableTreeNode (MiniMap)
│ │ │ └── FActivatableTreeNode (ChatBox)
│ │ └── FActivatableTreeNode (InventoryPanel)
│ └── FActivatableTreeRoot (ModalDialog) ← bIsModal=true 的控件也是独立根
│
└── ActionDomainRootNodes{} ← 按 ActionDomain 分组
└── [Domain A]
└── FActivatableTreeRoot (...)
└── ...
构建流程在这 UCommonUIActionRouterBase::ProcessRebuiltWidgets
void UCommonUIActionRouterBase::ProcessRebuiltWidgets()
{
// 【1】
// WidgetsByDirectParent TMap<父节点,[子节点...]>
// RootCandidates TArray[根节点]
// 【2】
// root = FActivatableTreeRoot::Create(ActionRouter, Widget)
// 注册到 RootNodes[] 或 ActionDomainRootNodes{}
// 【3】
// AssembleTreeRecursive 递归组装子树
}
控件激活和反激活的时候会开启和关闭节点输入,和激活焦点。
控件激活时
Widget.ActivateWidget()
→ bIsActive = true
→ NativeOnActivated()
→ OnActivated().Broadcast() ← 树节点监听此事件
→ FActivatableTreeNode::HandleWidgetActivated()
→ SetCanReceiveInput(true) // 开启本节点输入
→ if 支持激活焦点:
GetRoot()->UpdateLeafmostActiveNode(this) // ★ 更新叶节点
控件反激活时
Widget.DeactivateWidget()
→ bIsActive = false
→ NativeOnDeactivated()
→ OnDeactivated().Broadcast() ← 树节点监听此事件
→ FActivatableTreeNode::HandleWidgetDeactivated()
→ SetCanReceiveInput(false) // 关闭本节点输入
→ 向上找到最近仍活跃的父节点
→ ActionRouter.UpdateLeafNodeAndConfig(Root, 该父节点) // ★ 焦点回退到父级
叶节点(Leafmost Node)选择
系统始终维护一个 LeafmostActiveNode,即当前最深的活跃节点。它决定了输入配置和焦点。
FActivatableTreeNodePtr FindLeafmostActiveChild(const FActivatableTreeNodeRef& CurNode) 这个函数
递归找 GetLastPaintLayer 最大的节点。这个是LayerID。
Paint Layer 的作用:当同级有多个活跃子控件时,选择渲染层最高的(即视觉上最前端的),保证输入总是给到用户看到的最前面的控件。
这里的树和焦点有什么用?
找到了以后
// FActivatableTreeRoot::ApplyLeafmostNodeConfig
TOptional<FUIInputConfig> DesiredConfig = PinnedLeafmostNode->FindDesiredInputConfig();
if(DesiredConfig.IsSet())
{
GetActionRouter().SetActiveUIInputConfig(DesiredConfig.GetValue(), PinnedLeafmostNode->GetWidget());
}
else if(ICommonInputModule::GetSettings().GetEnableDefaultInputConfig())
{
// Nobody in the entire tree cares about the config and the default is enabled so fall back to the default
GetActionRouter().SetActiveUIInputConfig(FUIInputConfig());
}
SetActiveUIInputConfig=》ApplyUIInputConfig
ApplyUIInputConfig — 输入配置应用(核心!)
这是整个输入系统最关键的方法,将 FUIInputConfig 翻译成引擎层面的实际行为:
包括
控制角色输入
PC->SetIgnoreMoveInput(NewConfig.bIgnoreMoveInput);
PC->SetIgnoreLookInput(NewConfig.bIgnoreLookInput);
Menu 模式切换时刷新按键状态
if (NewConfig.GetInputMode() == ECommonInputMode::Menu
&& PreviousInputMode != NewConfig.GetInputMode())
{
// 下一帧清空所有"按住"的按键,防止切换模式时键被卡住
GetWorld()->GetTimerManager().SetTimerForNextTick(
this, &ThisClass::FlushPressedKeys);
}
切换到 Menu 模式时会自动清空已按下按键。解决了 "按 ESC 打开菜单后 W 键卡住角色一直往前走" 的问题。
设置视口鼠标属性
GameViewportClient->SetMouseCaptureMode(NewConfig.GetMouseCaptureMode());
GameViewportClient->SetHideCursorDuringCapture(
NewConfig.HideCursorDuringViewportCapture() && !ShouldAlwaysShowCursor());
GameViewportClient->SetMouseLockMode(NewConfig.GetMouseLockMode());
根据捕获模式执行 Slate 操作
永久捕获模式(CapturePermanently)
case EMouseCaptureMode::CapturePermanently:
case EMouseCaptureMode::CapturePermanently_IncludingInitialMouseDown:
PC->SetShowMouseCursor(false); // 隐藏光标
SlateOperations.UseHighPrecisionMouseMovement(ViewportWidget); // 高精度鼠标
SlateOperations.SetUserFocus(ViewportWidget); // 焦点给视口
SlateOperations.CaptureMouse(ViewportWidget); // 捕获鼠标
SlateOperations.LockMouseToWidget(ViewportWidget); // 锁定到视口
无捕获 / 按下时捕获模式(NoCapture / CaptureDuringMouseDown)
case EMouseCaptureMode::NoCapture:
case EMouseCaptureMode::CaptureDuringMouseDown:
case EMouseCaptureMode::CaptureDuringRightMouseDown:
PC->SetShowMouseCursor(true); // 显示光标
SlateOperations.ReleaseMouseCapture(); // 释放捕获
SlateOperations.ReleaseMouseLock(); // 释放锁定
光标复位
// 如果从隐藏光标变为显示光标,将光标移到视口中心
if (bWasCursorHidden && PC->ShouldShowMouseCursor())
{
SlateUser->SetCursorPosition(ViewportCenter);
}
同步 Enhanced Input 系统
if (PreviousInputMode != NewConfig.GetInputMode())
{
// 移除旧模式的 GameplayTag,添加新模式的 GameplayTag
IE->RemoveTagsFromInputMode(GetGameplayTagsForInputMode(PreviousInputMode));
IE->AppendTagsToInputMode(GetGameplayTagsForInputMode(NewConfig.GetInputMode()));
OnActiveInputModeChanged().Broadcast(NewConfig.GetInputMode());
}
OnActiveInputConfigChanged().Broadcast(NewConfig);
Enhanced Input 通过 GameplayTag 感知当前 CommonUI 输入模式,可在 InputMappingContext 中用 Tag 条件过滤。
===
小结:
还有一些细节,
每个 Root 只有一个 LeafmostActiveNode,但是Root可以有多个,只有活跃的 Root 会被 ApplyLeafmostNodeConfig,影响引擎的行为。
这个树有什么用?
- 决定输入模式,从叶子节点向上递归寻找一个可用的配置,影响引擎。
- 决定焦点,FocusLeafmostNode() 将 Slate 焦点设到叶节点对应控件的 GetDesiredFocusTarget()
- 决定哪些 Action Binding 可用。只有叶节点路径上(从叶到根)的 ActionBinding 处于活跃状态,其他分支的绑定被暂停
形象的理解一下,我们有一个控件树,只有最前面的一个UI能收到输入,手柄操作的焦点,并且这条链到root的UI注册的Action才可用。
疑问:
一个界面,背后被挡住了,还能收到输入事件吗。
背后的界面反激活, SetCanReceiveInput(false); // 关闭本节点 + 递归关闭所有子节点
输入事件到达的时候 FActivatableTreeNode::ProcessNormalInput 这里屏蔽了事件。
Push新界面,背后的界面是隐藏吗?如果是半透界面怎么办。
内部通过 SWidgetSwitcher 只是不tick和不渲染,对象还在。过度后旧的就看不见了。半透应该分不同层级,在不同的栈。
UCommonActivatableWidget 还提供了可选的激活/反激活可见性控制:
// 默认都是关闭的(false)
bool bSetVisibilityOnActivated = false;
ESlateVisibility ActivatedVisibility = ESlateVisibility::SelfHitTestInvisible;
bool bSetVisibilityOnDeactivated = false;
ESlateVisibility DeactivatedVisibility = ESlateVisibility::Collapsed;
ESC菜单,再开设置界面,一个back全关闭,距离流程合理吗?不是一个回到栈的上一个显示出来吗?
在 before push的时候,deactivated 了 ESC界面。由于是顶,自己触发了销毁。
具体逻辑在
DisplayedWidget->OnDeactivated().AddUObject(
this, &HandleActiveWidgetDeactivated, ToRawPtr(DisplayedWidget));
void HandleActiveWidgetDeactivated(UCommonActivatableWidget* DeactivatedWidget)
{
// 转到前一个 slot
if (DeactivatedWidget == DisplayedWidget && MySwitcher->GetActiveWidgetIndex() > 0)
{
DisplayedWidget->OnDeactivated().RemoveAll(this);
MySwitcher->TransitionToIndex(MySwitcher->GetActiveWidgetIndex() - 1); // 回退
}
}
// 清除所有高于当前 index 的 slot → ReleaseWidget → 从 WidgetList 移除 + 回收到 Pool
while (MySwitcher->GetNumWidgets() - 1 > ActiveWidgetIndex)
ReleaseWidget(...); // ← 这里真正移除并回收
// 激活新的栈顶
DisplayedWidget = ...;
DisplayedWidget->ActivateWidget();
如果去掉这个deactivated就是正常的 回到上一级。