LyraLog1 Lyra中的UI笔记1

我们先来理一理这其中有多少个类

UIManagerSubsystem

通过ini配置 ULyraUIManagerSubsystem

[/Script/LyraGame.LyraUIManagerSubsystem]
DefaultUIPolicyClass=/Game/UI/B_LyraUIPolicy.B_LyraUIPolicy_C

同时要配置一个 UGameUIPolicy 的子类 B_LyraUIPolicy_C

这个policy是必须的,否则你用了lyra的插件会崩

ULyraUIManagerSubsystem=>UGameUIManagerSubsystem=>UGameInstanceSubsystem

lyra项目=>lyra插件=>引擎

这个类存在的意义就是配置 UGameUIPolicy 这个类,然后NewObject,并在

NotifyPlayerAdded,NotifyPlayerRemoved,NotifyPlayerDestroyed 转发事件

这三个事件是从 UCommonGameInstance 转发过来的(lyra common game插件)

UCommonGameInstance重写了GameInstance的AddLocalPlayer,RemoveLocalPlayer做了转发

除此之外UCommonGameInstance处理了game session的逻辑,此时我们关注UI先忽略

UCLASS(MinimalAPI, Abstract, config = Game)
class UGameUIManagerSubsystem : public UGameInstanceSubsystem
{
public:
    UE_API virtual void NotifyPlayerAdded(UCommonLocalPlayer* LocalPlayer);
    UE_API virtual void NotifyPlayerRemoved(UCommonLocalPlayer* LocalPlayer);
    UE_API virtual void NotifyPlayerDestroyed(UCommonLocalPlayer* LocalPlayer);
private:
    UPROPERTY(Transient)
    TObjectPtr<UGameUIPolicy> CurrentPolicy = nullptr;

    UPROPERTY(config, EditAnywhere)
    TSoftClassPtr<UGameUIPolicy> DefaultUIPolicyClass;

lyra这个只多了一个逻辑,在tick中检查。

根据HUD->bShowHUD修改 RootLayout 的可见性

bShowHUD 什么时候会改呢?比如 ADebugCameraController::ToggleDisplay

UCLASS()
class ULyraUIManagerSubsystem : public UGameUIManagerSubsystem
{
private:
    bool Tick(float DeltaTime);
    void SyncRootLayoutVisibilityToShowHUD();
}

去掉一堆check,HUD->bShowHUD 变成 bShouldShowUI

if (UPrimaryGameLayout* RootLayout = Policy->GetRootLayout(CastChecked<UCommonLocalPlayer>(LocalPlayer)))
{
    const ESlateVisibility DesiredVisibility = bShouldShowUI ? ESlateVisibility::SelfHitTestInvisible : ESlateVisibility::Collapsed;
    if (DesiredVisibility != RootLayout->GetVisibility())
    {
        RootLayout->SetVisibility(DesiredVisibility);    
    }
}

UPrimaryGameLayout* RootLayout是个啥东西呢?

他指的是这个界面。

这个界面蓝图是 UPrimaryGameLayout 的子类

空的,只有4个 UCommonActivatableWidgetContainerBase,分别是四个层级的名字

他是由 UGameUIPolicy 关联的

UCLASS(MinimalAPI, Abstract, Blueprintable, Within = GameUIManagerSubsystem)
class UGameUIPolicy : public UObject
{
    GENERATED_BODY()
private:
    UPROPERTY(EditAnywhere)
    TSoftClassPtr<UPrimaryGameLayout> LayoutClass;
};

那么 UPrimaryGameLayout 是什么呢?额,就是上面那个界面。他的父类是 UCommonUserWidget

UCommonUserWidget 是CommonUI 这个引擎插件的内容,他的父类是 UUserWidget

是这个插件下UI类的基类

UCommonUserWidget

分析

看一个类先看属性,其他方法一般是针对属性的操作

UCLASS(MinimalAPI, ClassGroup = UI, meta = (Category = "Common UI", DisableNativeTick))
class UCommonUserWidget : public UUserWidget
{
    // ...

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, meta = (AllowPrivateAccess = true))
    bool bDisplayInActionBar = false;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, meta = (AllowPrivateAccess = true))
    bool bConsumePointerInput = false;

    TArray<FUIActionBindingHandle> ActionBindings;

    TArray<TWeakObjectPtr<const UWidget>> ScrollRecipients;
};

bDisplayInActionBar:

在 CommonUI 中,通常会有一个 操作栏(Common Bound Action Bar),位于屏幕底部或其他位置,自动显示当前可用的按键提示(例如:[A] 确认 [B] 返回)。例如lyra的设置界面就true了。

比如你去看 /Game/UI/Settings/W_LyraSettingScreen.W_LyraSettingScreen 这个设置界面的类设置

具体还要看看 UCommonBoundActionBar 的逻辑

