Lyra UI 继续

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 FilteredBindings 这里断点,如果没找到按钮)

=》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就是正常的 回到上一级。

上一篇
下一篇