bConsumePointerInput:

如果设置了,大多数事件不会继续向父类传递,达到屏蔽按键的功能。这种函数有若干

FReply UCommonUserWidget::NativeOnMouseButtonUp(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
    return bConsumePointerInput ? FReply::Handled() : Super::NativeOnMouseButtonUp(InGeometry, InMouseEvent);
}

ScrollRecipients 滚动接受者,当玩家使用手柄右摇杆(或配置的其他滚动轴)时,CommonUI 会自动让这个子控件进行滚动,而不需要你手动去 Tick 里写滚动逻辑。

ActionBindings:

这个我感觉还是比较常用的,在lyra中

RegisterUIActionBinding(FBindUIActionArgs(FUIActionTag::ConvertChecked(TAG_UI_ACTION_ESCAPE), false, FSimpleDelegate::CreateUObject(this, &ThisClass::HandleEscapeAction)));

RegisterUIActionBinding(FBindUIActionArgs(按键的gameplay tag, bDisplayInActionBar, 回调函数);

比如在项目设置-> Common UI Input Settings 设置了ESC会触发这个tag,注册了这个就会跑这个回调函数

总结

UserWidget是UE所有UI的基类

UCommonUserWidget 是 CommonUI 概念中所有UI的基类。封转了

  • 按键直接触发 gameplay tag 事件,再到回调的流程
  • 比较方便的UI输入屏蔽,不再传递给父类
  • 滚动控件功能的强化,原来只能拖动,现在手柄也能触发

回到他的子类 UPrimaryGameLayout

UPrimaryGameLayout

我们叫他根布局吧 RootLayout

前面我们看到这是一个几乎空的蓝图,里面是4个层级栈

UCLASS(MinimalAPI, Abstract, meta = (DisableNativeTick))
class UPrimaryGameLayout : public UCommonUserWidget
{
    GENERATED_BODY()

private:
    // 是否休眠,逻辑是注释的,先忽略这个变量吧
    bool bIsDormant = false;

    // Lets us keep track of all suspended input tokens so that multiple async UIs can be loading and we correctly suspend
    // for the duration of all of them.
    TArray<FName> SuspendInputTokens;

    // The registered layers for the primary layout.
    UPROPERTY(Transient, meta = (Categories = "UI.Layer"))
    TMap<FGameplayTag, TObjectPtr<UCommonActivatableWidgetContainerBase>> Layers;
};

Layer的管理

SuspendInputTokens 是一个Tarray

TMap<FGameplayTag, TObjectPtr> Layers;

前面我们说过这个 UI 上有4个栈控件。他的蓝图会调用注册

void UPrimaryGameLayout::RegisterLayer(FGameplayTag LayerTag, UCommonActivatableWidgetContainerBase* LayerWidget)
{
    if (!IsDesignTime())
    {
        LayerWidget->OnTransitioningChanged.AddUObject(this, &UPrimaryGameLayout::OnWidgetStackTransitioning);
        LayerWidget->SetTransitionDuration(0.0);
        Layers.Add(LayerTag, LayerWidget);
    }
}

绑定了回调到 OnWidgetStackTransitioning

然后加到这个TArray,这个代理什么时候触发呢?

OnTransitioningChanged 是 CommonUI 的容器控件(UCommonActivatableWidgetContainerBase 或其底层的 Switcher)暴露的一个事件委托。

它通知外界“界面切换动画的状态发生了改变”。

它携带一个 bool 参数:过度动画开始和结束

在lyra中

PushContentToLayer

触发 OnTransitioningChanged(true) -> 动画开始播放(例如淡入淡出、滑动)。

等待动画播放完毕。

触发 OnTransitioningChanged(false) -> 动画结束,新界面完全就绪。

这里设置了SetTransitionDuration(0.0),瞬切,但是流程是有的。

这个操作就是过度中不许玩家操作

void UPrimaryGameLayout::OnWidgetStackTransitioning(UCommonActivatableWidgetContainerBase* Widget, bool bIsTransitioning)
{
    if (bIsTransitioning)
    {
        const FName SuspendToken = UCommonUIExtensions::SuspendInputForPlayer(GetOwningLocalPlayer(), TEXT("GlobalStackTransion"));
        SuspendInputTokens.Add(SuspendToken);
    }
    else
    {
        if (ensure(SuspendInputTokens.Num() > 0))
        {
            const FName SuspendToken = SuspendInputTokens.Pop();
            UCommonUIExtensions::ResumeInputForPlayer(GetOwningLocalPlayer(), SuspendToken);
        }
    }
}

这里又引出了

UCommonUIExtensions,UCommonInputSubsystem 一会再来

还有 UCommonActivatableWidgetContainerBase 这个栈我们还没看过

其他接口

属性看好了,剩下的是

PushWidgetToLayerStack

PushWidgetToLayerStackAsync

去掉加载流程,最后都会走到这一步

if (UCommonActivatableWidgetContainerBase* Layer = GetLayerWidget(LayerName))
{
    return Layer->AddWidget<ActivatableWidgetT>(ActivatableWidgetClass, InitInstanceFunc);
}

GetPrimaryGameLayoutForPrimaryPlayer

一路拿到 UGameUIManagerSubsystem 再到 policy

最后

UPrimaryGameLayout* RootLayout = Policy->GetRootLayout(CommonLocalPlayer)

===

UGameUIPolicy

前面我们只知道配置了一个类 LayoutClass

然后 ULyraUIManagerSubsystem 转发事件 NotifyPlayer Add Remove Destoryed

UCLASS(MinimalAPI, Abstract, Blueprintable, Within = GameUIManagerSubsystem)
class UGameUIPolicy : public UObject
{
    GENERATED_BODY()

private:
    ELocalMultiplayerInteractionMode LocalMultiplayerInteractionMode = ELocalMultiplayerInteractionMode::PrimaryOnly;

    UPROPERTY(EditAnywhere)
    TSoftClassPtr<UPrimaryGameLayout> LayoutClass;

    UPROPERTY(Transient)
    TArray<FRootViewportLayoutInfo> RootViewportLayouts;

private:
    UE_API void NotifyPlayerAdded(UCommonLocalPlayer* LocalPlayer);
    UE_API void NotifyPlayerRemoved(UCommonLocalPlayer* LocalPlayer);
    UE_API void NotifyPlayerDestroyed(UCommonLocalPlayer* LocalPlayer);
};

本质上是玩家创建的时候

if (FRootViewportLayoutInfo* LayoutInfo = RootViewportLayouts.FindByKey(LocalPlayer))
{
    AddLayoutToViewport(LocalPlayer, LayoutInfo->RootLayout);
    LayoutInfo->bAddedToViewport = true;
}
else
{
    CreateLayoutWidget(LocalPlayer);
}

如果对于这个localplayer是第一次

void UGameUIPolicy::CreateLayoutWidget(UCommonLocalPlayer* LocalPlayer)
{
    if (APlayerController* PlayerController = LocalPlayer->GetPlayerController(GetWorld()))
    {
        // 这个就是我们配置的 layout class 加载出来
        TSubclassOf<UPrimaryGameLayout> LayoutWidgetClass = GetLayoutWidgetClass(LocalPlayer);
        if (ensure(LayoutWidgetClass && !LayoutWidgetClass->HasAnyClassFlags(CLASS_Abstract)))
        {
            // 然后 CreateWidget,加入 RootViewportLayouts 引用一下
            UPrimaryGameLayout* NewLayoutObject = CreateWidget<UPrimaryGameLayout>(PlayerController, LayoutWidgetClass);
            RootViewportLayouts.Emplace(LocalPlayer, NewLayoutObject, true);

            // 然后加到视口
            AddLayoutToViewport(LocalPlayer, NewLayoutObject);
        }
    }
}

AddToPlayerScreen 很眼熟了,老版本我们用的就是CreateWidget 和 AddToViewport/AddToPlayerScreen

void UGameUIPolicy::AddLayoutToViewport(UCommonLocalPlayer* LocalPlayer, UPrimaryGameLayout* Layout)
{
    UE_LOG(LogCommonGame, Log, TEXT("[%s] is adding player [%s]'s root layout [%s] to the viewport"), *GetName(), *GetNameSafe(LocalPlayer), *GetNameSafe(Layout));

    Layout->SetPlayerContext(FLocalPlayerContext(LocalPlayer));
    Layout->AddToPlayerScreen(1000);

    OnRootLayoutAddedToViewport(LocalPlayer, Layout);
}

总结:

我们定义了UIManagerSubSystem 这是GameInstance SubSystem。

UIManagerSubSystem 配置 UI Policy。

UI Policy中又配置了根布局,那4个栈的类

通过重写GameInstance的 Add/Remove LocalPlayer 把相关事件一路转发给policy。

创建和销毁这个根布局(这个过程本质上是CreateWidget和AddToPlayerScreen)

UCommonActivatableWidgetContainerBase

这是4个栈,其实这是容器基类。UI哪里点过来是这个类,他还有

UCommonActivatableWidgetStack 和 UCommonActivatableWidgetQueue 两个子类

Slate相关

先看属性

UCLASS(MinimalAPI, Abstract)
class UCommonActivatableWidgetContainerBase : public UWidget
{
    // ...

    /** The type of transition to play between widgets */
    UPROPERTY(EditAnywhere, Category = Transition)
    ECommonSwitcherTransition TransitionType;

    /** The curve function type to apply to the transition animation */
    UPROPERTY(EditAnywhere, Category = Transition)
    ETransitionCurve TransitionCurveType;

    /** The total duration of a single transition between widgets */
    UPROPERTY(EditAnywhere, Category = Transition)
    float TransitionDuration = 0.4f;

    /**
     * Whether to completely reset the pool of widgets when slate resources are released.
     * This usually happens when changing maps. You may not want to have all frontend screens loaded taking up memory while in game and vice versa.
     * Enabling this means widgets will have to be loaded again when re-entering the map next time.
     */
    UPROPERTY(EditAnywhere, Category = Performance)
    bool bResetPoolWhenReleasingSlateResources = false;

    /**
     * Controls how we will choose another widget if a transitioning widget is removed during the transition.
     * Note for Queues and Stacks, ECommonSwitcherTransitionFallbackStrategy::Previous is a good option.
     */
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Transition)
    ECommonSwitcherTransitionFallbackStrategy TransitionFallbackStrategy = ECommonSwitcherTransitionFallbackStrategy::None;

    // ...

    TSharedPtr<SOverlay> MyOverlay;
    TSharedPtr<SSpacer> MyInputGuard;
    TSharedPtr<SCommonAnimatedSwitcher> MySwitcher;
};

这些属性是这里用的,用于传递给底层Slate类 SCommonAnimatedSwitcher 它负责处理子界面的淡入淡出、滑动等动画。

https://www.cwlgame.cn/2024/06/25/ue-slate/

TSharedRef<SWidget> UCommonActivatableWidgetContainerBase::RebuildWidget()
{
    // slate 的写法就不看了
}

在我们 UUserWidget::AddToPlayerScreen 的时候

一路会跑到这里

bool UGameViewportSubsystem::AddToScreen(UWidget* Widget, ULocalPlayer* Player, FGameViewportWidgetSlot& Slot)
{
    SConstraintCanvas::FSlot* RawSlot = nullptr;

    // 构造...

    RawSlot->AttachWidget(Widget->TakeWidget());
}

UWidget::TakeWidget=>UWidget::TakeWidget_Private=>RebuildWidget 这样调用

Slate是UE底层的C++ UI框架,UserWidget是用户层的封装。

所有Slate都是 swidget的子类,通过 OnPaint 的参数 FGeometry& AllottedGeometry 返回几何体给渲染流程。

===

AddWidget

    UPROPERTY(Transient)
    TArray<TObjectPtr<UCommonActivatableWidget>> WidgetList;

    UPROPERTY(Transient)
    TObjectPtr<UCommonActivatableWidget> DisplayedWidget;

    UPROPERTY(Transient)
    FUserWidgetPool GeneratedWidgetsPool;

外部调用这个容器都是走 AddWidget

从UMG控件对象池中get or get ,然后加到 WidgetList

UCommonActivatableWidget* UCommonActivatableWidgetContainerBase::AddWidgetInternal(TSubclassOf<UCommonActivatableWidget> ActivatableWidgetClass, TFunctionRef<void(UCommonActivatableWidget&)> InitFunc)
{
    if (UCommonActivatableWidget* WidgetInstance = GeneratedWidgetsPool.GetOrCreateInstance(ActivatableWidgetClass))
    {
        InitFunc(*WidgetInstance);
        RegisterInstanceInternal(*WidgetInstance);
        return WidgetInstance;
    }
    return nullptr;
}

void UCommonActivatableWidgetContainerBase::RegisterInstanceInternal(UCommonActivatableWidget& NewWidget)
{
    UE_LOG(LogCommonUI, VeryVerbose, TEXT("UCommonActivatableWidgetContainerBase::RegisterInstanceInternal() NewWidget: %s"), *NewWidget.GetName());

    // @TODO: Log if bAutoActivate is true on the provided widget, since it quite simply makes no sense.
    if (ensure(!WidgetList.Contains(&NewWidget)))
    {
        WidgetList.Add(&NewWidget);
        OnWidgetAddedToList(NewWidget);
    }
}

而这个 DisplayedWidget 是显示的widget指针

在激活的UI变化的时候构建

void UCommonActivatableWidgetContainerBase::HandleActiveIndexChanged(int32 ActiveWidgetIndex)
{
    // ...

    DisplayedWidget = ActivatableWidgetFromSlate(MySwitcher->GetActiveWidget());

}

释放

    TArray<TSharedPtr<SWidget>> ReleasedWidgets;

    bool bRemoveDisplayedWidgetPostTransition = false;

    mutable FOnDisplayedWidgetChanged OnDisplayedWidgetChangedEvent;

注释说这里临界情况有点复杂,他希望被销毁的控件在内存中延迟一帧,所以有了这几个变量,另外是当做变化时候的代理

他的两个子类只是修改了维护切换的方式是栈还是队列。

总结

维护了一个栈中多个 UCommonActivatableWidget 的push, pop。过度。当前显示的界面。

上一篇
下一篇